From 2160c53091ef9f1e5751604f290f36601fc77c49 Mon Sep 17 00:00:00 2001 From: Brian Brondel Date: Tue, 9 Jun 2026 19:59:29 -0400 Subject: [PATCH] Add clamping around glass temperature. --- doc/news/OSW-2418.feature.rst | 1 + python/lsst/ts/eas/dome_model.py | 2 +- python/lsst/ts/eas/tma_model.py | 80 ++++++++++++++++++++++- tests/test_tma.py | 109 ++++++++++++++++++++++++++++++- 4 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 doc/news/OSW-2418.feature.rst diff --git a/doc/news/OSW-2418.feature.rst b/doc/news/OSW-2418.feature.rst new file mode 100644 index 0000000..3f25b0f --- /dev/null +++ b/doc/news/OSW-2418.feature.rst @@ -0,0 +1 @@ +Added clamping around glass temperature. diff --git a/python/lsst/ts/eas/dome_model.py b/python/lsst/ts/eas/dome_model.py index 0a1e3b9..516778b 100644 --- a/python/lsst/ts/eas/dome_model.py +++ b/python/lsst/ts/eas/dome_model.py @@ -317,7 +317,7 @@ async def adjust_louvers(self, sun_azimuth: float) -> None: louver_command = [ ( -1.0 - if cmd < 0 + if cmd <= 0 else self.louver_exposed_command if sd < self.louver_sun_angle else self.louver_shaded_command diff --git a/python/lsst/ts/eas/tma_model.py b/python/lsst/ts/eas/tma_model.py index fdf72ce..6b9a675 100644 --- a/python/lsst/ts/eas/tma_model.py +++ b/python/lsst/ts/eas/tma_model.py @@ -147,6 +147,8 @@ def __init__( maximum_heating_rate: float, slow_cooling_rate: float, fast_cooling_rate: float, + glass_offset_limit_low: float, + glass_offset_limit_high: float, fan_speed: dict[str, float], features_to_disable: list[str], forecast_glycol_setpoint_delta: float | None = None, @@ -206,6 +208,13 @@ def __init__( self.maximum_heating_rate = maximum_heating_rate self.slow_cooling_rate = slow_cooling_rate self.fast_cooling_rate = fast_cooling_rate + if glass_offset_limit_low > glass_offset_limit_high: + raise ValueError( + f"glass_offset_limit_low ({glass_offset_limit_low}) must be " + f"<= glass_offset_limit_high ({glass_offset_limit_high})." + ) + self.glass_offset_limit_low = glass_offset_limit_low + self.glass_offset_limit_high = glass_offset_limit_high self.features_to_disable = features_to_disable # Fan-driven adjustment applied on top of the configured glycol delta. @@ -356,6 +365,22 @@ def get_config_schema(cls) -> str: while observing. type: number default: 10.0 + glass_offset_limit_low: + description: >- + Minimum allowed offset (°C) of the M1M3TS heater setpoint relative to the + median M1M3 glass temperature. Commanded heater (and fan-target) setpoints + are clamped so they never fall below glass_temperature + this value. + Must be <= glass_offset_limit_high. + type: number + default: -0.4 + glass_offset_limit_high: + description: >- + Maximum allowed offset (°C) of the M1M3TS heater setpoint relative to the + median M1M3 glass temperature. Commanded heater (and fan-target) setpoints + are clamped so they never exceed glass_temperature + this value. + Must be >= glass_offset_limit_low. + type: number + default: 0.2 forecast_glycol_setpoint_delta: type: [number, "null"] default: null @@ -496,6 +521,31 @@ async def monitor(self) -> None: self.log.debug("TmaModel.monitor closing...") + def clamp_to_glass_offset(self, heaters_setpoint: float, glass_temperature: float) -> float: + """Clamp a heater setpoint to the configured offset band around glass. + + The heater setpoint is constrained so that its offset relative to the + median M1M3 glass temperature stays within + ``[glass_offset_limit_low, glass_offset_limit_high]``. Keeping the + heater setpoint close to the glass temperature avoids the excessive + z-gradients that interfere with AOS operation. + + Parameters + ---------- + heaters_setpoint : `float` + Requested heater setpoint (°C). + glass_temperature : `float` + Median M1M3 glass temperature (°C). + + Returns + ------- + `float` + The heater setpoint clamped to the glass-relative offset band. + """ + lower = glass_temperature + self.glass_offset_limit_low + upper = glass_temperature + self.glass_offset_limit_high + return min(max(heaters_setpoint, lower), upper) + async def apply_setpoints( self, setpoint: float, @@ -511,7 +561,30 @@ async def apply_setpoints( setpoint + glycol_delta + self.glycol_setpoint_delta_adjustment + delta_adjustment ) heaters_setpoint = setpoint + heater_delta + delta_adjustment - self.log.debug(f"Setting MTM1MTS: {glycol_setpoint=:.2f}°C {heaters_setpoint=:.2f}°C") + + # Clamp the heater setpoint to the configured offset band around + # M1M3 glass temperature, shifting glycol by the same amount so the + # configured glycol/heater relationship is preserved. + glass_temperature = self.glass_temperature_model.median_temperature + if glass_temperature is not None and math.isfinite(glass_temperature): + clamped_heaters = self.clamp_to_glass_offset(heaters_setpoint, glass_temperature) + if clamped_heaters != heaters_setpoint: + self.log.info( + f"Clamping M1M3TS heater setpoint to glass-relative limits: " + f"{heaters_setpoint:.2f} -> {clamped_heaters:.2f}°C " + f"(glass={glass_temperature:.2f}, " + f"offsets=[{self.glass_offset_limit_low:.2f}, " + f"{self.glass_offset_limit_high:.2f}])" + ) + glycol_setpoint += clamped_heaters - heaters_setpoint + heaters_setpoint = clamped_heaters + else: + self.log.warning( + "M1M3 glass temperature unavailable; applying M1M3TS setpoints " + "without glass-relative offset clamping." + ) + + self.log.debug(f"Setting MTM1MTS: {glycol_setpoint=:+.2f}°C {heaters_setpoint=:+.2f}°C") await self.send_apply_setpoints( glycol_setpoint=glycol_setpoint, heaters_setpoint=heaters_setpoint, @@ -555,7 +628,10 @@ async def set_fan_speed( ): return - control_setpoint = setpoint + heater_delta + # Pin the fan's heater target to the same glass-relative offset band + # used when commanding the heater setpoint, so the fan drives toward + # temperature the heater will actually hold. + control_setpoint = self.clamp_to_glass_offset(setpoint + heater_delta, glass_temperature) slope = (self.fan_speed_max - self.fan_speed_min) / ( self.fan_throttle_max_temp_diff - self.fan_throttle_turn_on_temp_diff ) diff --git a/tests/test_tma.py b/tests/test_tma.py index 668f2d7..1023f2e 100644 --- a/tests/test_tma.py +++ b/tests/test_tma.py @@ -111,6 +111,7 @@ async def run_with_parameters( night: bool = False, dome_closed: bool = False, run_monitor: bool = True, + glass_temperature: float | None = 0.0, **model_args: typing.Any, ) -> tuple[float | None, float | None, float | None, list[float] | None]: self.diurnal_timer = eas.diurnal_timer.DiurnalTimer() @@ -136,7 +137,7 @@ async def run_with_parameters( log=mock_m1m3ts.log, diurnal_timer=self.diurnal_timer, dome_model=self.dome_model, - glass_temperature_model=SimpleNamespace(median_temperature=0.0), + glass_temperature_model=SimpleNamespace(median_temperature=glass_temperature), weather_model=self.weather_model, weatherforecast_model=WeatherForecastModel(log=mock_m1m3ts.log), m1m3ts_remote=m1m3ts_remote, @@ -155,6 +156,8 @@ async def run_with_parameters( maximum_heating_rate=100, slow_cooling_rate=1, fast_cooling_rate=10, + glass_offset_limit_low=model_args.get("glass_offset_limit_low", -1000.0), + glass_offset_limit_high=model_args.get("glass_offset_limit_high", 1000.0), fan_speed={ "fan_speed_min": 700.0, "fan_speed_max": 2000.0, @@ -343,6 +346,100 @@ async def test_m1m3ts_applysetpoints_ignores_fan_adjustment(self) -> None: places=4, ) + async def test_glass_offset_clamp_high(self) -> None: + """Heater setpoint above glass + high limit is clamped to the limit.""" + # glass=0, deltas -1/-2: raw heater = 10-1 = 9 -> clamp to 0+2 = 2 + # (offset -7); raw glycol = 10-2 = 8 -> shifted by -7 = 1. + glycol_setpoint, heater_setpoint, _, _ = await self.run_with_parameters( + indoor_temperature=10.0, + night=True, + dome_closed=True, + run_monitor=False, + glycol_setpoint_delta=-2.0, + heater_setpoint_delta=-1.0, + top_end_setpoint_delta=-0.5, + glass_offset_limit_low=-2.0, + glass_offset_limit_high=2.0, + features_to_disable=[], + ) + assert heater_setpoint is not None and glycol_setpoint is not None + self.assertAlmostEqual(heater_setpoint, 2.0, places=4) + self.assertAlmostEqual(glycol_setpoint, 1.0, places=4) + + async def test_glass_offset_clamp_low(self) -> None: + """Heater setpoint below glass + low limit is clamped to the limit.""" + # glass=0, deltas -1/-2: raw heater = -10-1 = -11 -> clamp to 0-2 = -2 + # (offset +9); raw glycol = -10-2 = -12 -> shifted by +9 = -3. + glycol_setpoint, heater_setpoint, _, _ = await self.run_with_parameters( + indoor_temperature=-10.0, + night=True, + dome_closed=True, + run_monitor=False, + glycol_setpoint_delta=-2.0, + heater_setpoint_delta=-1.0, + top_end_setpoint_delta=-0.5, + glass_offset_limit_low=-2.0, + glass_offset_limit_high=2.0, + features_to_disable=[], + ) + assert heater_setpoint is not None and glycol_setpoint is not None + self.assertAlmostEqual(heater_setpoint, -2.0, places=4) + self.assertAlmostEqual(glycol_setpoint, -3.0, places=4) + + async def test_glass_offset_within_band_no_clamp(self) -> None: + """A heater setpoint inside the band is applied unchanged.""" + # glass=0, deltas -1/-2: raw heater = 2-1 = 1 (within [-5, 5]); + # raw glycol = 2-2 = 0. Neither is clamped. + glycol_setpoint, heater_setpoint, _, _ = await self.run_with_parameters( + indoor_temperature=2.0, + night=True, + dome_closed=True, + run_monitor=False, + glycol_setpoint_delta=-2.0, + heater_setpoint_delta=-1.0, + top_end_setpoint_delta=-0.5, + glass_offset_limit_low=-5.0, + glass_offset_limit_high=5.0, + features_to_disable=[], + ) + assert heater_setpoint is not None and glycol_setpoint is not None + self.assertAlmostEqual(heater_setpoint, 1.0, places=4) + self.assertAlmostEqual(glycol_setpoint, 0.0, places=4) + + async def test_glass_offset_unavailable_applies_unclamped(self) -> None: + """With no glass temperature, setpoints are applied without clamp.""" + # Tight band that WOULD clamp, but glass is unavailable, so raw values + # are sent: heater = 10-1 = 9, glycol = 10-2 = 8. + glycol_setpoint, heater_setpoint, _, _ = await self.run_with_parameters( + indoor_temperature=10.0, + night=True, + dome_closed=True, + run_monitor=False, + glass_temperature=None, + glycol_setpoint_delta=-2.0, + heater_setpoint_delta=-1.0, + top_end_setpoint_delta=-0.5, + glass_offset_limit_low=-0.5, + glass_offset_limit_high=0.5, + features_to_disable=[], + ) + assert heater_setpoint is not None and glycol_setpoint is not None + self.assertAlmostEqual(heater_setpoint, 9.0, places=4) + self.assertAlmostEqual(glycol_setpoint, 8.0, places=4) + + async def test_glass_offset_limit_validation(self) -> None: + """Constructing with low > high raises ValueError.""" + with self.assertRaises(ValueError): + await self.run_with_parameters( + run_monitor=False, + glycol_setpoint_delta=-2.0, + heater_setpoint_delta=-1.0, + top_end_setpoint_delta=-0.5, + glass_offset_limit_low=5.0, + glass_offset_limit_high=-5.0, + features_to_disable=[], + ) + async def test_disabled_m1m3ts(self) -> None: """The applySetpoint should not be called when m1m3ts is disabled.""" glycol_setpoint_delta = -2 @@ -437,6 +534,8 @@ async def test_fan_speed(self) -> None: maximum_heating_rate=100, slow_cooling_rate=1, fast_cooling_rate=10, + glass_offset_limit_low=-1000.0, + glass_offset_limit_high=1000.0, fan_speed={ "fan_speed_min": 700.0, "fan_speed_max": 2000.0, @@ -527,6 +626,8 @@ async def test_forecast_setpoints_use_forecast_fan_and_glycol_adjustment( maximum_heating_rate=100, slow_cooling_rate=1, fast_cooling_rate=10, + glass_offset_limit_low=-1000.0, + glass_offset_limit_high=1000.0, fan_speed={ "fan_speed_min": 700.0, "fan_speed_max": 2000.0, @@ -584,6 +685,8 @@ async def test_forecast_setpoints_require_idle_delay_controller(self) -> None: maximum_heating_rate=100, slow_cooling_rate=1, fast_cooling_rate=10, + glass_offset_limit_low=-1000.0, + glass_offset_limit_high=-1000.0, fan_speed={ "fan_speed_min": 700.0, "fan_speed_max": 2000.0, @@ -647,6 +750,8 @@ async def test_top_end_only_forecast_does_not_activate_m1m3_forecast_state( maximum_heating_rate=100, slow_cooling_rate=1, fast_cooling_rate=10, + glass_offset_limit_low=-1000.0, + glass_offset_limit_high=-1000.0, fan_speed={ "fan_speed_min": 700.0, "fan_speed_max": 2000.0, @@ -708,6 +813,8 @@ async def test_delay_policy_time_delay(self) -> None: maximum_heating_rate=100, slow_cooling_rate=1, fast_cooling_rate=10, + glass_offset_limit_low=-1000.0, + glass_offset_limit_high=-1000.0, fan_speed={ "fan_speed_min": 700.0, "fan_speed_max": 2000.0,