STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
diff.py
Go to the documentation of this file.
1"""One change list for every Layer X tool that proposes a change: Set point, Hardware, Injector holes.
2
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::
6
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": "..."}
15
16``target`` says where the number lives, and with it which way the change is written:
17
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).
27
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.
31
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.
35"""
36
37from __future__ import annotations
38
39import copy
40import json
41from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
42
43SCHEMA = "layerx.change-list/1"
44
45CAD_IMPACTS = ("none", "re-drill", "new plate", "new part", "setting only")
46DOMAINS = ("operation", "drawing", "design", "model")
47#: Where an after-value comes from. The first three are the drawing's own provenances.
48SOURCES = ("measured", "manufacturer", "estimated", "solved", "catalog", "fitted", "assumed")
49
50#: The figures a change list reports the effect on: key -> (label, unit). The keys are the
51#: tools' burn figures (setpoint.figures).
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"),
69 # The replay's figure, on EngineDesign's feed_system: the graded one is on the drawing's lines.
70 "chug_margin_min": ("Chug margin, lowest (config feed basis)", ""),
71 "dome_psig": ("Dome dial", "psig"),
72 "lockup_psia": ("Tank lockup", "psia"),
73}
74
75
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]
95 return {
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,
102 }
103
104
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:
109 return {}
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)
116 return out
117
118
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."""
122 if not after:
123 return []
124 rows = []
125 for k, (label, unit) in FIGURES.items():
126 a = after.get(k)
127 b = (before or {}).get(k)
128 if not isinstance(a, (int, float)) or isinstance(a, bool):
129 continue
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})
132 if limits:
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]})
136 return rows
137
138
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)
146 out_changes = []
147 for c in changes:
148 c = dict(c)
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)
154 return {
155 "schema": SCHEMA,
156 "tool": tool,
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},
163 }
164
165
166# ------------------------------------------------------------------ where each domain goes
167
168
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").
171
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"])
182 out[keys[1]] = None
183 return out
184
185
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."""
190 if not updates:
191 return None
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")
194 return {
195 "method": "PUT",
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",
201 }
202
203
204def overrides_of(diff: Mapping[str, Any]) -> List[Any]:
205 """The drawing-domain changes as restatements (engine.layerx.measurements.Override)."""
206 from engine.layerx.measurements import Override
207
208 out = []
209 for c in diff.get("changes") or []:
210 if c.get("domain") != "drawing" or not isinstance(c.get("after"), (int, float)):
211 continue
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))
215 return out
216
217
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.
222
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)
227 if not overrides:
228 return None
229 from engine.layerx.measurements import apply_overrides
230
231 patched, applied, missing = apply_overrides(payload, overrides)
232 if missing:
233 raise ValueError(f"the drawing has no {', '.join(missing)}: the change list is for another revision")
234 return {
235 "schema": "pid-designer/diagram",
236 "name": name,
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"],
241 "applied": applied},
242 "apply": [
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",
246 ],
247 "needs_pid_designer": list(diff.get("needs_pid_designer") or []),
248 }
249
250
251def to_json(obj: Mapping[str, Any]) -> str:
252 return json.dumps(obj, indent=1, sort_keys=False, default=str)
253
254
255# ------------------------------------------------------------------ Injector holes
256
257
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))]
270 pair = None
271 if grid and target.get("thrust_N") and target.get("of"):
272 # The drill pair nearest the target in thrust and O/F together (each relative), in Forward mode:
273 # the nearest drill per side can miss O/F by 3 % (AUDIT 5.3: #51/#53 gave O/F 1.5501 for 1.5).
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)
279 if row is not None:
280 d0 = row.get("from")
281 d1_mm = row.get("to")
282 else:
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 []
286 out.append(change(
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:
302 out.append(change(
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)
308 if arow is not None:
309 out.append(change(
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:
316 out.append(change(
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}
331 return d
#define min(a, b)
Definition Arduino.h:42