STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
parameter_registry.py
Go to the documentation of this file.
1"""Every parameter a config carries, and what the schema says about it.
2
3The Parameters workspace lists these: one row per leaf of ``PintleEngineConfig``, with its current
4value, its schema default, whether it differs, its units (taken from the ``[unit]`` in its
5description) and the description itself. It walks the pydantic models, so a field added to
6``config_schemas.py`` appears here without anyone remembering to list it.
7
8A block that is null in the config (``chamber: null``) is one row of kind ``block``; its fields
9appear once it is set. Keys a config carries that the schema does not declare (models with
10``extra="allow"``) are rows of kind ``undeclared``: they are passed through, so they are shown.
11"""
12from __future__ import annotations
13
14import math
15import re
16from typing import Any, Dict, List, Optional, Tuple, Type, Union, get_args, get_origin
17
18from pydantic import BaseModel
19from pydantic_core import PydanticUndefined
20
21_UNIT = re.compile(r"\‍[([^\‍[\‍]]{1,28})\‍]")
22# A bracketed note such as "[DEPRECATED — no effect]" is not a unit.
23_NOT_UNIT = re.compile(r"[A-Z]{4,}| — | - ")
24
25#: Top-level sections in the order the workspace shows them, with the label it uses.
26SECTIONS: List[Tuple[str, str]] = [
27 ("design_requirements", "Requirements & optimizer"),
28 ("fluids", "Propellants"),
29 ("injector", "Injector"),
30 ("discharge", "Discharge & Cd"),
31 ("spray", "Spray & vaporisation"),
32 ("combustion", "Combustion & CEA"),
33 ("chamber_geometry", "Chamber & nozzle"),
34 ("chamber", "Chamber (legacy)"),
35 ("nozzle", "Nozzle (legacy)"),
36 ("ablative_cooling", "Ablative liner"),
37 ("graphite_insert", "Graphite insert"),
38 ("stainless_steel_case", "Case"),
39 ("regen_cooling", "Regenerative cooling"),
40 ("film_cooling", "Film cooling"),
41 ("stability", "Stability"),
42 ("feed_system", "Feed system"),
43 ("lox_tank", "LOX tank"),
44 ("fuel_tank", "Fuel tank"),
45 ("press_tank", "Pressurant"),
46 ("rocket", "Vehicle"),
47 ("environment", "Environment"),
48 ("thrust", "Thrust profile"),
49 ("pressure_curves", "Pressure curves"),
50 ("solver", "Solver"),
51 ("optimizer", "Optimizer"),
52 ("propellant_preset", "Propellant preset"),
53 ("design_valid_for", "Design state"),
54]
55_SECTION_LABEL = dict(SECTIONS)
56#: Sections the workspace lists only while they hold a value: blocks superseded by
57#: ``chamber_geometry``, kept so an old file still loads.
58LEGACY_SECTIONS = frozenset({"chamber", "nozzle"})
59#: Sections the app writes for itself (the forward-mode staleness stamp), never a person; the
60#: workspace leaves them out of its list. Search still finds their rows.
61INTERNAL_SECTIONS = frozenset({"design_valid_for"})
62
63
64def _models_in(annotation: Any) -> List[Type[BaseModel]]:
65 """The BaseModel classes an annotation can hold (through Optional/Union)."""
66 if isinstance(annotation, type) and issubclass(annotation, BaseModel):
67 return [annotation]
68 if get_origin(annotation) is Union:
69 return [m for a in get_args(annotation) for m in _models_in(a)]
70 return []
71
72
73def _dict_value_model(annotation: Any) -> Optional[Type[BaseModel]]:
74 """``Dict[str, Model]`` (possibly Optional) -> Model."""
75 for a in ([annotation] + list(get_args(annotation)) if get_origin(annotation) is Union else [annotation]):
76 if get_origin(a) in (dict, Dict):
77 args = get_args(a)
78 if len(args) == 2:
79 ms = _models_in(args[1])
80 if ms:
81 return ms[0]
82 return None
83
84
85def _type_name(annotation: Any) -> str:
86 origin = get_origin(annotation)
87 if origin is Union:
88 parts = [a for a in get_args(annotation) if a is not type(None)]
89 base = " | ".join(_type_name(a) for a in parts)
90 return base + (" | null" if len(parts) < len(get_args(annotation)) else "")
91 if origin is not None and getattr(origin, "__name__", "") == "Literal":
92 return "enum"
93 if str(annotation).startswith("typing.Literal") or getattr(annotation, "__origin__", None) is not None and "Literal" in str(annotation):
94 return "enum"
95 if origin in (list, List):
96 return "list"
97 if origin in (dict, Dict):
98 return "dict"
99 return getattr(annotation, "__name__", str(annotation))
100
101
102def _choices(annotation: Any) -> Optional[List[Any]]:
103 """Allowed values of a Literal (possibly Optional) annotation."""
104 args = get_args(annotation) if get_origin(annotation) is Union else [annotation]
105 out: List[Any] = []
106 for a in args:
107 if "Literal" in str(a):
108 out.extend(get_args(a))
109 return out or None
110
111
112def _default(fi: Any) -> Any:
113 if fi.default_factory is not None:
114 try:
115 v = fi.default_factory()
116 except TypeError:
117 return None
118 return v.model_dump() if isinstance(v, BaseModel) else v
119 return None if fi.default is PydanticUndefined else fi.default
120
121
122def _plain(v: Any) -> Any:
123 if isinstance(v, BaseModel):
124 return v.model_dump()
125 if isinstance(v, float) and not math.isfinite(v):
126 return str(v)
127 return v
128
129
130def _differs(value: Any, default: Any) -> bool:
131 if isinstance(value, float) and isinstance(default, (int, float)) and default is not None:
132 return not math.isclose(value, float(default), rel_tol=1e-12, abs_tol=0.0)
133 return _plain(value) != _plain(default)
134
135
136def _leaf(path: str, section: str, fi: Any, value: Any) -> Dict[str, Any]:
137 desc = (fi.description or "").strip()
138 m = _UNIT.search(desc)
139 default = _default(fi)
140 required = fi.is_required()
141 return {
142 "path": path,
143 "section": section,
144 "kind": "field",
145 "type": _type_name(fi.annotation),
146 "choices": _choices(fi.annotation),
147 "value": _plain(value),
148 "default": default,
149 "required": required,
150 "modified": (not required) and _differs(value, default),
151 "unit": m.group(1) if m and not _NOT_UNIT.search(m.group(1)) else None,
152 "description": desc,
153 }
154
155
156def _walk(model: BaseModel, prefix: str, section: str, out: List[Dict[str, Any]]) -> None:
157 cls = type(model)
158 for name, fi in cls.model_fields.items():
159 path = f"{prefix}{name}"
160 value = getattr(model, name, None)
161 dm = _dict_value_model(fi.annotation)
162 if dm is not None:
163 for key, sub in (value or {}).items():
164 if isinstance(sub, BaseModel):
165 _walk(sub, f"{path}.{key}.", section, out)
166 continue
167 if _models_in(fi.annotation):
168 if isinstance(value, BaseModel):
169 _walk(value, f"{path}.", section, out)
170 else:
171 out.append({"path": path, "section": section, "kind": "block", "type": "block",
172 "value": None, "default": None, "modified": False, "unit": None,
173 "choices": None, "required": False,
174 "description": (fi.description or "").strip() or "not set"})
175 continue
176 out.append(_leaf(path, section, fi, value))
177 for key, value in (model.model_extra or {}).items():
178 out.append({"path": f"{prefix}{key}", "section": section, "kind": "undeclared",
179 "type": type(value).__name__, "value": _plain(value), "default": None,
180 "modified": False, "unit": None, "choices": None, "required": False,
181 "description": "not declared in the schema; passed through as written"})
182
183
184def config_parameters(config: BaseModel) -> Dict[str, Any]:
185 """All parameters of ``config`` (a PintleEngineConfig), grouped by section."""
186 rows: List[Dict[str, Any]] = []
187 for name, fi in type(config).model_fields.items():
188 value = getattr(config, name, None)
189 section = name
190 dm = _dict_value_model(fi.annotation)
191 if dm is not None:
192 for key, sub in (value or {}).items():
193 if isinstance(sub, BaseModel):
194 _walk(sub, f"{name}.{key}.", section, rows)
195 continue
196 if _models_in(fi.annotation):
197 if isinstance(value, BaseModel):
198 _walk(value, f"{name}.", section, rows)
199 else:
200 rows.append({"path": name, "section": section, "kind": "block", "type": "block",
201 "value": None, "default": None, "modified": False, "unit": None,
202 "choices": None, "required": False,
203 "description": (fi.description or "").strip() or "not set"})
204 continue
205 rows.append(_leaf(name, section, fi, value))
206 order = {s: i for i, (s, _) in enumerate(SECTIONS)}
207 sections = sorted({r["section"] for r in rows}, key=lambda s: order.get(s, len(order)))
208 return {
209 "sections": [{"key": s, "label": _SECTION_LABEL.get(s, s.replace("_", " ").capitalize()),
210 "count": sum(1 for r in rows if r["section"] == s),
211 "modified": sum(1 for r in rows if r["section"] == s and r["modified"]),
212 "legacy": s in LEGACY_SECTIONS,
213 "internal": s in INTERNAL_SECTIONS}
214 for s in sections],
215 "parameters": rows,
216 }
List[Type[BaseModel]] _models_in(Any annotation)
Optional[List[Any]] _choices(Any annotation)
Optional[Type[BaseModel]] _dict_value_model(Any annotation)
None _walk(BaseModel model, str prefix, str section, List[Dict[str, Any]] out)
Dict[str, Any] _leaf(str path, str section, Any fi, Any value)
bool _differs(Any value, Any default)
Dict[str, Any] config_parameters(BaseModel config)