From 8e845d268a442d52f9ce9f4f29405c80673c6681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20S=C3=A1nchez-Gallego?= Date: Fri, 29 Nov 2024 22:14:28 -0800 Subject: [PATCH] Transparency monitor (#39) * Initial implementation without actual data evaluation * Initial implementation without actual data evaluation * Merge branch 'albireox-transparency-monitor' of github.com:sdss/lvmgort into albireox-transparency-monitor * Merge branch 'main' into albireox-transparency-monitor * Merge branch 'main' into albireox-transparency-monitor * Calculate and report transparency status and trend but no action is taken yet * Update lock * Refresh transparency data every 60 seconds * Use updated transparency API query * Try-except post-exposure routine * Add command to report transparency status * Fix import of TransparencyQuality and simplify code * Fix reference to column name in transparency monitor * Fix handling of cancelled observation when the exposure is taken * Merge branch 'main' into albireox-transparency-monitor * Merge branch 'main' into albireox-transparency-monitor * Merge branch 'main' into albireox-transparency-monitor * Use median of last 10 minutes for zp and transparency quality * Implement transparency monitor * Merge branch 'main' into albireox-transparency-monitor * Merge branch 'main' into albireox-transparency-monitor * Add retries to NPS commands * Force refresh guider status before guiding or focusing * Log sci transparency during monitoring * Report transparency quality flag name, not value * Add start/stop-transparency commands * Merge branch 'main' into albireox-transparency-monitor * Do not alert of open door * Release dome lock if dome fails to move * Add LOCKED alert to the list of active alerts * Add timeout for non-critical tasks in handle_unsafe * Better handling of failed or cancelled calibrations * Update CHANGELOG.md --- CHANGELOG.md | 1 + src/gort/devices/guider.py | 11 + src/gort/etc/actor_schema.json | 15 ++ src/gort/observer.py | 2 +- src/gort/overwatcher/__init__.py | 1 + src/gort/overwatcher/actor/commands.py | 75 ++++++ src/gort/overwatcher/alerts.py | 14 +- src/gort/overwatcher/calibration.py | 36 +-- src/gort/overwatcher/helpers/dome.py | 5 + src/gort/overwatcher/observer.py | 72 +++++- src/gort/overwatcher/overwatcher.py | 67 +++-- src/gort/overwatcher/transparency.py | 338 +++++++++++++++++++++++++ src/gort/tools.py | 12 +- 13 files changed, 597 insertions(+), 52 deletions(-) create mode 100644 src/gort/overwatcher/transparency.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d40741d..3be7e67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### 🚀 New * Add `observer schedule-focus-sweep` command to Overwatcher actor to schedule a focus sweep before the next tile. +* [#39](https://github.com/sdss/lvmgort/pull/39) Implement transparency monitoring. ### ✨ Improved diff --git a/src/gort/devices/guider.py b/src/gort/devices/guider.py index 8757883..18a5203 100644 --- a/src/gort/devices/guider.py +++ b/src/gort/devices/guider.py @@ -58,6 +58,13 @@ def telescope(self): return self.gort.telescopes[self.name] + async def update_status(self): + """Returns the guider status.""" + + status_reply = await self.actor.commands.status() + + return status_reply.flatten() + async def _status_cb(self, reply: AMQPReply): """Listens to guider keywords and updates the internal state.""" @@ -177,6 +184,8 @@ async def focus( await self.actor.commands.adjust_focus(reply_callback=reply_callback) return + await self.update_status() + if self.status & GuiderStatus.NON_IDLE: self.write_to_log( "Guider is not idle. Stopping it before focusing.", @@ -279,6 +288,8 @@ async def guide( self.separation = None + await self.update_status() + if self.status & GuiderStatus.NON_IDLE: raise GortGuiderError( "Guider is not IDLE", diff --git a/src/gort/etc/actor_schema.json b/src/gort/etc/actor_schema.json index fdf76fa..a306001 100644 --- a/src/gort/etc/actor_schema.json +++ b/src/gort/etc/actor_schema.json @@ -54,6 +54,21 @@ "stage": { "oneOf": [{ "type": "string" }, { "type": "null" }] }, "standard_no": { "oneOf": [{ "type": "number" }, { "type": "null" }] } } + }, + "transparency": { + "type": "object", + "properties": { + "telescope": { "type": "string" }, + "zero_point": { "oneOf": [{ "type": "number" }, { "type": "null" }] }, + "quality": { + "type": "string", + "enum": ["BAD", "POOR", "GOOD", "UNKNOWN"] + }, + "trend": { + "type": "string", + "enum": ["IMPROVING", "WORSENING", "FLAT", "UNKNOWN"] + } + } } }, "additionalProperties": false diff --git a/src/gort/observer.py b/src/gort/observer.py index c620040..208bd5b 100644 --- a/src/gort/observer.py +++ b/src/gort/observer.py @@ -463,7 +463,7 @@ async def observe_tile( except GortObserverCancelledError: write_log("Observation cancelled.", "warning") - failed = True + failed = len(exposures) == 0 except KeyboardInterrupt: write_log("Observation interrupted by user.", "warning") diff --git a/src/gort/overwatcher/__init__.py b/src/gort/overwatcher/__init__.py index ebff297..d7b9179 100644 --- a/src/gort/overwatcher/__init__.py +++ b/src/gort/overwatcher/__init__.py @@ -18,4 +18,5 @@ from .observer import ObserverOverwatcher from .overwatcher import Overwatcher from .safety import SafetyOverwatcher +from .transparency import TransparencyOverwatcher from .weather import WeatherOverwatcher diff --git a/src/gort/overwatcher/actor/commands.py b/src/gort/overwatcher/actor/commands.py index 499a331..c7e544e 100644 --- a/src/gort/overwatcher/actor/commands.py +++ b/src/gort/overwatcher/actor/commands.py @@ -8,6 +8,8 @@ from __future__ import annotations +import asyncio +import math import time from typing import TYPE_CHECKING, Any @@ -229,3 +231,76 @@ async def schedule_focus_sweep(command: OverwatcherCommand): command.actor.overwatcher.observer.force_focus = True return command.finish() + + +@overwatcher_cli.group() +def transparency(): + """Transparency commands.""" + + pass + + +@transparency.command(name="status") +async def transparency_status(command: OverwatcherCommand): + """Reports the transparency status of the science telescope.""" + + overwatcher = command.actor.overwatcher + transparency = overwatcher.transparency + + now = time.time() + if transparency.last_updated < now - 120: + command.warning("Transparency data is stale.") + return command.finish( + transparency={ + "telescope": "sci", + "mean_zp": None, + "quality": "unknown", + "trend": "unknown", + } + ) + + zp = transparency.zero_point["sci"] + + return command.finish( + transparency={ + "telescope": "sci", + "mean_zp": None if math.isnan(zp) else round(zp, 2), + "quality": transparency.get_quality_string("sci"), + "trend": transparency.get_trend_string("sci"), + } + ) + + +@transparency.command() +async def start_monitoring(command: OverwatcherCommand): + """Starts monitoring the transparency.""" + + overwatcher = command.actor.overwatcher + + if not overwatcher.transparency.is_monitoring(): + await overwatcher.transparency.start_monitoring() + command.info("Starting transparency monitoring.") + + elapsed: float = 0 + while True: + if not overwatcher.transparency.is_monitoring(): + return command.finish("Transparency monitoring has been stopped.") + + await asyncio.sleep(1) + elapsed += 1 + + if elapsed >= 30: + await command.child_command("transparency status") + elapsed = 0 + + +@transparency.command() +async def stop_monitoring(command: OverwatcherCommand): + """Stops monitoring the transparency.""" + + overwatcher = command.actor.overwatcher + + if overwatcher.transparency.is_monitoring(): + await overwatcher.transparency.stop_monitoring() + + return command.finish() diff --git a/src/gort/overwatcher/alerts.py b/src/gort/overwatcher/alerts.py index d427dc0..f342dc4 100644 --- a/src/gort/overwatcher/alerts.py +++ b/src/gort/overwatcher/alerts.py @@ -55,6 +55,7 @@ class ActiveAlert(enum.Flag): DOOR = enum.auto() CAMERA_TEMPERATURE = enum.auto() O2 = enum.auto() + LOCKED = enum.auto() UNKNOWN = enum.auto() @@ -139,12 +140,6 @@ def is_safe(self) -> tuple[bool, ActiveAlert]: self.log.warning("Alerts data not available. is_safe() returns False.") return False, ActiveAlert.UNKNOWN - # If we have issued a previous unsafe alert, the main task will close the dome - # and put a lock for 30 minutes to prevent the dome from opening/closing too - # frequently if the weather is unstable. - if self.locked_until > 0 and time() < self.locked_until: - return False, ActiveAlert(0) - is_safe: bool = True active_alerts = ActiveAlert(0) @@ -168,7 +163,6 @@ def is_safe(self) -> tuple[bool, ActiveAlert]: # These alerts are not critical but we log them. # TODO: maybe we do want to do something about these alerts. if self.state.door_alert: - self.log.warning("Door alert detected.") active_alerts |= ActiveAlert.DOOR if self.state.camera_temperature_alert: self.log.warning("Camera temperature alert detected.") @@ -177,6 +171,12 @@ def is_safe(self) -> tuple[bool, ActiveAlert]: self.log.warning("O2 alert detected.") active_alerts |= ActiveAlert.O2 + # If we have issued a previous unsafe alert, the main task will close the dome + # and put a lock for 30 minutes to prevent the dome from opening/closing too + # frequently if the weather is unstable. + if self.locked_until > 0 and time() < self.locked_until: + return False, active_alerts | ActiveAlert.LOCKED + if is_safe: self.locked_until = 0 diff --git a/src/gort/overwatcher/calibration.py b/src/gort/overwatcher/calibration.py index abc4877..53fead5 100644 --- a/src/gort/overwatcher/calibration.py +++ b/src/gort/overwatcher/calibration.py @@ -475,24 +475,26 @@ async def task(self): self.module.run_calibration(next_calibration) ) await self.module._calibration_task + except asyncio.CancelledError: - await notify( - f"Calibration {name} has been cancelled.", - level="warning", - ) - next_calibration.record_state( - CalibrationState.CANCELLED, - fail_reason="calibration cancelled by Overwatcher or user.", - ) + if not next_calibration.is_finished(): + await notify( + f"Calibration {name} has been cancelled.", + level="warning", + ) + next_calibration.record_state(CalibrationState.CANCELLED) + except Exception as ee: - await notify( - f"Error running calibration {name}: {ee}", - level="error", - ) - next_calibration.record_state( - CalibrationState.FAILED, - fail_reason=str(ee), - ) + if not next_calibration.is_finished(): + await notify( + f"Error running calibration {name}: {ee}", + level="error", + ) + next_calibration.record_state( + CalibrationState.FAILED, + fail_reason=str(ee), + ) + finally: if next_calibration.is_finished(): dome_closed = await self.module.overwatcher.dome.is_closing() @@ -711,6 +713,8 @@ async def cancel(self): await notify(f"Cancelling calibration {name}.", level="warning") self._calibration_task = await cancel_task(self._calibration_task) + running_calibration.record_state(CalibrationState.CANCELLED) + # Ensure we close the dome. This is allowed even # if the overwatcher is disabled. if running_calibration.model.close_dome_after: diff --git a/src/gort/overwatcher/helpers/dome.py b/src/gort/overwatcher/helpers/dome.py index 8664e5d..d30558b 100644 --- a/src/gort/overwatcher/helpers/dome.py +++ b/src/gort/overwatcher/helpers/dome.py @@ -180,6 +180,11 @@ async def _move( "it may be partially or fully open.", level="critical", ) + + # Release the lock here. force_disable() may require closing the dome. + if self._move_lock.locked(): + self._move_lock.release() + await self.overwatcher.force_disable() raise diff --git a/src/gort/overwatcher/observer.py b/src/gort/overwatcher/observer.py index 5537fc9..1b5d82f 100644 --- a/src/gort/overwatcher/observer.py +++ b/src/gort/overwatcher/observer.py @@ -19,6 +19,7 @@ from gort.exposure import Exposure from gort.overwatcher import OverwatcherModule from gort.overwatcher.core import OverwatcherModuleTask +from gort.overwatcher.transparency import TransparencyQuality from gort.tile import Tile from gort.tools import cancel_task, run_in_executor @@ -236,8 +237,6 @@ async def observe_loop_task(self): n_tile_positions = 0 while True: - exp: Exposure | bool = False - try: # Wait in case the troubleshooter is doing something. await self.overwatcher.troubleshooter.wait_until_ready(300) @@ -254,6 +253,8 @@ async def observe_loop_task(self): await self.check_focus(force=n_tile_positions == 0 or self.force_focus) for dpos in tile.dither_positions: + exp: Exposure | bool = False + await self.overwatcher.troubleshooter.wait_until_ready(300) if not self.check_twilight(): @@ -293,6 +294,14 @@ async def observe_loop_task(self): if result and len(exps) > 0: exp = exps[0] + try: + await self.post_exposure(exp) + except Exception as err: + await self.notify( + f"Failed to run post-exposure routine: {err}", + level="error", + ) + if self.is_cancelling: break @@ -402,3 +411,62 @@ async def pre_observe_checks(self): await self.gort.specs.reset() return True + + async def post_exposure(self, exp: Exposure | bool): + """Runs post-exposure checks.""" + + if exp is False: + raise GortError("No exposure was returned.") + + # Output transparency data for the last exposure. + transparency = self.overwatcher.transparency + transparency.write_to_log(["sci"]) + + if self._cancelling: + return + + if transparency.quality["sci"] & TransparencyQuality.BAD: + await self.notify( + "Transparency is bad. Stopping observations and starting " + "the transparency monitor.", + ) + + # If we reach twilight this will cause the overwatcher + # to immediately stop observations. + self.exposure_completes = 0 + + try: + await asyncio.wait_for( + transparency.start_monitoring(), + timeout=3600, + ) + + except asyncio.TimeoutError: + await self.notify("Transparency monitor timed out.", level="warning") + await self.overwatcher.shutdown( + reason="Transparency has been bad for over one hour.", + disable_overwatcher=True, + ) + + else: + # The transparency monitor has ended. There are two possible reasons: + + # - Something stopped the observing loop and with it the monitor. + # Do nothing and return. The main task will handle the rest. + if self._cancelling: + return + + # - The transparency is good and the monitor has ended. + if transparency.quality["sci"] & TransparencyQuality.GOOD: + await self.notify("Transparency is good. Resuming observations.") + return + + else: + await self.notify( + "Transparency is still bad but the monitor stopped. " + "Triggering shutdown.", + ) + await self.overwatcher.shutdown( + reason="Transparency monitor failed.", + disable_overwatcher=True, + ) diff --git a/src/gort/overwatcher/overwatcher.py b/src/gort/overwatcher/overwatcher.py index 8bc7920..b99bc8b 100644 --- a/src/gort/overwatcher/overwatcher.py +++ b/src/gort/overwatcher/overwatcher.py @@ -133,37 +133,52 @@ async def handle_unsafe(self): # TODO: should this only happen if the overwatcher is enabled? if not closed or observing or calibrating: - await self.overwatcher.notify( - "Unsafe conditions detected.", - level="warning", - ) - - if observing and not cancelling: - try: - await self.overwatcher.observer.stop_observing( - immediate=True, - reason="unsafe conditions", - ) - except Exception as err: - await self.overwatcher.notify( - f"Error stopping observing: {err!r}", - level="error", - ) + try: + async with asyncio.timeout(delay=30): await self.overwatcher.notify( - "I will close the dome anyway.", + "Unsafe conditions detected.", level="warning", ) - if calibrating: - await self.overwatcher.calibrations.cancel() + if observing and not cancelling: + try: + await self.overwatcher.observer.stop_observing( + immediate=True, + reason="unsafe conditions", + ) + except Exception as err: + await self.overwatcher.notify( + f"Error stopping observing: {err!r}", + level="error", + ) + await self.overwatcher.notify( + "I will close the dome anyway.", + level="warning", + ) + + if calibrating: + await self.overwatcher.calibrations.cancel() + + except asyncio.TimeoutError: + await self.overwatcher.notify( + "Timed out while handling unsafe conditions.", + level="error", + ) - if not closed: - await self.overwatcher.notify("Closing the dome.") - await self.overwatcher.dome.shutdown(retry=True, park=True) + except Exception as err: + await self.overwatcher.notify( + f"Error handling unsafe conditions: {err!r}", + level="error", + ) + + finally: + if not closed: + await self.overwatcher.notify("Closing the dome.") + await self.overwatcher.dome.shutdown(retry=True, park=True) - # If we have to close because of unsafe conditions, we don't want - # to reopen too soon. We lock the dome for 30 minutes. - self.overwatcher.alerts.locked_until = time() + 1800 + # If we have to close because of unsafe conditions, we don't want + # to reopen too soon. We lock the dome for 30 minutes. + self.overwatcher.alerts.locked_until = time() + 1800 async def handle_daytime(self): """Handles daytime.""" @@ -266,6 +281,7 @@ def __init__( EventsOverwatcher, ObserverOverwatcher, SafetyOverwatcher, + TransparencyOverwatcher, ) # Check if the instance already exists, in which case do nothing. @@ -294,6 +310,7 @@ def __init__( self.calibrations = CalibrationsOverwatcher(self, calibrations_file) self.observer = ObserverOverwatcher(self) self.alerts = AlertsOverwatcher(self) + self.transparency = TransparencyOverwatcher(self) self.events = EventsOverwatcher(self) async def run(self): diff --git a/src/gort/overwatcher/transparency.py b/src/gort/overwatcher/transparency.py new file mode 100644 index 0000000..8988ff5 --- /dev/null +++ b/src/gort/overwatcher/transparency.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# @Author: José Sánchez-Gallego (gallegoj@uw.edu) +# @Date: 2024-11-11 +# @Filename: transparency.py +# @License: BSD 3-clause (http://www.opensource.org/licenses/BSD-3-Clause) + +from __future__ import annotations + +import asyncio +import enum +from time import time + +from typing import TYPE_CHECKING, Literal, Sequence, TypedDict, cast, get_args + +import numpy +import polars + +from sdsstools.utils import GatheringTaskGroup + +from gort.exceptions import GortError +from gort.overwatcher.core import OverwatcherModuleTask +from gort.overwatcher.overwatcher import OverwatcherModule +from gort.tools import cancel_task, get_lvmapi_route + + +if TYPE_CHECKING: + pass + + +__all__ = ["TransparencyOverwatcher", "TransparencyQuality", "TransparencyQuality"] + + +class TransparencyQuality(enum.Flag): + """Flags for transparency status.""" + + GOOD = enum.auto() + POOR = enum.auto() + BAD = enum.auto() + UNKNOWN = enum.auto() + IMPROVING = enum.auto() + WORSENING = enum.auto() + FLAT = enum.auto() + + +Telescopes = Literal["sci", "spec", "skye", "skyw"] + + +class TransparencyQualityDict(TypedDict): + sci: TransparencyQuality + skye: TransparencyQuality + skyw: TransparencyQuality + spec: TransparencyQuality + + +class TransparencyZPDict(TypedDict): + sci: float + skye: float + skyw: float + spec: float + + +class TransparencyMonitorTask(OverwatcherModuleTask["TransparencyOverwatcher"]): + """Monitors transparency.""" + + name = "transparency_monitor" + keep_alive = True + restart_on_error = True + + def __init__(self): + super().__init__() + + self.unavailable: bool = False + + async def task(self): + """Updates the transparency data.""" + + n_failures: int = 0 + + while True: + try: + await self.update_data() + except Exception as err: + if not self.unavailable: + self.log.error(f"Failed to get transparency data: {err!r}") + n_failures += 1 + else: + self.module.last_updated = time() + self.unavailable = False + n_failures = 0 + finally: + if n_failures >= 5 and not self.unavailable: + await self.notify( + "Cannot retrieve transparency data. Will continue trying but " + "transparency monitoring will be unavailable.", + level="error", + ) + + self.module.reset() + self.unavailable = True + + await asyncio.sleep(60) + + async def update_data(self): + """Retrieves and evaluates transparency data.""" + + now: float = time() + lookback: float = 3600 + + # Get transparency data from the API for the last hour. + data = await get_lvmapi_route( + "/transparency/", + params={"start_time": now - lookback, "end_time": now}, + ) + + self.module.data_start_time = data["start_time"] + self.module.data_end_time = data["end_time"] + + data = ( + polars.DataFrame( + data["data"], + orient="row", + schema={ + "date": polars.String(), + "timestamp": polars.Float64(), + "telescope": polars.String(), + "zero_point": polars.Float32(), + }, + ) + .with_columns( + date=polars.col.date.str.to_datetime(time_zone="UTC", time_unit="ms") + ) + .sort("telescope", "date") + ) + + # Add a rolling mean. + data = data.with_columns( + zero_point_10m=polars.col.zero_point.rolling_mean_by( + by="date", + window_size="10m", + ).over("telescope") + ) + + # Get last 5 and 15 minutes of data. + data_10 = data.filter(polars.col.timestamp > (now - 300)) + data_15 = data.filter(polars.col.timestamp > (now - 900)) + + # Use the last 5 minutes of data to determine the transparency status + # and value the last 15 minutes to estimate the trend. + for tel in ["sci", "spec", "skye", "skyw"]: + data_tel_10 = data_10.filter(polars.col.telescope == tel) + data_tel_15 = data_15.filter(polars.col.telescope == tel) + + if len(data_tel_10) < 5: + self.module.quality[tel] = TransparencyQuality.UNKNOWN + self.module.zero_point[tel] = numpy.nan + continue + + avg_10 = data_tel_10["zero_point_10m"].median() + if avg_10 is not None: + avg_10 = cast(float, avg_10) + self.module.zero_point[tel] = round(float(avg_10), 2) + + if avg_10 < -22.75: + self.module.quality[tel] = TransparencyQuality.GOOD + elif avg_10 > -22.75 and avg_10 < -22.25: + self.module.quality[tel] = TransparencyQuality.POOR + else: + self.module.quality[tel] = TransparencyQuality.BAD + + time_15m = data_tel_15["timestamp"].to_numpy() - data_tel_15["timestamp"][0] + zp_15m = data_tel_15["zero_point_10m"].to_numpy() + gradient_15m = (zp_15m[-1] - zp_15m[0]) / (time_15m[-1] - time_15m[0]) + + if gradient_15m > 5e-4: + self.module.quality[tel] |= TransparencyQuality.WORSENING + elif gradient_15m < -5e-4: + self.module.quality[tel] |= TransparencyQuality.IMPROVING + else: + self.module.quality[tel] |= TransparencyQuality.FLAT + + return data + + +class TransparencyOverwatcher(OverwatcherModule): + """Monitors alerts.""" + + name = "alerts" + + tasks = [TransparencyMonitorTask()] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.last_updated: float = 0 + + self.data_start_time: float = 0 + self.data_end_time: float = 0 + + self._monitor_task: asyncio.Task | None = None + + self.reset() + + def reset(self): + """Reset values.""" + + self.data_start_time: float = 0 + self.data_end_time: float = 0 + + self.quality = TransparencyQualityDict( + sci=TransparencyQuality.UNKNOWN, + skye=TransparencyQuality.UNKNOWN, + skyw=TransparencyQuality.UNKNOWN, + spec=TransparencyQuality.UNKNOWN, + ) + + self.zero_point = TransparencyZPDict( + sci=numpy.nan, + skye=numpy.nan, + skyw=numpy.nan, + spec=numpy.nan, + ) + + self.data: polars.DataFrame = polars.DataFrame( + None, + schema={ + "time": polars.Datetime(time_unit="ms", time_zone="UTC"), + "zero_point": polars.Float32(), + "telescope": polars.String(), + }, + ) + + def write_to_log( + self, + telescopes: Sequence[Telescopes] | Telescopes = get_args(Telescopes), + ): + """Writes the current state to the log.""" + + if isinstance(telescopes, str): + telescopes = [telescopes] + + for tel in telescopes: + self.log.info( + f"Transparency for {tel}: quality={self.get_quality_string(tel)}; " + f"trend={self.get_trend_string(tel)}; zp={self.zero_point[tel]:.2f}" + ) + + def get_quality_string(self, telescope: Telescopes) -> str: + """Returns the quality as a string.""" + + quality_flag = self.quality[telescope] + quality: str = "UNKNOWN" + + if quality_flag & TransparencyQuality.BAD: + quality = "BAD" + elif quality_flag & TransparencyQuality.POOR: + quality = "POOR" + elif quality_flag & TransparencyQuality.GOOD: + quality = "GOOD" + + return quality + + def get_trend_string(self, telescope: Telescopes) -> str: + """Returns the trend as a string.""" + + quality_flag = self.quality[telescope] + trend: str = "UNKNOWN" + + if quality_flag & TransparencyQuality.IMPROVING: + trend = "IMPROVING" + elif quality_flag & TransparencyQuality.WORSENING: + trend = "WORSENING" + elif quality_flag & TransparencyQuality.FLAT: + trend = "FLAT" + + return trend + + def is_monitoring(self): + """Returns True if the transparency monitor is running.""" + + return self._monitor_task is not None and not self._monitor_task.done() + + async def start_monitoring(self): + """Starts monitoring transparency.""" + + if self.is_monitoring(): + return + + self.gort.log.info("Starting transparency monitor.") + self._monitor_task = asyncio.create_task(self._monitor_transparency()) + + async def stop_monitoring(self): + """Stops monitoring transparency.""" + + if self.is_monitoring(): + self.gort.log.info("Stopping transparency monitor.") + self._monitor_task = await cancel_task(self._monitor_task) + await self.gort.guiders.stop() + + async def _monitor_transparency(self): + """Monitors transparency.""" + + # Stop guiding and fold all telescopes except sci. + await self.gort.guiders.stop() + async with GatheringTaskGroup() as group: + for tel in ["spec", "skye", "skyw"]: + group.create_task( + self.gort.telescopes[tel].park( + disable=False, + kmirror=False, + ) + ) + + # Start monitoring with the sci telescope. + sci_task = asyncio.create_task(self.gort.guiders["sci"].monitor(sleep=30)) + + while True: + await asyncio.sleep(30) + + if sci_task.done(): + self.gort.log.error("sci guider has stopped monitoring transparency.") + await self.stop_monitoring() + raise GortError("sci guider has stopped monitoring transparency.") + + sci_quality = self.quality["sci"] + sci_zero_point = self.zero_point["sci"] + + if sci_quality & TransparencyQuality.GOOD: + self.gort.log.info("sci guider has detected good transparency.") + await cancel_task(sci_task) + await self.stop_monitoring() + break + + self.log.info( + f"sci guider transparency quality: {sci_quality.name} " + f"(zero_point={sci_zero_point:.2f})." + ) diff --git a/src/gort/tools.py b/src/gort/tools.py index 4f6c544..674b7f8 100644 --- a/src/gort/tools.py +++ b/src/gort/tools.py @@ -724,8 +724,18 @@ async def get_lvmapi_route(route: str, params: dict = {}, **kwargs): host, port = config["services"]["lvmapi"].values() + if "/" in host: + host, base_route = host.split("/") + if base_route != "": + base_route += "/" + else: + base_route = "" + + if route.startswith("/"): + route = route[1:] + async with httpx.AsyncClient( - base_url=f"http://{host}:{port}", + base_url=f"http://{host}:{port}/{base_route}", follow_redirects=True, ) as client: response = await client.get(route, params=params)