config: fail fast on missing TempCtrl PID gains
Config access was unchecked dict[key] everywhere, so a missing/
misnamed gain (we've hit this once already: config.json.sim's gain/
Model nesting mismatch) surfaced as a bare KeyError from deep inside
some component's __init__ or first process() tick, with no indication
which config key was wrong.
utils/config_validate.py adds a small stdlib-only dotted-path checker
(no schema library - matches this project's stdlib-first leaning) and
validate_temp_ctrl(), scoped to the section that actually caused an
incident: TempCtrl.pid_type plus every kp/ki/kd/kt gain under
TempCtrl.Outer and TempCtrl.Inner.{Heat,Hold,Cool}. Called once in
server/brewpi.py right after json.load(), before any factory runs.
Optional-with-default keys (Outer.y_hold_min, Inner.Hold.yi_max,
Thresholds) stay optional here too.
Heater/Stirrer/TempSensor/Pot sections and unknown-key warnings are
deliberately left for later - see components/pid/TODO.md.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4oEE99rkKVU8L8tkzXWdG
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import unittest
|
||||
|
||||
from utils.config_validate import ConfigError, require, validate_temp_ctrl
|
||||
|
||||
|
||||
def _valid_temp_ctrl_config():
|
||||
gains = {"kp": 0.08, "ki": 0.02, "kd": 0.0, "kt": 1.5}
|
||||
return {
|
||||
"TempCtrl": {
|
||||
"pid_type": "Smith",
|
||||
"Outer": dict(gains),
|
||||
"Inner": {
|
||||
"Heat": dict(gains),
|
||||
"Hold": dict(gains),
|
||||
"Cool": dict(gains),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TestRequire(unittest.TestCase):
|
||||
def test_returns_value_when_present(self):
|
||||
self.assertEqual(require({"a": {"b": 1}}, "a.b"), 1)
|
||||
|
||||
def test_raises_on_missing_leaf(self):
|
||||
with self.assertRaises(ConfigError):
|
||||
require({"a": {}}, "a.b")
|
||||
|
||||
def test_raises_on_missing_intermediate(self):
|
||||
with self.assertRaises(ConfigError):
|
||||
require({}, "a.b")
|
||||
|
||||
def test_error_names_full_path_up_to_missing_key(self):
|
||||
with self.assertRaisesRegex(ConfigError, r"a\.b"):
|
||||
require({"a": {}}, "a.b.c")
|
||||
|
||||
def test_raises_on_wrong_type(self):
|
||||
with self.assertRaises(ConfigError):
|
||||
require({"a": "not a number"}, "a", type=(int, float))
|
||||
|
||||
def test_accepts_matching_type(self):
|
||||
self.assertEqual(require({"a": 1.5}, "a", type=(int, float)), 1.5)
|
||||
|
||||
|
||||
class TestValidateTempCtrl(unittest.TestCase):
|
||||
def test_valid_config_passes(self):
|
||||
validate_temp_ctrl(_valid_temp_ctrl_config())
|
||||
|
||||
def test_missing_gain_raises(self):
|
||||
config = _valid_temp_ctrl_config()
|
||||
del config["TempCtrl"]["Inner"]["Hold"]["kp"]
|
||||
with self.assertRaisesRegex(ConfigError, r"TempCtrl\.Inner\.Hold\.kp"):
|
||||
validate_temp_ctrl(config)
|
||||
|
||||
def test_missing_pid_type_raises(self):
|
||||
config = _valid_temp_ctrl_config()
|
||||
del config["TempCtrl"]["pid_type"]
|
||||
with self.assertRaises(ConfigError):
|
||||
validate_temp_ctrl(config)
|
||||
|
||||
def test_optional_keys_not_required(self):
|
||||
config = _valid_temp_ctrl_config()
|
||||
# Outer.y_hold_min, Inner.Hold.yi_max, and the whole Thresholds
|
||||
# section all have their own defaults elsewhere and must stay
|
||||
# optional here too.
|
||||
validate_temp_ctrl(config)
|
||||
self.assertNotIn("y_hold_min", config["TempCtrl"]["Outer"])
|
||||
self.assertNotIn("Thresholds", config["TempCtrl"])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user