Add a thermal guard that slows the robot before the Pi throttles
hermes_thermal reads the CPU temperature and throttle flags, publishes /health/*, and at 72 / 77 / 82 C scales the camera, face and detector rates down through their live parameters, restoring them as it cools one level at a time with a margin and a dwell so it does not flap. The face and detector gained live parameter callbacks for this, the bridge's robot://status carries a health block (temperature, level, throttle flags now and since boot), and a bringup test asserts the guard's normal rates equal the real ones. Live testing on the robot (forcing 73 / 78 / 85 C and back) caught a bug the unit tests missed: one logging line whose severity changed between warn and info raised in rclpy and killed the guard on its first cooling step, and the respawn hid it. Fixed with separate call sites; the tick now survives any error; a full heat-then-cool test drives the real node, and a mutation check confirmed it fails on the original bug. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFN8nRRCiDiMN7ChBpHJhs
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import pytest
|
||||
|
||||
rclpy = pytest.importorskip('rclpy')
|
||||
|
||||
|
||||
def test_the_node_constructs_and_has_the_three_targets():
|
||||
"""Declaring a parameter twice raises at construction, and nothing else exercises the node."""
|
||||
from hermes_thermal.thermal_node import ThermalNode
|
||||
rclpy.init()
|
||||
try:
|
||||
node = ThermalNode()
|
||||
assert [t.name for t in node.targets] == ['/camera', '/hermes_face', '/hermes_face_detector']
|
||||
assert node.policy.level == 'ok'
|
||||
node.destroy_node()
|
||||
finally:
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
def test_a_full_heat_then_cool_run_through_the_real_node_does_not_crash_and_steps_down_one_level_at_a_time():
|
||||
"""The bug this guards against: a logging call site whose severity changed (warn heating, info cooling)
|
||||
raised in the timer callback, so the node died on its first cooling step."""
|
||||
from rclpy.parameter import Parameter
|
||||
from hermes_thermal.thermal_node import ThermalNode
|
||||
rclpy.init()
|
||||
try:
|
||||
node = ThermalNode()
|
||||
node.act = False # no other nodes here; this is about the policy path
|
||||
node.set_parameters([Parameter('dwell_s', value=0.0)])
|
||||
levels = []
|
||||
for temp in (60.0, 73.0, 78.0, 85.0, 60.0, 60.0, 60.0, 60.0):
|
||||
node.set_parameters([Parameter('fake_temp_c', value=temp)])
|
||||
node._poll() # _poll, not _tick: let any exception surface
|
||||
levels.append(node.policy.level)
|
||||
assert levels == ['ok', 'warm', 'hot', 'critical', 'hot', 'warm', 'ok', 'ok']
|
||||
node.destroy_node()
|
||||
finally:
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
def test_the_tick_survives_an_unexpected_error():
|
||||
from hermes_thermal.thermal_node import ThermalNode
|
||||
rclpy.init()
|
||||
try:
|
||||
node = ThermalNode()
|
||||
node._poll = lambda: (_ for _ in ()).throw(RuntimeError('boom'))
|
||||
node._tick() # must not raise
|
||||
node.destroy_node()
|
||||
finally:
|
||||
rclpy.shutdown()
|
||||
@@ -0,0 +1,57 @@
|
||||
import pytest
|
||||
|
||||
from hermes_thermal.policy import LEVELS, SCALE, ThermalPolicy, scaled
|
||||
|
||||
|
||||
def run(p, temps, t0=0.0, dt=1.0):
|
||||
t, out = t0, []
|
||||
for temp in temps:
|
||||
t += dt
|
||||
out.append(p.update(temp, t))
|
||||
return out, t
|
||||
|
||||
|
||||
def test_cool_stays_ok():
|
||||
out, _ = run(ThermalPolicy(), [55, 60, 65, 71.9])
|
||||
assert set(out) == {'ok'}
|
||||
|
||||
|
||||
def test_heating_escalates_at_once_even_several_levels():
|
||||
p = ThermalPolicy()
|
||||
assert p.update(73, 1.0) == 'warm'
|
||||
assert p.update(78, 2.0) == 'hot'
|
||||
assert p.update(90, 3.0) == 'critical' # straight to the top if it asks for it
|
||||
assert ThermalPolicy().update(84, 1.0) == 'critical'
|
||||
|
||||
|
||||
def test_cooling_needs_the_margin_and_the_dwell_and_goes_one_step_at_a_time():
|
||||
p = ThermalPolicy(dwell_s=20.0, margin_c=3.0)
|
||||
p.update(84, 0.0) # critical
|
||||
assert p.update(75, 5.0) == 'critical' # cooler, but inside the dwell
|
||||
assert p.update(75, 25.0) == 'hot' # dwell over, 75 < 82-3: one step down
|
||||
assert p.update(75, 26.0) == 'hot' # not another step at once
|
||||
assert p.update(73, 46.0) == 'warm' # 73 < 77-3
|
||||
assert p.update(70, 47.0) == 'warm'
|
||||
assert p.update(68, 67.0) == 'ok' # 68 < 72-3
|
||||
|
||||
|
||||
def test_a_temperature_hovering_at_a_threshold_does_not_flap():
|
||||
p = ThermalPolicy(dwell_s=20.0, margin_c=3.0)
|
||||
out, _ = run(p, [72.5, 71.5, 72.2, 71.0, 72.1, 70.0, 72.3] * 6, dt=1.0)
|
||||
assert out[0] == 'warm' and set(out) == {'warm'} # never drops back to ok while it stays within the margin
|
||||
|
||||
|
||||
def test_a_reheat_during_recovery_escalates_again():
|
||||
p = ThermalPolicy(dwell_s=10.0)
|
||||
p.update(84, 0.0)
|
||||
assert p.update(70, 20.0) == 'hot'
|
||||
assert p.update(83, 21.0) == 'critical'
|
||||
|
||||
|
||||
def test_scaling_respects_floors_and_never_exceeds_normal():
|
||||
assert scaled(15.0, 'ok', 2.0) == 15.0
|
||||
assert scaled(15.0, 'hot', 2.0) == pytest.approx(15 * 0.45)
|
||||
assert scaled(15.0, 'critical', 4.0) == 4.0 # the floor wins over 15*0.2 = 3
|
||||
assert scaled(1.0, 'critical', 2.0) == 1.0 # a floor above normal must not raise the rate
|
||||
assert [scaled(30.0, lv, 8.0) for lv in LEVELS] == sorted([scaled(30.0, lv, 8.0) for lv in LEVELS], reverse=True)
|
||||
assert SCALE['ok'] == 1.0
|
||||
@@ -0,0 +1,34 @@
|
||||
import subprocess
|
||||
|
||||
from hermes_thermal.sensors import decode_throttled, parse_throttled, read_temp_c, read_throttled
|
||||
|
||||
|
||||
def test_read_temp(tmp_path):
|
||||
f = tmp_path / 'temp'
|
||||
f.write_text('61850\n')
|
||||
assert read_temp_c(str(f)) == 61.85
|
||||
assert read_temp_c(str(tmp_path / 'missing')) is None
|
||||
f.write_text('garbage')
|
||||
assert read_temp_c(str(f)) is None
|
||||
|
||||
|
||||
def test_parse_and_decode_throttled():
|
||||
assert parse_throttled('throttled=0x0\n') == 0
|
||||
assert parse_throttled('throttled=0x50005') == 0x50005
|
||||
assert parse_throttled('nonsense') is None
|
||||
d = decode_throttled(0x50005) # under-voltage and throttled, now and since boot
|
||||
assert d['now'] == {'under_voltage': True, 'arm_frequency_capped': False, 'throttled': True,
|
||||
'soft_temperature_limit': False}
|
||||
assert d['since_boot']['under_voltage'] and d['since_boot']['throttled'] and not d['since_boot']['arm_frequency_capped']
|
||||
assert decode_throttled(0)['now'] == {k: False for k in d['now']}
|
||||
assert decode_throttled(None) == {'available': False}
|
||||
|
||||
|
||||
def test_read_throttled_handles_a_missing_vcgencmd():
|
||||
def boom(*a, **k):
|
||||
raise FileNotFoundError
|
||||
assert read_throttled(boom) is None
|
||||
|
||||
def ok(*a, **k):
|
||||
return subprocess.CompletedProcess(a, 0, stdout='throttled=0x20002\n', stderr='')
|
||||
assert read_throttled(ok) == 0x20002
|
||||
Reference in New Issue
Block a user