1"""One change list for every Layer X tool that proposes a change: Set point, Hardware, Injector holes.
3A tool's answer is a list of changes a person can act on, each saying what moves, from what to
4what, on which part of the drawing or the design, where the new number comes from, and what it
5costs to make (``cad_impact``). One record per change::
7 {"component": "SV-LOX-PRESS", "pid_node_id": "SV_LOX_PRESS", "field": "Cv",
8 "before": 1.7, "after": 3.8, "unit": "Cv",
9 "provenance": "catalog row vent-solenoid-cv3.8 (estimated): ...",
10 "effect": {"mean_thrust_N": +41.2, "of_mean": -0.0011, ...},
11 "cad_impact": "new part",
12 # and, beyond the required keys:
13 "target": "node:SV_LOX_PRESS", "domain": "drawing", "label": "...", "source": "estimated",
14 "before_provenance": "...", "catalog": {...} | None, "drill": {...} | None, "note": "..."}
16``target`` says where the number lives, and with it which way the change is written:
18* ``node:<id>`` / ``edge:<id>`` (domain ``drawing``): a drawing parameter. Never written over the
19 drawing: :func:`pid_designer_export` gives a patched copy for pid-designer ("You own the drawing.
20 We own what it does", docs/integration/pid-to-feedtwin-handoff.md).
21* ``design:<dotted path>`` (domain ``design``): the engine config. Written only on confirmation,
22 through ``PUT /api/config?expect_sha256=`` (backend/routers/config.py), which refuses with 409 when
23 the live design is no longer the one the change was computed for (:func:`design_write`).
24* ``op:<name>`` (domain ``operation``): a stand setting (dome dial, lockup, fill). Nothing is
25 written; :func:`settings_patch` fills the Layer X rail.
26* ``model:<dotted path>`` (domain ``model``): a fitted model number, not a part (the feed K0).
28``effect`` is the change in each graded figure, after minus before, from the *verifying burn* of the
29whole list. With more than one change it is the list's combined effect, said so in
30``effect_basis``: separating them needs a burn per change, which no tool here pays for.
32pid-designer has no import endpoint (lib/stardesign/stardesign/documents.py: create takes a name
33only). Its payload is the graph itself, ``{"nodes": [...], "edges": [...]}``, which ``copy`` then
34``autosave`` accept; :func:`pid_designer_export` returns that graph, patched, with the steps.
37from __future__
import annotations
41from typing
import Any, Dict, Iterable, List, Mapping, Optional, Sequence
43SCHEMA =
"layerx.change-list/1"
45CAD_IMPACTS = (
"none",
"re-drill",
"new plate",
"new part",
"setting only")
46DOMAINS = (
"operation",
"drawing",
"design",
"model")
48SOURCES = (
"measured",
"manufacturer",
"estimated",
"solved",
"catalog",
"fitted",
"assumed")
52FIGURES: Dict[str, tuple] = {
53 "mean_thrust_N": (
"Mean thrust",
"N"),
54 "thrust_t0_N": (
"Thrust at T-0",
"N"),
55 "peak_thrust_N": (
"Peak thrust",
"N"),
56 "min_thrust_N": (
"Lowest thrust",
"N"),
57 "thrust_spread_pct": (
"Thrust spread (peak - lowest) / mean",
"%"),
58 "total_impulse_Ns": (
"Total impulse",
"N·s"),
59 "burn_time_s": (
"Burn time",
"s"),
60 "of_mean": (
"O/F",
""),
61 "pc_mean_psia": (
"Chamber pressure, mean",
"psia"),
62 "isp_mean_s": (
"Isp, mean",
"s"),
63 "ox_peak_psia": (
"LOX tank peak",
"psia"),
64 "fuel_peak_psia": (
"Fuel tank peak",
"psia"),
65 "ox_stiffness_min": (
"LOX injector ΔP/Pc, lowest",
""),
66 "fuel_stiffness_min": (
"Fuel injector ΔP/Pc, lowest",
""),
67 "copv_spare_psi": (
"Bottle over lockup at burnout",
"psi"),
68 "copv_end_psia": (
"Bottle at burnout",
"psia"),
70 "chug_margin_min": (
"Chug margin, lowest (config feed basis)",
""),
71 "dome_psig": (
"Dome dial",
"psig"),
72 "lockup_psia": (
"Tank lockup",
"psia"),
76def change(*, component: str, field: str, before: Any, after: Any, unit: str, provenance: str,
77 cad_impact: str, target: str, domain: Optional[str] =
None, pid_node_id: Optional[str] =
None,
78 label: Optional[str] =
None, source: Optional[str] =
None, before_provenance: Optional[str] =
None,
79 catalog: Optional[Dict[str, Any]] =
None, drill: Optional[Dict[str, Any]] =
None,
80 note: Optional[str] =
None, effect: Optional[Dict[str, float]] =
None) -> Dict[str, Any]:
81 """One change record. ``target`` is ``node:``/``edge:``/``design:``/``op:``/``model:`` plus the id."""
82 kind = target.split(
":", 1)[0]
83 domain = domain
or {
"node":
"drawing",
"edge":
"drawing",
"design":
"design",
"op":
"operation",
84 "model":
"model"}.get(kind)
85 if domain
not in DOMAINS:
86 raise ValueError(f
"change target {target!r}: no domain for {kind!r}")
87 if cad_impact
not in CAD_IMPACTS:
88 raise ValueError(f
"cad_impact {cad_impact!r}; expected one of {', '.join(CAD_IMPACTS)}")
89 if source
is not None and source
not in SOURCES:
90 raise ValueError(f
"source {source!r}; expected one of {', '.join(SOURCES)}")
91 if not str(provenance
or "").strip():
92 raise ValueError(f
"{target}.{field}: a changed number needs its provenance")
93 if pid_node_id
is None and kind
in (
"node",
"edge"):
94 pid_node_id = target.split(
":", 1)[1]
96 "component": component,
"pid_node_id": pid_node_id,
"field": field,
97 "before": before,
"after": after,
"unit": unit,
"provenance": provenance,
98 "effect": dict(effect)
if effect
else {},
"cad_impact": cad_impact,
99 "target": target,
"domain": domain,
"label": label
or f
"{component} {field}",
100 "source": source,
"before_provenance": before_provenance,
101 "catalog": catalog,
"drill": drill,
"note": note,
105def effects(before: Optional[Mapping[str, Any]], after: Optional[Mapping[str, Any]],
106 keys: Optional[Iterable[str]] =
None) -> Dict[str, float]:
107 """``{figure: after - before}`` for every figure both burns have as a number."""
108 if not before
or not after:
110 out: Dict[str, float] = {}
111 for k
in (keys
or FIGURES):
112 a, b = after.get(k), before.get(k)
113 if isinstance(a, (int, float))
and isinstance(b, (int, float))
and not isinstance(a, bool) \
114 and not isinstance(b, bool):
115 out[k] = float(a) - float(b)
119def effect_rows(before: Optional[Mapping[str, Any]], after: Optional[Mapping[str, Any]],
120 limits: Optional[Sequence[Mapping[str, Any]]] =
None) -> List[Dict[str, Any]]:
121 """Each figure before -> after with its change, for the diff's own table."""
125 for k, (label, unit)
in FIGURES.items():
127 b = (before
or {}).get(k)
128 if not isinstance(a, (int, float))
or isinstance(a, bool):
130 rows.append({
"key": k,
"label": label,
"unit": unit,
"before": b,
"after": a,
131 "delta": (float(a) - float(b))
if isinstance(b, (int, float))
and not isinstance(b, bool)
else None})
133 bad = [r
for r
in limits
if r.get(
"grade") ==
"bad"]
134 rows.append({
"key":
"limits_bad",
"label":
"Limits broken",
"unit":
"",
"before":
None,
135 "after": len(bad),
"delta":
None,
"detail": [r.get(
"label")
for r
in bad]})
139def build(tool: str, changes: List[Dict[str, Any]], *, before: Optional[Mapping[str, Any]] =
None,
140 after: Optional[Mapping[str, Any]] =
None, limits: Optional[Sequence[Mapping[str, Any]]] =
None,
141 basis: Optional[Dict[str, Any]] =
None, notes: Optional[List[str]] =
None,
142 needs_pid_designer: Optional[List[Dict[str, Any]]] =
None) -> Dict[str, Any]:
143 """The change list. Each record without its own ``effect`` gets the whole list's, measured from
144 the ``before`` burn to the ``after`` (verifying) burn."""
145 combined = effects(before, after)
149 if not c.get(
"effect"):
150 c[
"effect"] = dict(combined)
151 c[
"effect_basis"] = (
"this change alone" if len(changes) == 1
152 else "combined with the other changes in this list")
153 out_changes.append(c)
157 "changes": out_changes,
158 "effects": effect_rows(before, after, limits),
159 "basis": basis
or {},
160 "notes": list(notes
or []),
161 "needs_pid_designer": list(needs_pid_designer
or []),
162 "counts": {d: sum(1
for c
in out_changes
if c[
"domain"] == d)
for d
in DOMAINS},
169def settings_patch(diff: Mapping[str, Any]) -> Dict[str, Optional[float]]:
170 """The operation-domain changes as a Layer X rail patch ("use these settings").
172 Each clears the rail's own knob for what it sets, as the rail itself does when a person types
173 one or the other (``Rail.tsx``): the dome dial (``dome_psia``) wins over a tank pressure and the
174 absolute fill (``copv_pressure_psia``) over a gauge one in ``prepare``, so with either knob
175 turned the patch was merged in and silently ignored."""
176 rail = {
"op:lockup_psia": (
"tank_pressure_psia",
"dome_psia"),
"op:copv_psig": (
"copv_pressure_psig",
"copv_pressure_psia")}
177 out: Dict[str, Optional[float]] = {}
178 for c
in diff.get(
"changes")
or []:
179 keys = rail.get(c.get(
"target",
""))
180 if keys
and isinstance(c.get(
"after"), (int, float)):
181 out[keys[0]] = float(c[
"after"])
186def design_write(updates: Optional[Mapping[str, Any]], config_sha256: str) -> Optional[Dict[str, Any]]:
187 """The design-domain changes as the one write the server accepts for them: a merge into the
188 live design, refused (409) unless the design is still ``config_sha256``. Nothing is written here;
189 the UI sends it after the person confirms, with the checkout the route requires."""
192 if not config_sha256
or len(config_sha256) != 64:
193 raise ValueError(
"a design write needs the sha256 of the design it was computed for")
196 "path":
"/api/config",
197 "query": {
"expect_sha256": config_sha256},
198 "body": copy.deepcopy(dict(updates)),
199 "requires_confirmation":
True,
200 "precondition":
"409 when the live design is no longer the one these changes were computed for",
204def overrides_of(diff: Mapping[str, Any]) -> List[Any]:
205 """The drawing-domain changes as restatements (engine.layerx.measurements.Override)."""
209 for c
in diff.get(
"changes")
or []:
210 if c.get(
"domain") !=
"drawing" or not isinstance(c.get(
"after"), (int, float)):
212 source = c.get(
"source")
if c.get(
"source")
in (
"measured",
"manufacturer",
"estimated")
else "estimated"
213 out.append(Override(target=c[
"target"], parameter=c[
"field"], value=float(c[
"after"]), unit=c.get(
"unit")
or "",
214 source=str(c[
"provenance"])[:300], provenance=source))
218def pid_designer_export(payload: Mapping[str, Any], diff: Mapping[str, Any], *, name: str,
219 source: str =
"") -> Optional[Dict[str, Any]]:
220 """The drawing with the change list's drawing-domain changes written in, in pid-designer's own
221 payload shape, or ``None`` when the list changes nothing on the drawing.
223 The patched parameters carry ``{value, unit, source, reference}`` exactly as pid-designer stores
224 them (measurements.apply_overrides). The source drawing is never touched: the steps copy it
225 first and autosave the patched graph into the copy."""
226 overrides = overrides_of(diff)
231 patched, applied, missing = apply_overrides(payload, overrides)
233 raise ValueError(f
"the drawing has no {', '.join(missing)}: the change list is for another revision")
235 "schema":
"pid-designer/diagram",
237 "derived_from": source,
238 "nodes": patched.get(
"nodes")
or [],
239 "edges": patched.get(
"edges")
or [],
240 "layerx": {
"changes": [c
for c
in diff.get(
"changes")
or []
if c.get(
"domain") ==
"drawing"],
243 "POST /api/pid/diagrams/copy {owner, id, name}: a copy of the source drawing you own (and hold)",
244 "POST /api/pid/diagrams/{new id}/autosave with this file's {nodes, edges}",
245 "Import the copy into Layer X and burn it: its sha256 is then the change list's after-state",
247 "needs_pid_designer": list(diff.get(
"needs_pid_designer")
or []),
251def to_json(obj: Mapping[str, Any]) -> str:
252 return json.dumps(obj, indent=1, sort_keys=
False, default=str)
258def from_reconcile(result: Mapping[str, Any]) -> Dict[str, Any]:
259 """A reconcile result (engine.layerx.reconcile.run_reconcile) as a change list. Its old keys
260 stay; this is the same answer in the shared format."""
261 upd = result.get(
"design_update")
or {}
262 geo_after = ((upd.get(
"injector")
or {}).get(
"geometry")
or {})
263 dis_after = upd.get(
"discharge")
or {}
264 before = result.get(
"before")
or {}
265 out: List[Dict[str, Any]] = []
266 names = {
"oxidizer":
"LOX",
"fuel":
"Fuel"}
267 target = result.get(
"target")
or {}
268 grid = [r
for r
in result.get(
"drill_grid")
or []
if isinstance(r.get(
"thrust_N"), (int, float))
269 and isinstance(r.get(
"of"), (int, float))]
271 if grid
and target.get(
"thrust_N")
and target.get(
"of"):
274 pair =
min(grid, key=
lambda r: abs(r[
"thrust_N"] / target[
"thrust_N"] - 1.0) + abs(r[
"of"] / target[
"of"] - 1.0))
275 for side, word
in names.items():
276 d0 = before.get(
"d_O_mm" if side ==
"oxidizer" else "d_F_mm")
277 d1 = (geo_after.get(side)
or {}).get(
"d_jet")
278 row = next((r
for r
in result.get(
"changes")
or []
if r.get(
"item") == f
"{word} orifice diameter"),
None)
281 d1_mm = row.get(
"to")
283 d1_mm = d1 * 1e3
if isinstance(d1, (int, float))
else None
284 if isinstance(d0, (int, float))
and isinstance(d1_mm, (int, float))
and abs(d1_mm - d0) > 1e-6:
285 options = (result.get(
"drill_options")
or {}).get(side)
or []
287 component=f
"{word} injector orifices", field=
"d_jet", before=round(d0, 5), after=round(d1_mm, 5),
288 unit=
"mm", target=f
"design:injector.geometry.{side}.d_jet", source=
"solved",
289 provenance=(
"solved: Layer X injector reconcile, holes sized in Forward mode through the drawing's "
290 "fitted feed and burned until they stop moving"),
291 cad_impact=
"re-drill" if d1_mm > d0
else "new plate",
292 drill={
"options": options,
293 "picked": ({
"drill": pair[side],
"d_mm": pair[
"d_O_mm" if side ==
"oxidizer" else "d_F_mm"],
294 "pair": {
"oxidizer": pair[
"oxidizer"],
"fuel": pair[
"fuel"]},
295 "forward": {
"thrust_N": pair[
"thrust_N"],
"of": pair[
"of"]}}
if pair
else None),
296 "note":
"the exact diameter is what was burned; the drill pair is priced in Forward mode only "
297 "(drill_grid), not burned"},
298 note=(row
or {}).get(
"fabrication")))
299 lod = (dis_after.get(side)
or {}).get(
"orifice_l_over_d")
300 lrow = next((r
for r
in result.get(
"changes")
or []
if r.get(
"item") == f
"{word} passage L/d"),
None)
301 if lrow
is not None and isinstance(lod, (int, float))
and abs(lrow[
"to"] - lrow[
"from"]) > 1e-9:
303 component=f
"{word} injector passages", field=
"orifice_l_over_d", before=round(lrow[
"from"], 4),
304 after=round(lod, 4), unit=
"", target=f
"design:discharge.{side}.orifice_l_over_d", source=
"solved",
305 provenance=
"follows the hole: the drilled passage keeps its length", cad_impact=
"none",
306 note=lrow.get(
"fabrication")))
307 arow = next((r
for r
in result.get(
"changes")
or []
if r.get(
"item") == f
"{word} jet angle"),
None)
310 component=f
"{word} jets", field=
"impingement_angle", before=arow[
"from"], after=arow[
"to"], unit=
"deg",
311 target=f
"design:injector.geometry.{side}.impingement_angle", source=
"solved",
312 provenance=
"solved: whole-degree angles, included angle held, that keep the spray resultant's tilt",
313 cad_impact=
"new plate"))
314 for side, fs
in (upd.get(
"feed_system")
or {}).items():
315 if isinstance(fs, dict)
and "K0" in fs:
317 component=f
"{names.get(side, side)} feed loss", field=
"K0", before=
None, after=fs[
"K0"], unit=
"",
318 target=f
"model:feed_system.{side}.K0", source=
"fitted",
319 provenance=f
"fitted to a Layer X burn of the drawing's feed ({(fs.get('derived_from') or {}).get('by', 'feedfit')})",
320 cad_impact=
"none", note=
"a model number, not a part: it makes Forward mode agree with the drawing"))
321 before_burn, after_burn = result.get(
"before_burn"), result.get(
"after_burn")
322 notes = list(result.get(
"notes")
or [])
323 notes.append(
"Effects are the burned injector's (pass 1 against the last pass); the drill pair is not burned.")
324 notes.append(
"The holes are sized for the target at the T-0 lockup in Forward mode; the regulator's supply-pressure "
325 "effect lifts the burn's mean above it (AUDIT 9.10 5: +0.9 %).")
326 sha = str(result.get(
"config_sha256")
or "")
327 d = build(
"reconcile", out, before=before_burn, after=after_burn, notes=notes,
328 basis={
"config_sha256": sha,
"condition": result.get(
"condition"),
329 "lockup_psia": result.get(
"lockup_psia"),
"target": target})
330 d[
"exports"] = {
"design_write": design_write(upd, sha)
if len(sha) == 64
else None,
"pid_designer":
None}