diff --git a/pyproject.toml b/pyproject.toml index ed03a16..5f6039a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ select = ["E", "F", "I"] unfixable = ["F841"] [tool.ruff.per-file-ignores] -"__init__.py" = ["F403", "E402"] +"__init__.py" = ["F403", "E402", "F401"] [tool.ruff.isort] known-first-party = ["ln2fill"] diff --git a/src/ln2fill/__main__.py b/src/ln2fill/__main__.py index 49291c4..c7ce9e2 100644 --- a/src/ln2fill/__main__.py +++ b/src/ln2fill/__main__.py @@ -8,9 +8,12 @@ from __future__ import annotations +import asyncio import datetime import logging import pathlib +import signal +import sys import warnings from typing import Unpack @@ -166,7 +169,7 @@ def update_options( async def handle_fill(**options: Unpack[OptionsType]): """Handles the purge/fill process.""" - from ln2fill.core import LN2Handler + from ln2fill.handlers import LN2Handler if options["quiet"]: log.sh.setLevel(logging.ERROR) @@ -193,6 +196,18 @@ async def handle_fill(**options: Unpack[OptionsType]): await handler.check() + async def signal_handler(): + log.error("User aborted the process. Closing all valves before exiting.") + await handler.abort(only_active=False) + log.error("All valves closed. Exiting.") + asyncio.get_running_loop().call_soon(sys.exit, 0) + + for signame in ("SIGINT", "SIGTERM"): + asyncio.get_running_loop().add_signal_handler( + getattr(signal, signame), + lambda: asyncio.create_task(signal_handler()), + ) + max_purge_time = options["purge_time"] or options["max_purge_time"] await handler.purge( use_thermistor=options["use_thermistors"], @@ -201,6 +216,13 @@ async def handle_fill(**options: Unpack[OptionsType]): prompt=not options["no_prompt"], ) + max_fill_time = options["fill_time"] or options["max_fill_time"] + await handler.fill( + use_thermistors=options["use_thermistors"], + min_fill_time=options["min_fill_time"], + max_fill_time=max_fill_time, + prompt=not options["no_prompt"], + ) @click.command(name="ln2fill") @click.argument( diff --git a/src/ln2fill/handlers/__init__.py b/src/ln2fill/handlers/__init__.py new file mode 100644 index 0000000..0f4d775 --- /dev/null +++ b/src/ln2fill/handlers/__init__.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# @Author: José Sánchez-Gallego (gallegoj@uw.edu) +# @Date: 2023-11-26 +# @Filename: __init__.py +# @License: BSD 3-clause (http://www.opensource.org/licenses/BSD-3-Clause) + +from __future__ import annotations + +from .ln2 import LN2Handler +from .thermistor import ThermistorHandler +from .valve import ValveHandler diff --git a/src/ln2fill/core.py b/src/ln2fill/handlers/ln2.py similarity index 56% rename from src/ln2fill/core.py rename to src/ln2fill/handlers/ln2.py index 3396b43..35ece01 100644 --- a/src/ln2fill/core.py +++ b/src/ln2fill/handlers/ln2.py @@ -2,287 +2,29 @@ # -*- coding: utf-8 -*- # # @Author: José Sánchez-Gallego (gallegoj@uw.edu) -# @Date: 2023-11-11 -# @Filename: ln2fill.py +# @Date: 2023-11-26 +# @Filename: ln2.py # @License: BSD 3-clause (http://www.opensource.org/licenses/BSD-3-Clause) from __future__ import annotations import asyncio -import dataclasses -from time import time -from typing import Coroutine, Optional +from typing import Coroutine import sshkeyboard -from pydantic import Field, model_validator from pydantic.dataclasses import dataclass -from rich.progress import TaskID from sdsstools.configuration import RecursiveDict from ln2fill import config, log +from ln2fill.handlers.valve import ValveHandler from ln2fill.tools import ( - TimerProgressBar, - cancel_nps_threads, - cancel_task, get_spectrograph_status, read_thermistors, - valve_on_off, ) -PROGRESS_BAR = TimerProgressBar(console=log.rich_console) - - -@dataclass -class ThermistorHandler: - """Reads a thermistor, potentially closing the valve if active. - - Parameters - ---------- - valve_handler - The `.ValveHandler` instance associated with this thermistor. - interval - The interval in seconds between thermistor reads. - min_open_time - The minimum valve open time. The thermistor will not be read until - the minimum time has been reached. - close_valve - If ``True``, closes the valve once the thermistor is active. - min_active_time - Number of seconds the thermistor must be active before the valve is - closed. - - """ - - valve_handler: ValveHandler - interval: float = 1 - min_open_time: float = Field(default=0.0, ge=0.0) - close_valve: bool = True - min_active_time: float = Field(default=10.0, ge=0.0) - - def __init__(self, *args, **kwargs): - self.valve_handler = self.valve_handler - - # Unix time at which we started to monitor. - self._start_time: float = -1 - - # Unix time at which the thermistor became active. - self._active_time: float = -1 - - thermistor_channel = self.valve_handler.thermistor_channel - assert thermistor_channel - - self.thermistor_channel: str = thermistor_channel - - async def read_thermistor(self): - """Reads the thermistor and returns its state.""" - - thermistors = await read_thermistors() - return thermistors[self.thermistor_channel] - - async def start_monitoring(self): - """Monitors the thermistor and potentially closes the valve.""" - - await asyncio.sleep(self.min_open_time) - - while True: - thermistor_status = await self.read_thermistor() - if thermistor_status is True: - if self._active_time < 0: - self._active_time = time.time() - if time.time() - self._active_time > self.min_active_time: - break - - await asyncio.sleep(self.interval) - - if self.close_valve: - await self.valve_handler.finish_fill() - - -@dataclass -class ValveHandler: - """Handles a valve, including opening and closing, timeouts, and thermistors. - - Parameters - ---------- - valve - The name of the valve. Additional information such as thermistor channel, - actor names, etc. are determined from the configuration file if not - explicitely provided. - thermistor_channel - The channel of the thermistor associated with the valve. - nps_actor - The NPS actor that command the valve. - show_progress_bar - Whether to show a progress bar during fills. - - """ - - valve: str - thermistor_channel: Optional[str] = dataclasses.field(default=None, repr=False) - nps_actor: Optional[str] = dataclasses.field(default=None, repr=False) - show_progress_bar: Optional[bool] = dataclasses.field(default=True, repr=False) - - @model_validator(mode="after") - def validate_fields(self): - if self.valve not in config["valves"]: - raise ValueError(f"Unknown valve {self.valve!r}.") - - if self.thermistor_channel is None: - thermistor_key = f"valves.{self.valve}.thermistor" - self.thermistor_channel = config.get(thermistor_key, self.valve) - - if self.nps_actor is None: - self.nps_actor = config.get(f"valves.{self.valve}.actor") - if self.nps_actor is None: - raise ValueError(f"Cannot find NPS actor for valve {self.valve!r}.") - - return self - - def __post_init__(self): - self.thermistor = ThermistorHandler(self) - - self._thread_id: int | None = None - self._progress_bar_id: TaskID | None = None - - self._monitor_task: asyncio.Task | None = None - self._timeout_task: asyncio.Task | None = None - - self.event = asyncio.Event() - self.active: bool = False - - async def start_fill( - self, - min_open_time: float = 0.0, - timeout: float | None = None, - use_thermistor: bool = True, - ): - """Starts a fill. - - Parameters - ---------- - min_open_time - Minimum time to keep the valve open. - timeout - The maximumtime to keep the valve open. If ``None``, defaults to - the ``max_open_time`` value. - use_thermistor - Whether to use the thermistor to close the valve. If ``True`` and - ``fill_time`` is not ``None``, ``fill_time`` become the maximum - open time. - - """ - - if timeout is None: - # Some hardcoded hard limits. - if self.valve == "purge": - timeout = 2000 - else: - timeout = 600 - - await self._set_state(True, timeout=timeout, use_script=True) - - if use_thermistor: - self.thermistor.min_open_time = min_open_time - self._monitor_task = asyncio.create_task(self.thermistor.start_monitoring()) - - if self.show_progress_bar: - if self.valve.lower() == "purge": - initial_description = "Purge in progress ..." - complete_description = "Purge complete" - else: - initial_description = "Fill in progress ..." - complete_description = "Fill complete" - - self._progress_bar_id = await PROGRESS_BAR.add_timer( - timeout, - label=self.valve, - initial_description=initial_description, - complete_description=complete_description, - ) - - await asyncio.sleep(2) - self._timeout_task = asyncio.create_task(self._schedule_timeout_task(timeout)) - - self.active = True - - self.event.clear() - await self.event.wait() - - async def _schedule_timeout_task(self, timeout: float): - """Schedules a task to cancel the fill after a timeout.""" - - await asyncio.sleep(timeout) - await self.finish_fill() - - async def finish_fill(self): - """Finishes the fill, closing the valve.""" - - await self._set_state(False) - - await cancel_task(self._monitor_task) - self._monitor_task = None - - if self._progress_bar_id is not None: - await PROGRESS_BAR.stop_timer(self._progress_bar_id) - self._progress_bar_id = None - - await cancel_task(self._timeout_task) - self._timeout_task = None - - if not self.event.is_set(): - self.event.set() - - self.active = False - - async def _set_state( - self, - on: bool, - use_script: bool = True, - timeout: float | None = None, - ): - """Sets the state of the valve. - - Parameters - ---------- - on - Whether to open or close the valve. - use_script - Whether to use the ``cycle_with_timeout`` script to set a timeout - after which the valve will be closed. - timeout - Maximum open valve time. - - """ - - assert self.nps_actor is not None - - # If there's already a thread running for this valve, we cancel it. - if self._thread_id is not None: - await cancel_nps_threads(self.nps_actor, self._thread_id) - - thread_id = await valve_on_off( - self.valve, - on, - timeout=timeout, - use_script=use_script, - ) - - if thread_id is not None: - self._thread_id = thread_id - - if on: - if thread_id is not None: - log.debug( - f"Valve {self.valve!r} was opened with timeout={timeout} " - f"(thread_id={thread_id})." - ) - else: - log.debug(f"Valve {self.valve!r} was closed.") - - @dataclass class LN2Handler: """The main LN2 purge/fill handlerclass. diff --git a/src/ln2fill/handlers/thermistor.py b/src/ln2fill/handlers/thermistor.py new file mode 100644 index 0000000..2ae9247 --- /dev/null +++ b/src/ln2fill/handlers/thermistor.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# @Author: José Sánchez-Gallego (gallegoj@uw.edu) +# @Date: 2023-11-26 +# @Filename: thermistor.py +# @License: BSD 3-clause (http://www.opensource.org/licenses/BSD-3-Clause) + +from __future__ import annotations + +import asyncio +from time import time + +from pydantic import Field +from pydantic.dataclasses import dataclass + +from ln2fill.handlers.valve import ValveHandler +from ln2fill.tools import read_thermistors + + +@dataclass +class ThermistorHandler: + """Reads a thermistor, potentially closing the valve if active. + + Parameters + ---------- + valve_handler + The `.ValveHandler` instance associated with this thermistor. + interval + The interval in seconds between thermistor reads. + min_open_time + The minimum valve open time. The thermistor will not be read until + the minimum time has been reached. + close_valve + If ``True``, closes the valve once the thermistor is active. + min_active_time + Number of seconds the thermistor must be active before the valve is + closed. + + """ + + valve_handler: ValveHandler + interval: float = 1 + min_open_time: float = Field(default=0.0, ge=0.0) + close_valve: bool = True + min_active_time: float = Field(default=10.0, ge=0.0) + + def __init__(self, *args, **kwargs): + self.valve_handler = self.valve_handler + + # Unix time at which we started to monitor. + self._start_time: float = -1 + + # Unix time at which the thermistor became active. + self._active_time: float = -1 + + thermistor_channel = self.valve_handler.thermistor_channel + assert thermistor_channel + + self.thermistor_channel: str = thermistor_channel + + async def read_thermistor(self): + """Reads the thermistor and returns its state.""" + + thermistors = await read_thermistors() + return thermistors[self.thermistor_channel] + + async def start_monitoring(self): + """Monitors the thermistor and potentially closes the valve.""" + + await asyncio.sleep(self.min_open_time) + + while True: + thermistor_status = await self.read_thermistor() + if thermistor_status is True: + if self._active_time < 0: + self._active_time = time.time() + if time.time() - self._active_time > self.min_active_time: + break + + await asyncio.sleep(self.interval) + + if self.close_valve: + await self.valve_handler.finish_fill() diff --git a/src/ln2fill/handlers/valve.py b/src/ln2fill/handlers/valve.py new file mode 100644 index 0000000..4edf522 --- /dev/null +++ b/src/ln2fill/handlers/valve.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# @Author: José Sánchez-Gallego (gallegoj@uw.edu) +# @Date: 2023-11-26 +# @Filename: valve.py +# @License: BSD 3-clause (http://www.opensource.org/licenses/BSD-3-Clause) + +from __future__ import annotations + +import asyncio +import dataclasses + +from typing import Optional + +from pydantic import model_validator +from pydantic.dataclasses import dataclass +from rich.progress import TaskID + +from ln2fill import config, log +from ln2fill.tools import ( + TimerProgressBar, + cancel_nps_threads, + cancel_task, + valve_on_off, +) + + +PROGRESS_BAR = TimerProgressBar(console=log.rich_console) + + +@dataclass +class ValveHandler: + """Handles a valve, including opening and closing, timeouts, and thermistors. + + Parameters + ---------- + valve + The name of the valve. Additional information such as thermistor channel, + actor names, etc. are determined from the configuration file if not + explicitely provided. + thermistor_channel + The channel of the thermistor associated with the valve. + nps_actor + The NPS actor that command the valve. + show_progress_bar + Whether to show a progress bar during fills. + + """ + + valve: str + thermistor_channel: Optional[str] = dataclasses.field(default=None, repr=False) + nps_actor: Optional[str] = dataclasses.field(default=None, repr=False) + show_progress_bar: Optional[bool] = dataclasses.field(default=True, repr=False) + + @model_validator(mode="after") + def validate_fields(self): + if self.valve not in config["valves"]: + raise ValueError(f"Unknown valve {self.valve!r}.") + + if self.thermistor_channel is None: + thermistor_key = f"valves.{self.valve}.thermistor" + self.thermistor_channel = config.get(thermistor_key, self.valve) + + if self.nps_actor is None: + self.nps_actor = config.get(f"valves.{self.valve}.actor") + if self.nps_actor is None: + raise ValueError(f"Cannot find NPS actor for valve {self.valve!r}.") + + return self + + def __post_init__(self): + from ln2fill.handlers.thermistor import ThermistorHandler + + self.thermistor = ThermistorHandler(self) + + self._thread_id: int | None = None + self._progress_bar_id: TaskID | None = None + + self._monitor_task: asyncio.Task | None = None + self._timeout_task: asyncio.Task | None = None + + self.event = asyncio.Event() + self.active: bool = False + + async def start_fill( + self, + min_open_time: float = 0.0, + timeout: float | None = None, + use_thermistor: bool = True, + ): + """Starts a fill. + + Parameters + ---------- + min_open_time + Minimum time to keep the valve open. + timeout + The maximumtime to keep the valve open. If ``None``, defaults to + the ``max_open_time`` value. + use_thermistor + Whether to use the thermistor to close the valve. If ``True`` and + ``fill_time`` is not ``None``, ``fill_time`` become the maximum + open time. + + """ + + if timeout is None: + # Some hardcoded hard limits. + if self.valve == "purge": + timeout = 2000 + else: + timeout = 600 + + await self._set_state(True, timeout=timeout, use_script=True) + + if use_thermistor: + self.thermistor.min_open_time = min_open_time + self._monitor_task = asyncio.create_task(self.thermistor.start_monitoring()) + + if self.show_progress_bar: + if self.valve.lower() == "purge": + initial_description = "Purge in progress ..." + complete_description = "Purge complete" + else: + initial_description = "Fill in progress ..." + complete_description = "Fill complete" + + self._progress_bar_id = await PROGRESS_BAR.add_timer( + timeout, + label=self.valve, + initial_description=initial_description, + complete_description=complete_description, + ) + + await asyncio.sleep(2) + self._timeout_task = asyncio.create_task(self._schedule_timeout_task(timeout)) + + self.active = True + + self.event.clear() + await self.event.wait() + + async def _schedule_timeout_task(self, timeout: float): + """Schedules a task to cancel the fill after a timeout.""" + + await asyncio.sleep(timeout) + await self.finish_fill() + + async def finish_fill(self): + """Finishes the fill, closing the valve.""" + + await self._set_state(False) + + await cancel_task(self._monitor_task) + self._monitor_task = None + + if self._progress_bar_id is not None: + await PROGRESS_BAR.stop_timer(self._progress_bar_id) + self._progress_bar_id = None + + await cancel_task(self._timeout_task) + self._timeout_task = None + + if not self.event.is_set(): + self.event.set() + + self.active = False + + async def _set_state( + self, + on: bool, + use_script: bool = True, + timeout: float | None = None, + ): + """Sets the state of the valve. + + Parameters + ---------- + on + Whether to open or close the valve. + use_script + Whether to use the ``cycle_with_timeout`` script to set a timeout + after which the valve will be closed. + timeout + Maximum open valve time. + + """ + + assert self.nps_actor is not None + + # If there's already a thread running for this valve, we cancel it. + if self._thread_id is not None: + await cancel_nps_threads(self.nps_actor, self._thread_id) + + thread_id = await valve_on_off( + self.valve, + on, + timeout=timeout, + use_script=use_script, + ) + + if thread_id is not None: + self._thread_id = thread_id + + if on: + if thread_id is not None: + log.debug( + f"Valve {self.valve!r} was opened with timeout={timeout} " + f"(thread_id={thread_id})." + ) + else: + log.debug(f"Valve {self.valve!r} was closed.")