Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/news/OSW-2418.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added clamping around glass temperature.
2 changes: 1 addition & 1 deletion python/lsst/ts/eas/dome_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 78 additions & 2 deletions python/lsst/ts/eas/tma_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
)
Expand Down
109 changes: 108 additions & 1 deletion tests/test_tma.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down