class ConfigError(Exception): """Raised for a missing or wrong-typed config key, named by its full dotted path (e.g. "TempCtrl.Inner.Hold.kp") so the error points straight at the offending line in config.json instead of surfacing as a bare KeyError/TypeError from wherever the value first gets used.""" def require(config, path, type=None): node = config parts = path.split('.') for i, key in enumerate(parts): if not isinstance(node, dict) or key not in node: raise ConfigError("missing required config key: {}".format('.'.join(parts[:i + 1]))) node = node[key] if type is not None and not isinstance(node, type): type_name = ' or '.join(t.__name__ for t in type) if isinstance(type, tuple) else type.__name__ raise ConfigError("config key {} must be {}, got {!r}".format(path, type_name, node)) return node def validate_temp_ctrl(config): """Checked eagerly at startup so a missing/misnamed PID gain fails with a clear message here instead of a bare KeyError mid-__init__ or on the first process() tick (this bit us once already: config.json.sim's gain/ Model nesting mismatch). Only the gains every pid_type actually reads unconditionally are required; anything with its own default elsewhere (Outer.y_hold_min, Inner.Hold.yi_max, the whole Thresholds section - see temp_controller_base.py/temp_controller_fsm.py's DEFAULT_THRESHOLDS) stays optional here too, on purpose.""" require(config, 'TempCtrl', dict) require(config, 'TempCtrl.pid_type', str) for gain in ('kp', 'ki', 'kd', 'kt'): require(config, 'TempCtrl.Outer.{}'.format(gain), (int, float)) for branch in ('Heat', 'Hold', 'Cool'): require(config, 'TempCtrl.Inner.{}.{}'.format(branch, gain), (int, float))