"""Emulator behaviour, especially the failure modes. The fail-cold policy is a safety property, not a nicety: a stuck-hot channel can convince the grill a cook finished early. These tests pin that down. """ from __future__ import annotations import time import pytest from smokescreen.models import ProbeReading from smokescreen.outputs.pt1000_emulator import ( ChannelCalibration, PT1000Emulator, ) from smokescreen.outputs.rheostat import MockRheostat from smokescreen.pt1000 import temp_c_to_resistance def make_emulator(**kwargs) -> tuple[PT1000Emulator, MockRheostat]: channel = MockRheostat(max_code=1023) emulator = PT1000Emulator( jack=1, channel=channel, calibration=ChannelCalibration(r_offset_ohms=1000.0, step_ohms=1.0), **kwargs, ) return emulator, channel def reading(tip_c: float, *, age_s: float = 0.0) -> ProbeReading: return ProbeReading( probe_id="test:1", tip_c=tip_c, source="test", timestamp=time.monotonic() - age_s, ) @pytest.mark.parametrize("temp_c", [0.0, 25.0, 60.0, 93.3, 150.0]) def test_applied_temperature_tracks_request(temp_c): emulator, _ = make_emulator() assert emulator.apply_temperature(temp_c) == pytest.approx(temp_c, abs=0.2) def test_wiper_code_matches_the_pt1000_curve(): emulator, channel = make_emulator() emulator.apply_temperature(100.0) # r_offset 1000 + code*1 should land on 1385 ohm. expected_code = round(temp_c_to_resistance(100.0) - 1000.0) assert channel.code == expected_code def test_temperature_above_hardware_range_clamps_rather_than_wraps(): emulator, channel = make_emulator() applied = emulator.apply_temperature(300.0) assert channel.code == channel.max_code assert applied < 300.0 # clamped, and the caller can see it def test_stale_reading_fails_cold(): emulator, channel = make_emulator(stale_after_s=30.0) emulator.update(reading(90.0)) assert channel.code > 0 emulator.update(reading(90.0, age_s=120.0)) assert channel.code == 0 assert emulator.state.stale assert "stale" in emulator.state.error def test_missing_probe_fails_cold(): emulator, channel = make_emulator() emulator.update(None) assert channel.code == 0 assert emulator.state.stale @pytest.mark.parametrize("bad", [float("nan"), float("inf"), 5000.0]) def test_implausible_reading_fails_cold_instead_of_raising(bad): emulator, channel = make_emulator() emulator.update(reading(bad)) assert channel.code == 0 assert emulator.state.stale def test_fail_cold_is_never_hot(): """Whatever else happens, the fault state must read cold, not hot.""" emulator, channel = make_emulator() emulator.apply_temperature(150.0) emulator.fail_cold("test") assert channel.code == 0 assert emulator.min_ohms < temp_c_to_resistance(0.0) + 1e-6 def test_close_drives_cold_before_releasing(): emulator, channel = make_emulator() emulator.apply_temperature(120.0) emulator.close() assert channel.history[-1] == 0 def test_calibration_solved_from_two_measured_points(): cal = ChannelCalibration.from_two_points( code_a=0, ohms_a=1012.4, code_b=1000, ohms_b=2015.6 ) assert cal.r_offset_ohms == pytest.approx(1012.4, abs=0.01) assert cal.step_ohms == pytest.approx(1.0032, abs=0.0001) assert cal.ohms_for_code(500) == pytest.approx(1514.0, abs=0.1) def test_calibration_rejects_degenerate_points(): with pytest.raises(ValueError): ChannelCalibration.from_two_points(10, 1000.0, 10, 2000.0)