STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
measurements.py
Go to the documentation of this file.
1"""Phase 5: what has been measured, put where the assumption used to be.
2
3A drawing carries every number the twin needs, each with a provenance:
4* measured;
5* manufacturer;
6* estimated;
7* default.
8
9Most of the numbers that move a burn are the last two, for example the dome
10regulator's supply-pressure effect, its droop, the tank walls and the line
11fittings. They are not wrong to have, but they are not known.
12
13An **override** replaces one drawing parameter with a stated value and its provenance. That
14value is usually measured, with the test named and an uncertainty. It goes into the drawing's
15JSON before the twin reads it, so the twin's own assembly report sees a measured number where
16an estimate used to be. Nothing downstream knows overrides exist.
17
18Overrides belong to a person and a drawing (by content id). They live in that person's data
19directory, never in the drawing itself, because the drawing is the shared hardware record
20(ADR-0002). A measurement that should be permanent belongs on the drawing in pid-designer,
21with its source.
22
23Engine-side measurements are different: a cold-flow Cd, a patternated E_m, a nozzle
24efficiency. They belong to the engine and live in the engine config's ``measurements`` block
25(engine/pipeline/measurements.py). The engine card is built from the config with them
26applied, so Layer X picks them up without duplicating them here.
27"""
28
29from __future__ import annotations
30
31import copy
32import hashlib
33import json
34import math
35import re
36import time
37from dataclasses import asdict, dataclass
38from pathlib import Path
39from typing import Any, Dict, List, Mapping, Optional, Tuple
40
41PROVENANCES = ("measured", "manufacturer", "estimated")
42
43
44@dataclass(frozen=True)
46 """One drawing parameter, restated."""
47
48 target: str
49 """``node:<symbol id>`` or ``edge:<line id>``."""
50 parameter: str
51 value: float
52 unit: str
53 source: str
54 """Who measured it and how: 'GN2 regulator flow test, 0.1 kg/s steps, 2026-10-04'."""
55 provenance: str = "measured"
56 uncertainty: Optional[float] = None
57 """One standard uncertainty, in ``unit``."""
58 date: Optional[str] = None
59
60 def key(self) -> str:
61 return f"{self.target}.{self.parameter}"
62
63 @classmethod
64 def from_dict(cls, raw: Mapping[str, Any]) -> "Override":
65 target = str(raw["target"])
66 if not re.fullmatch(r"(node|edge):[^\s]+", target):
67 raise ValueError(f"override target {target!r} is not node:<id> or edge:<id>")
68 provenance = str(raw.get("provenance") or "measured")
69 if provenance not in PROVENANCES:
70 raise ValueError(f"provenance {provenance!r}; expected one of {', '.join(PROVENANCES)}")
71 source = str(raw.get("source") or "").strip()
72 if not source:
73 raise ValueError(f"{target}.{raw.get('parameter')}: a restated number needs its source")
74 uncertainty = raw.get("uncertainty")
75 value = float(raw["value"])
76 parameter = str(raw["parameter"])
77 if not math.isfinite(value) or (uncertainty not in (None, "") and not math.isfinite(float(uncertainty))):
78 raise ValueError(f"{target}.{parameter}: a restated number must be finite")
79 # Volumes, lengths, bores, flow coefficients and ratings are not negative; a height is.
80 if value < 0 and parameter not in SIGNED_PARAMETERS:
81 raise ValueError(f"{target}.{parameter}: {value:g} is negative")
82 for name, text, cap in (("parameter", parameter, 60), ("source", source, 300),
83 ("unit", str(raw.get("unit") or ""), 24), ("date", str(raw.get("date") or ""), 40)):
84 if len(text) > cap:
85 raise ValueError(f"{target}.{parameter}: {name} is longer than {cap} characters")
86 return cls(target=target, parameter=parameter, value=value,
87 unit=str(raw.get("unit") or ""), source=source, provenance=provenance,
88 uncertainty=None if uncertainty in (None, "") else abs(float(uncertainty)),
89 date=(str(raw["date"]) if raw.get("date") else None))
90
91
92#: Parameters that may be negative: a line that climbs from the tank to the injector.
93SIGNED_PARAMETERS = frozenset({"elevation_change"})
94
95
96def _element(payload: Dict[str, Any], target: str) -> Optional[Dict[str, Any]]:
97 kind, ident = target.split(":", 1)
98 for item in payload.get("nodes" if kind == "node" else "edges") or []:
99 if isinstance(item, dict) and str(item.get("id")) == ident:
100 return item
101 return None
102
103
104def apply_overrides(payload: Mapping[str, Any], overrides: List[Override]) -> Tuple[Dict[str, Any], List[Dict[str, Any]], List[str]]:
105 """A copy of ``payload`` with ``overrides`` written into its parameters.
106
107 Returns ``(payload, applied, missing)``: what was written, as the report shows it, and the
108 keys whose element the drawing no longer has (a drawing edited since the measurement was
109 entered). Those are reported, not dropped silently.
110 """
111 out = copy.deepcopy(dict(payload))
112 applied: List[Dict[str, Any]] = []
113 missing: List[str] = []
114 for o in overrides:
115 element = _element(out, o.target)
116 if element is None:
117 missing.append(o.key())
118 continue
119 data = element.setdefault("data", {})
120 params = data.setdefault("params", {})
121 before = params.get(o.parameter)
122 reference = o.source + (f" ({o.date})" if o.date else "")
123 if o.uncertainty is not None:
124 reference += f"; ±{o.uncertainty:g} {o.unit}".rstrip()
125 params[o.parameter] = {"value": o.value, "unit": o.unit, "source": o.provenance, "reference": reference}
126 applied.append({**asdict(o), "key": o.key(),
127 "was": ({"value": before.get("value"), "unit": before.get("unit"),
128 "source": before.get("source")} if isinstance(before, dict) else None)})
129 return out, applied, missing
130
131
132_PSI = 6894.757293168361
133
134
135def absolute_psia(parameter: str, value: float, unit: str, symbol: str = "") -> Optional[float]:
136 """An absolute pressure on the drawing as the twin reads it, in psia; None for anything else.
137
138 A bare ``psi`` on a tank's ``pressure`` or a regulator's ``dome_pressure`` is gauge
139 (``feedtwin.model.pressure.drawn_unit``, ADR 0004), so a drawn 3750 psi is 3764.7 psia. A
140 person reads and types psia (ADR 0007): the restatement table shows that number, not the
141 drawing's spelling. Differences (droop, bias, relief set) are the same in either and stay as
142 drawn."""
143 try:
144 from feedtwin.model.pressure import DIFFERENCE, PressureReferenceError, drawn_unit
145 from feedtwin.model.units import UnknownUnit, get_unit
146 except ImportError: # pragma: no cover - feedtwin is a dependency of Layer X
147 return None
148 try:
149 if get_unit(unit).dimension != "pressure" or parameter in DIFFERENCE:
150 return None
151 return get_unit(drawn_unit(parameter, unit, symbol)).to_si(float(value)) / _PSI
152 except (UnknownUnit, PressureReferenceError, TypeError, ValueError):
153 return None
154
155
156def parameter_table(payload: Mapping[str, Any]) -> List[Dict[str, Any]]:
157 """Every parameter on the drawing, as the drawing states it: what a person reviews before
158 deciding what to measure next. An absolute pressure also carries ``psia``, the number it means
159 (:func:`absolute_psia`): the table shows and takes that one."""
160 rows: List[Dict[str, Any]] = []
161 for kind, items in (("node", payload.get("nodes") or []), ("edge", payload.get("edges") or [])):
162 for item in items:
163 if not isinstance(item, dict):
164 continue
165 data = item.get("data") if isinstance(item.get("data"), dict) else {}
166 params = data.get("params") or {}
167 if not isinstance(params, dict):
168 continue
169 label = data.get("label") or item.get("id")
170 ctype = data.get("componentType") or item.get("type") or kind
171 for name, p in params.items():
172 if not isinstance(p, dict) or "value" not in p:
173 continue
174 try:
175 value = float(p["value"])
176 except (TypeError, ValueError):
177 continue
178 row = {"target": f"{kind}:{item.get('id')}", "label": label, "type": ctype,
179 "parameter": name, "value": value, "unit": p.get("unit", ""),
180 "source": p.get("source", ""), "reference": p.get("reference", "")}
181 psia = absolute_psia(name, value, str(p.get("unit", "")), str(ctype))
182 if psia is not None:
183 row["psia"] = psia
184 rows.append(row)
185 return rows
186
187
188def override_view(o: Override, rows: List[Dict[str, Any]]) -> Dict[str, Any]:
189 """A stored restatement as the table shows it: on an absolute pressure, with ``psia`` too, so a
190 restatement saved as a bare (gauge) psi before the table spoke psia reads back as what it means."""
191 out = asdict(o)
192 row = next((r for r in rows if r["target"] == o.target and r["parameter"] == o.parameter), None)
193 if row is not None and "psia" in row:
194 psia = absolute_psia(o.parameter, o.value, o.unit, str(row.get("type") or ""))
195 if psia is not None:
196 out["psia"] = psia
197 return out
198
199
200def lineage(drawing: Any) -> str:
201 """What stays the same when a drawing is edited: the shipped file or the pid-designer document
202 (without its release). An upload has no identity beyond its content: two different files
203 uploaded under one name must not share restatements, so an upload's lineage is its content id."""
204 source = str(getattr(drawing, "source", "") or "")
205 if source.startswith("pid-designer:"):
206 return source.split("@", 1)[0]
207 if source.startswith("shipped:"):
208 return source
209 return f"upload:{getattr(drawing, 'id', '')}"
210
211
213 """One person's overrides, per drawing, in ``<user dir>/layerx/measurements/<key>.json``.
214
215 Keyed by the drawing's lineage (:func:`lineage`), so restatements carry over when the drawing
216 is edited; each file records the revision they were entered against, and an element the new
217 revision no longer has is reported by :func:`apply_overrides`, not dropped. A bare content id
218 (the old key) is still read."""
219
220 def __init__(self, user_dir: Optional[Path]) -> None:
221 self.dir = (user_dir / "layerx" / "measurements") if user_dir is not None else None
222
223 @staticmethod
224 def _key(drawing: Any) -> str:
225 if isinstance(drawing, str):
226 return drawing
227 return hashlib.sha256(lineage(drawing).encode("utf-8")).hexdigest()[:16]
228
229 def _path(self, key: str) -> Optional[Path]:
230 if self.dir is None or not re.fullmatch(r"[0-9a-f]{16}", key):
231 return None
232 return self.dir / f"{key}.json"
233
234 def _read(self, drawing: Any) -> Tuple[Optional[Dict[str, Any]], List[str]]:
235 keys = [self._key(drawing)]
236 if not isinstance(drawing, str):
237 keys.append(str(drawing.id))
238 for key in keys:
239 path = self._path(key)
240 if path is None or not path.is_file():
241 continue
242 try:
243 return json.loads(path.read_text()), []
244 except (OSError, json.JSONDecodeError) as exc:
245 return None, [f"{path.name} could not be read ({type(exc).__name__}); its restatements are not applied"]
246 return None, []
247
248 def get(self, drawing: Any) -> List[Override]:
249 """``drawing`` is a :class:`~engine.layerx.sources.Drawing` (or, for old callers, its id)."""
250 return self.load(drawing)[0]
251
252 def load(self, drawing: Any) -> Tuple[List[Override], List[str], Optional[str]]:
253 """``(overrides, problems, revision)``: what is restated, what could not be read, and the
254 sha256 of the drawing they were entered against."""
255 raw, problems = self._read(drawing)
256 if raw is None:
257 return [], problems, None
258 out = []
259 for item in raw.get("overrides", []):
260 try:
261 out.append(Override.from_dict(item))
262 except (KeyError, TypeError, ValueError) as exc:
263 problems.append(f"{item.get('target')}.{item.get('parameter')}: {exc}; not applied")
264 return out, problems, raw.get("drawing_sha256")
265
266 def put(self, drawing: Any, overrides: List[Override]) -> None:
267 path = self._path(self._key(drawing))
268 if path is None:
269 raise ValueError("no place to keep measurements for this drawing")
270 path.parent.mkdir(parents=True, exist_ok=True)
271 keys = [o.key() for o in overrides]
272 if len(keys) != len(set(keys)):
273 raise ValueError("two overrides for the same parameter")
274 record: Dict[str, Any] = {"updated": time.time(), "overrides": [asdict(o) for o in overrides]}
275 if not isinstance(drawing, str):
276 record.update({"lineage": lineage(drawing), "drawing_id": drawing.id, "drawing_sha256": drawing.sha256})
277 path.write_text(json.dumps(record, indent=1))
List[Override] get(self, Any drawing)
None __init__(self, Optional[Path] user_dir)
Tuple[Optional[Dict[str, Any]], List[str]] _read(self, Any drawing)
Tuple[List[Override], List[str], Optional[str]] load(self, Any drawing)
None put(self, Any drawing, List[Override] overrides)
Optional[Path] _path(self, str key)
"Override" from_dict(cls, Mapping[str, Any] raw)
Optional[float] absolute_psia(str parameter, float value, str unit, str symbol="")
Tuple[Dict[str, Any], List[Dict[str, Any]], List[str]] apply_overrides(Mapping[str, Any] payload, List[Override] overrides)
Dict[str, Any] override_view(Override o, List[Dict[str, Any]] rows)
List[Dict[str, Any]] parameter_table(Mapping[str, Any] payload)
Optional[Dict[str, Any]] _element(Dict[str, Any] payload, str target)
TOML_NODISCARD_CTOR time() noexcept=default