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): """Generic dotted-path lookup against any nested-dict document - not tied to server config.json's own shape, so this is exactly as usable for a sud.json schedule (see utils/sud_validate.py) as for TempCtrl below.""" 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 require_schema(config, schema, _prefix=''): """Generic declarative counterpart to require(): schema mirrors the document's own shape, with every leaf naming the required type(s) (see TEMPCTRL_SCHEMA below for an example) - one shared mechanism for any config-shaped document with a fixed structure, whether that's server config.json's TempCtrl section or (for the parts of it that are static - see utils/sud_validate.py for the parts that aren't, e.g. per-step conditional requirements) a sud.json schedule. A schema value that's itself a dict describes a required sub-object: checked (and reported, on failure, as its own dotted path) before recursing into it, so a wrong-typed intermediate node (e.g. 'TempCtrl' itself not being an object) is named directly rather than surfacing as a confusing "missing" error on one of its children instead.""" for key, spec in schema.items(): path = '{}.{}'.format(_prefix, key) if _prefix else key if isinstance(spec, dict): require(config, path, type=dict) require_schema(config, spec, path) else: require(config, path, type=spec) _GAIN_SCHEMA = {'kp': (int, float), 'ki': (int, float), 'kd': (int, float), 'kt': (int, float)} # 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. TEMPCTRL_SCHEMA = { 'TempCtrl': { 'pid_type': str, 'Outer': _GAIN_SCHEMA, 'Inner': { 'Heat': _GAIN_SCHEMA, 'Hold': _GAIN_SCHEMA, 'Cool': _GAIN_SCHEMA, }, }, } 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).""" require_schema(config, TEMPCTRL_SCHEMA)