Files
steveandClaude Opus 5 ba0c3b58b6 Initial commit: PT1000 probe emulation bridge
Feeds third-party BBQ probes into a pellet grill's wired probe jacks by
presenting the resistance a real PT1000 RTD would show at that temperature.
The grill sees an ordinary wired probe, so its display, app, target-temp
alarms and Keep Warm all work with no protocol reversing involved.

Pluggable probe sources: ThermoWorks RFX (via ThermoWorks Cloud), MEATER
(community-derived BLE decode), a synthetic simulator for hardware-free
development, and a Combustion stub.

Two safety invariants are load-bearing:
- Stale, missing or implausible readings drive the channel cold, never hot.
  A stuck-hot channel could convince the grill a cook finished early.
- Unimplemented sources raise rather than returning plausible numbers, since
  the grill acts on these values.

Cloud sources are dated by the cloud's own timestamp rather than by fetch
time, because ThermoWorks serves a dead probe's last value with a fresh 200.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-16 22:46:14 -04:00

94 lines
3.0 KiB
Python

"""PT1000 curve tests against published IEC 60751 reference values.
These numbers are the whole basis for the grill believing our fake probe, so
they are checked against the standard table rather than against our own output.
"""
from __future__ import annotations
import pytest
from smokescreen.pt1000 import (
MAX_TEMP_C,
MIN_TEMP_C,
is_plausible,
required_span_ohms,
resistance_to_temp_c,
resistance_to_temp_c_clamped,
resolution_c_per_ohm,
temp_c_to_resistance,
)
# (celsius, ohms) from the IEC 60751 alpha=0.00385 PT1000 table.
REFERENCE_POINTS = [
(-20.0, 921.60),
(0.0, 1000.00),
(20.0, 1077.94),
(50.0, 1193.97),
(100.0, 1385.06),
(150.0, 1573.25),
(200.0, 1758.56),
(250.0, 1940.98),
]
@pytest.mark.parametrize("temp_c,expected_ohms", REFERENCE_POINTS)
def test_temp_to_resistance_matches_standard_table(temp_c, expected_ohms):
assert temp_c_to_resistance(temp_c) == pytest.approx(expected_ohms, abs=0.05)
@pytest.mark.parametrize("temp_c,ohms", REFERENCE_POINTS)
def test_resistance_to_temp_matches_standard_table(temp_c, ohms):
assert resistance_to_temp_c(ohms) == pytest.approx(temp_c, abs=0.02)
@pytest.mark.parametrize("temp_c", [-40, -10, 0, 0.5, 37, 93.3, 165, 250, 350])
def test_round_trip_is_lossless(temp_c):
ohms = temp_c_to_resistance(temp_c)
assert resistance_to_temp_c(ohms) == pytest.approx(temp_c, abs=0.001)
def test_curve_is_monotonic_across_the_food_range():
"""A non-monotonic patch would make the inverse ambiguous."""
ohms = [temp_c_to_resistance(t / 10) for t in range(-400, 3501)]
assert all(b > a for a, b in zip(ohms, ohms[1:]))
@pytest.mark.parametrize("bad_ohms", [50.0, 0.0, 842.0, 2300.0, 1e6, float("nan")])
def test_resistance_off_the_curve_is_rejected(bad_ohms):
"""Unguarded, the curve extrapolates 50 ohm to a plausible-looking -235 C."""
with pytest.raises(ValueError):
resistance_to_temp_c(bad_ohms)
@pytest.mark.parametrize("ohms,expected", [(50.0, MIN_TEMP_C), (1e6, MAX_TEMP_C)])
def test_clamped_variant_saturates_instead_of_raising(ohms, expected):
assert resistance_to_temp_c_clamped(ohms) == pytest.approx(expected, abs=0.01)
def test_clamped_variant_is_exact_inside_the_range():
assert resistance_to_temp_c_clamped(1385.06) == pytest.approx(100.0, abs=0.02)
def test_required_span_covers_a_real_cook():
lo, hi = required_span_ohms(0.0, 200.0)
assert lo == pytest.approx(1000.0, abs=0.1)
assert hi == pytest.approx(1758.6, abs=0.5)
# This is the number that sizes the hardware: a 1k rheostat is enough.
assert hi - lo < 1000.0
def test_one_ohm_is_finer_than_the_grill_displays():
# Grill shows whole degrees F; 1 ohm must be comfortably under 1 F (0.56 C).
assert resolution_c_per_ohm(100.0) < 0.4
@pytest.mark.parametrize("bad", [float("nan"), float("inf"), -273.0, 900.0])
def test_implausible_values_rejected(bad):
assert not is_plausible(bad)
@pytest.mark.parametrize("good", [-20.0, 0.0, 93.3, 200.0])
def test_plausible_values_accepted(good):
assert is_plausible(good)