1"""Phase 5: what has been measured, put where the assumption used to be.
3A drawing carries every number the twin needs, each with a provenance:
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.
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.
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,
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.
29from __future__
import annotations
37from dataclasses
import asdict, dataclass
38from pathlib
import Path
39from typing
import Any, Dict, List, Mapping, Optional, Tuple
41PROVENANCES = (
"measured",
"manufacturer",
"estimated")
44@dataclass(frozen=True)
46 """One drawing parameter, restated."""
49 """``node:<symbol id>`` or ``edge:<line id>``."""
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
61 return f
"{self.target}.{self.parameter}"
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()
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")
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)):
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))
93SIGNED_PARAMETERS = frozenset({
"elevation_change"})
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:
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.
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.
111 out = copy.deepcopy(dict(payload))
112 applied: List[Dict[str, Any]] = []
113 missing: List[str] = []
117 missing.append(o.key())
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
132_PSI = 6894.757293168361
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.
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
149 if get_unit(unit).dimension !=
"pressure" or parameter
in DIFFERENCE:
151 return get_unit(drawn_unit(parameter, unit, symbol)).to_si(float(value)) / _PSI
152 except (UnknownUnit, PressureReferenceError, TypeError, ValueError):
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 [])):
163 if not isinstance(item, dict):
165 data = item.get(
"data")
if isinstance(item.get(
"data"), dict)
else {}
166 params = data.get(
"params")
or {}
167 if not isinstance(params, dict):
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:
175 value = float(p[
"value"])
176 except (TypeError, ValueError):
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))
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."""
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 ""))
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:"):
209 return f
"upload:{getattr(drawing, 'id', '')}"
213 """One person's overrides, per drawing, in ``<user dir>/layerx/measurements/<key>.json``.
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."""
220 def __init__(self, user_dir: Optional[Path]) ->
None:
221 self.
dir = (user_dir /
"layerx" /
"measurements")
if user_dir
is not None else None
225 if isinstance(drawing, str):
227 return hashlib.sha256(
lineage(drawing).encode(
"utf-8")).hexdigest()[:16]
229 def _path(self, key: str) -> Optional[Path]:
230 if self.
dir is None or not re.fullmatch(
r"[0-9a-f]{16}", key):
232 return self.
dir / f
"{key}.json"
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))
239 path = self.
_path(key)
240 if path
is None or not path.is_file():
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"]
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]
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)
257 return [], problems,
None
259 for item
in raw.get(
"overrides", []):
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")
266 def put(self, drawing: Any, overrides: List[Override]) ->
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