STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
setpoint.py
Go to the documentation of this file.
1"""Set point: the dome dial, lockup and bottle fill that give a target mean thrust.
2
3The old optimiser searched lockup and fill for the most impulse, and the answer was always its cap:
4impulse at a fixed load rises steadily with lockup (docs/layerx/AUDIT.md D6, 9.10). The operator's
5question is the inverse one -- "which dome and fill give 7.2 kN, and what O/F and tank peak do I get
6there" -- and it has a root, not an optimum. This solves it.
7
8**Unknowns.** Tank lockup [psia] (the dome dial follows from it exactly: ``prepare.dome_for_lockup``
9is affine in the dial) and the bottle fill [psig].
10
11**Equations.**
12
131. Mean thrust of the burn (impulse over burn time, on the replay basis by default) equals the
14 target, within ``thrust_tol_rel``.
152. With ``solve_fill``: the bottle over lockup at burnout lies between ``margin_psi`` and
16 ``margin_psi + margin_tol_psi`` -- the least fill that keeps the margin. The band is one-sided
17 (the solve aims at its middle) so an accepted fill never sits under the margin the shared
18 limit grades ``bottle_margin`` against.
19
20**Method.** Every point is a whole Layer X burn with the rail's settings: no surrogate, and the
21answer is the last burn, never an interpolation.
22
23* Lockup alone (fill held): the current settings and a proportional first guess burn in parallel,
24 then Newton steps whose slope is the least-squares slope of mean thrust against lockup over the
25 burns so far (with two burns this is the secant method; Press et al., *Numerical Recipes*, 3rd ed.,
26 2007, sec. 9.2). On LE4 thrust is linear in lockup (10.0-10.2 N/psi, AUDIT 9.10 3.2), so this is
27 three burns.
28* Then lockup and fill together: Broyden's method (C. G. Broyden, "A class of methods for solving
29 nonlinear simultaneous equations", *Mathematics of Computation* 19(92), 577-593, 1965). Its first
30 Jacobian takes the lockup column from the burns above (measured) and the fill column from a stated
31 assumption (spare rises 1 psi per psi of fill; fill does not move mean thrust), then each burn
32 updates it.
33
34**What it cannot do.** One dome regulator presses both tanks, so the set point cannot move O/F
35(AUDIT 9.10 3.1: -0.087 % over 578-625 psia). It reports O/F and its offset from a stated design
36O/F and points at Hardware mode (holes, line resistance, a trim orifice). Fuel lead is not modelled:
37both mains open together at Fire (AUDIT 9.10 3.6).
38
39**Limits.** Every burn is graded with the shared limits (``engine.layerx.diag.limits.grade`` when it
40exists; until then a fallback built on ``optimize.grade`` plus each tank's peak against its MAWP, its
41MEOP when given, and the design's cap), and a vessel trip ends the burn and fails it. The set point
42is a root, not a search, so a limit does not bend the answer: a solution that breaks one is reported
43as infeasible with the lockup where the binding limit is reached (linear through the burns,
44labelled an estimate).
45"""
46
47from __future__ import annotations
48
49import math
50import time
51from dataclasses import dataclass, field, replace
52from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
53
54PSI = 6894.757293168361
55
56Progress = Callable[[str, float], None]
57
58#: First Jacobian column for the fill (Broyden updates it from the burns). Spare at burnout rises
59#: one psi per psi of fill when the gas drawn is independent of the fill (AUDIT 9.10 3.4 measured
60#: 0.83 psi/psi between 3700 and 4500 psig on LE4, less near dropout); fill does not move the mean
61#: thrust except through the regulator's supply-pressure effect and dropout (-12 N over 800 psi).
62D_SPARE_D_FILL_GUESS = 1.0
63D_THRUST_D_FILL_GUESS = 0.0
64
65#: A Newton step on lockup is capped at this fraction of the lockup: the burns are linear over
66#: +-8 % on LE4, and a step past that is outside what the slope was measured on.
67MAX_LOCKUP_STEP = 0.15
68
69#: The parameters a set point leaves unmeasured: what the integrator puts in the uncertainty sweep,
70#: because each one moves the answer.
71UNMEASURED = [
72 {"key": "regulator.supply_pressure_effect", "where": "drawing PR_D supply_coefficient",
73 "why": "sets the in-burn tank rise, so the lockup for a mean thrust and the tank peak (17 psi/1000 psi "
74 "drawing vs 10 in EngineDesign's 1092 note, AUDIT D9)"},
75 {"key": "regulator.droop", "where": "drawing PR_D droop (He value is a GN2 back-fit tagged measured)",
76 "why": "moves the delivered lockup under flow and the bottle spare (AUDIT D14)"},
77 {"key": "tank.MAWP", "where": "drawing tank MAWP (1000 psi, estimated)", "why": "a limit the answer is graded on"},
78 {"key": "tank.MEOP", "where": "request meop_psi (user-stated 750 psi fuel; LOX unstated)",
79 "why": "a limit the answer is graded on (AUDIT D11)"},
80 {"key": "margin_psi", "where": "request", "why": "assumed 100 psi bottle over lockup at burnout; sets the fill"},
81 {"key": "design_requirements.target_thrust", "where": "config",
82 "why": "the target itself; the YAML says 6500 N, the stated goal is ~7.2 kN (AUDIT 9.10 6)"},
83 {"key": "design_requirements.optimal_of_ratio", "where": "config",
84 "why": "the O/F the offset is reported against (1.5 vs Forward mode's 1.523, AUDIT D12)"},
85 {"key": "nozzle_efficiency", "where": "config (0.95 schema default, no source)",
86 "why": "scales thrust directly, so the lockup for a target (AUDIT D16)"},
87 {"key": "erosion inputs (Bartz coefficient, wall O/F)", "where": "replay",
88 "why": "the replay's mean thrust carries the eroding throat (AUDIT finding 10)"},
89]
90
91
92@dataclass(frozen=True)
94 target_thrust_N: Optional[float] = None
95 """The mean thrust to reach [N]. ``None``: ``design_requirements.target_thrust``."""
96 thrust_tol_rel: float = 1.0e-3
97 solve_fill: bool = True
98 margin_psi: float = 100.0
99 """Bottle over lockup at burnout [psi]: the least fill that keeps it. 100 psi is the margin the
100 old optimiser and the uncertainty sweep use, for the regulator model's single back-fit."""
101 margin_tol_psi: float = 20.0
102 """Width of the accepted band above the margin [psi]: the answer's spare is in
103 [margin_psi, margin_psi + margin_tol_psi], never under the margin."""
104 meop_psi: Optional[Dict[str, float]] = None
105 """Per tank side (``oxidiser``/``fuel``), a maximum expected operating pressure across the wall
106 [psi], graded as a limit on the tank's peak."""
107 design_of: Optional[float] = None
108 """The O/F the offset is reported against. ``None``: ``design_requirements.optimal_of_ratio``."""
109 max_burns: int = 10
110 replay: bool = True
111 """Burn the search on the replay basis (the Burn tab's). Off: search without the replay, then
112 verify the answer with it and correct once for the offset."""
113 verify: bool = True
114 """With ``replay`` off: burn the answer again with the replay (and correct once)."""
115 lockup_bounds_psia: Optional[Tuple[float, float]] = None
116 """Hard window for the lockup. ``None``: 50 %-150 % of the starting lockup, and never above the
117 lowest pressure a tank trips the stand at (the drawing's MAWP, or burst over the safety factor)."""
118
119 @classmethod
120 def from_dict(cls, raw: Dict[str, Any]) -> "SetpointRequest":
121 known = set(cls.__dataclass_fields__)
122 out = cls(**{k: (tuple(v) if k == "lockup_bounds_psia" and v is not None else v)
123 for k, v in raw.items() if k in known and v is not None})
124 if out.max_burns < 1:
125 raise ValueError("max_burns must be at least 1")
126 if not 0.0 < out.thrust_tol_rel < 0.5:
127 raise ValueError("thrust_tol_rel must be between 0 and 0.5")
128 return out
129
130
131def target_thrust(config: Any, request: SetpointRequest) -> Tuple[float, str]:
132 """The mean thrust to reach and where it came from. Raises ``ValueError`` when there is none."""
133 if request.target_thrust_N:
134 return float(request.target_thrust_N), "request"
135 req = getattr(config, "design_requirements", None)
136 value = getattr(req, "target_thrust", None) if req is not None else None
137 if not value or not math.isfinite(float(value)) or float(value) <= 0:
138 raise ValueError("No target thrust: the design states no design_requirements.target_thrust; enter one.")
139 return float(value), ("design_requirements.target_thrust (the schema calls it a peak thrust; the set point "
140 "targets the burn's mean)")
141
142
143def design_of_ratio(config: Any, request: Any) -> Tuple[Optional[float], str]:
144 if getattr(request, "design_of", None):
145 return float(request.design_of), "request"
146 req = getattr(config, "design_requirements", None)
147 value = getattr(req, "optimal_of_ratio", None) if req is not None else None
148 return ((float(value), "design_requirements.optimal_of_ratio (AUDIT D12: Forward mode's O/F is the other "
149 "candidate reference)") if value else (None, "none stated"))
150
151
152# ---------------------------------------------------------------------- one burn
153
154
155def figures(res: Dict[str, Any], lockup_psia: Optional[float]) -> Dict[str, Any]:
156 """A burn's graded figures, flat, on the delivered (replay) basis where there is one. The keys
157 are the change list's (engine.layerx.diff.FIGURES)."""
158 s = res.get("summary") or {}
159 dv_all = res.get("delivered") or {}
160 dv = dv_all.get("summary") or {}
161 ox, fu = s.get("ox") or {}, s.get("fuel") or {}
162
163 def pick(key: str) -> Any:
164 v = dv.get(key)
165 return v if v is not None else s.get(key)
166
167 mean, peak, low = pick("mean_thrust_N"), pick("peak_thrust_N"), pick("min_thrust_N")
168 thrust_series = dv_all.get("thrust_N") or []
169 t0 = next((v for v in thrust_series if isinstance(v, (int, float)) and math.isfinite(v)), None)
170 copv_end = s.get("copv_end_psia")
171 # The bottle's spare on the shared limit's basis (diag.limits ``bottle_margin``): over the higher
172 # tank's pressure at T-0 when the run reports it, else over the lockup asked for. One number, so
173 # the fill the solve accepts is the one the limit grades.
174 t0_tanks = [float(v) for v in (ox.get("t0_psia"), fu.get("t0_psia"))
175 if isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v)]
176 spare_over = max(t0_tanks) if t0_tanks else lockup_psia
177 return {
178 "mean_thrust_N": mean,
179 "thrust_t0_N": t0 if t0 is not None else s.get("thrust_t0_N"),
180 "peak_thrust_N": peak,
181 "min_thrust_N": low,
182 "thrust_spread_pct": ((peak - low) / mean * 100.0) if mean and peak is not None and low is not None else None,
183 "total_impulse_Ns": pick("total_impulse_Ns"),
184 "impulse_to_depletion_Ns": pick("impulse_to_depletion_Ns"),
185 "burn_time_s": s.get("burn_time_s"),
186 "of_mean": s.get("of_mean"),
187 "pc_mean_psia": pick("pc_mean_psia"),
188 "isp_mean_s": pick("isp_mean_s"),
189 "ox_peak_psia": ox.get("peak_psia"),
190 "fuel_peak_psia": fu.get("peak_psia"),
191 "ox_stiffness_min": ox.get("stiffness_min"),
192 "fuel_stiffness_min": fu.get("stiffness_min"),
193 "ox_residual_kg": ox.get("residual_kg"),
194 "fuel_residual_kg": fu.get("residual_kg"),
195 "ox_used_kg": (ox["loaded_kg"] - ox["residual_kg"]) if ox.get("loaded_kg") is not None
196 and ox.get("residual_kg") is not None else None,
197 "fuel_used_kg": (fu["loaded_kg"] - fu["residual_kg"]) if fu.get("loaded_kg") is not None
198 and fu.get("residual_kg") is not None else None,
199 "copv_end_psia": copv_end,
200 "copv_spare_psi": (copv_end - spare_over) if copv_end is not None and spare_over else None,
201 # The replay's, on EngineDesign's feed_system (``chug_margin_basis``); these burns run no stability
202 # diagnostic, so the drawing-basis margin the Burn tab grades is not computed here.
203 "chug_margin_min": dv.get("chug_margin_min"),
204 "chug_margin_basis": "config" if dv.get("chug_margin_min") is not None else None,
205 "throat_area_growth": dv.get("throat_area_growth"),
206 "depleted_side": s.get("depleted_side"),
207 "failed_steps": s.get("failed_steps"),
208 "card_outside_steps": s.get("card_outside_steps"),
209 "converged": res.get("converged", True),
210 "replayed": bool(dv),
211 }
212
213
214def _row(key: str, label: str, group: str, value: Optional[float], limit: Optional[float], direction: str,
215 unit: str, basis: str, hint: str, *, warn: Optional[float] = None, soft: bool = False) -> Dict[str, Any]:
216 """One limit in the contract's shape (docs/layerx/DATA-CONTRACT.md 1). ``soft``: grades warn, not bad."""
217 if value is None or limit is None:
218 grade = "info"
219 else:
220 over = value > limit if direction == "max" else value < limit
221 near = warn is not None and (value > warn if direction == "max" else value < warn)
222 grade = ("warn" if soft else "bad") if over else ("warn" if near else "ok")
223 return {"key": key, "label": label, "group": group, "value": value, "unit": unit, "limit": limit, "warn": warn,
224 "direction": direction, "grade": grade, "t_worst": None, "index_worst": None, "series_ref": None,
225 "basis": basis, "hint": hint}
226
227
228def _fallback_limits(res: Dict[str, Any], prep: Any, config: Any, grading: Dict[str, Any]) -> List[Dict[str, Any]]:
229 """The limits until ``engine/layerx/diag/limits.py`` exists: ``optimize.grade``'s (bottle margin,
230 injector stiffness, a tank runs dry, every step solves) and each tank's peak against its MAWP and
231 the design's cap."""
232 from engine.layerx import optimize as opt
233
234 d = prep.derived
235 lockup = float(d["target_lockup_psia"])
236 e = {"ok": True, "preflight": [], "figures": opt._figures(res), "x": {"lockup_psia": lockup}, "lockup_psia": lockup}
237 req = opt.OptimizeRequest(dropout_margin_psi=float(grading.get("margin_psi", 100.0)), stiffness=True)
238 graded = opt.grade(e, req, d.get("stiffness_band") or {}, None)
239 groups = {"dropout": "pressurant", "stiffness_ox": "injector", "stiffness_fuel": "injector",
240 "depletion": "propellant", "solve": "model"}
241 rows = []
242 for c in graded["constraints"]:
243 if "value" in c:
244 rows.append(_row(c["key"], c["label"], groups.get(c["key"], "model"), c["value"], c["limit"], c["kind"],
245 c.get("unit", ""), "optimize.grade (fallback)", "the old optimiser's constraint"))
246 else:
247 rows.append({**_row(c["key"], c["label"], groups.get(c["key"], "model"), None, None, "min", "",
248 "optimize.grade (fallback)", c.get("detail", "")), "grade": "bad"})
249 s = res.get("summary") or {}
250 roles = getattr(prep, "roles", {}) or {}
251 mawp = d.get("tank_mawp_psi") or {}
252 ratings = d.get("vessel_ratings") or {}
253 dr = getattr(config, "design_requirements", None)
254 for side, block, cap_key in (("oxidiser", "ox", "max_lox_tank_pressure_psi"), ("fuel", "fuel", "max_fuel_tank_pressure_psi")):
255 peak = (s.get(block) or {}).get("peak_psia")
256 tank = roles.get(side)
257 word = "LOX" if side == "oxidiser" else "Fuel"
258 if tank in mawp:
259 rows.append(_row(f"tank_peak_mawp.{side}", f"{word} tank peak vs rating", "tanks", peak,
260 float(mawp[tank]), "max", "psia",
261 f"the {(ratings.get(tank) or {}).get('words') or f'{float(mawp[tank]):.1f} psia'} {tank} "
262 "trips the stand at (prepare.vessel_trip)",
263 "the twin trips the stand above it (Session._check_limits), absolute"))
264 cap = getattr(dr, cap_key, None) if dr is not None else None
265 if cap:
266 rows.append(_row(f"tank_peak_cap.{side}", f"{word} tank peak vs the design's cap", "tanks", peak, float(cap),
267 "max", "psia", f"design_requirements.{cap_key}, read as psia as the optimiser did",
268 "warn until AUDIT D11 decides whether the cap is a T-0 or a peak limit", soft=True))
269 return rows
270
271
272def grade_limits(res: Dict[str, Any], prep: Any, config: Any, grading: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], str]:
273 """Every limit of one burn: the shared grade when it exists, else the fallback; plus the
274 request's own MEOP and the vessel trip if the shared grade did not carry them."""
275 rows: List[Dict[str, Any]] = []
276 basis = ""
277 try:
278 from engine.layerx.diag.limits import grade as shared # written alongside; may not exist yet
279 except ImportError:
280 shared = None
281 meop = {k: float(v) for k, v in (grading.get("meop_psi") or {}).items() if v}
282 if shared is not None:
283 try:
284 # The request's MEOP goes to the shared grade, so it is graded once, on the contract's
285 # basis (psi across the wall, ``tank_meop_ox``/``tank_meop_fuel``).
286 rows = [dict(r) for r in shared(res, prep=prep, config=config, meop_psi=meop or None) or []]
287 basis = "engine.layerx.diag.limits.grade"
288 except Exception as exc: # noqa: BLE001 - the burn stands; the fallback grades it
289 basis = f"diag.limits.grade failed ({type(exc).__name__}: {exc}); "
290 rows = []
291 if not rows:
292 rows = _fallback_limits(res, prep, config, grading)
293 basis += "fallback: optimize.grade, tank peaks vs MAWP and the design cap"
294 keys = {r.get("key") for r in rows}
295 d = prep.derived
296 ambient_psia = float(d.get("ambient_pa") or 101325.0) / PSI
297 s = res.get("summary") or {}
298 for side, block in (("oxidiser", "ox"), ("fuel", "fuel")):
299 m = meop.get(side)
300 if m and f"tank_peak_meop.{side}" not in keys and f"tank_meop_{block}" not in keys:
301 rows.append(_row(f"tank_peak_meop.{side}", f"{'LOX' if side == 'oxidiser' else 'Fuel'} tank peak vs MEOP",
302 "tanks", (s.get(block) or {}).get("peak_psia"), m + ambient_psia, "max", "psia",
303 "the request's MEOP over the site's atmosphere", "MEOP is across the wall"))
304 if not any("trip" in str(k) for k in keys):
305 tripped = res.get("tripped")
306 rows.append({**_row("vessel_trip", "No vessel trips", "model", None, None, "max", "",
307 "result.tripped (feedtwin MAWP trip)", "a trip stops the burn; what follows is frozen"),
308 "grade": "bad" if tripped else "ok", "value": tripped or None})
309 return rows, basis
310
311
312def _dome_per_1000psi_fill(prep: Any, lockup_psia: float, fill_psig: float) -> Optional[float]:
313 """d(dome dial)/d(fill) at this lockup [psi per 1000 psi], from the drawing's regulators on a copy
314 of the model (the burn's own model is not touched)."""
315 import copy
316
317 from feedtwin.session.gauge import from_psig
318
319 from engine.layerx.prepare import dome_for_lockup
320
321 try:
322 model = copy.deepcopy(prep.model)
323 hi = dome_for_lockup(model, lockup_psia * PSI, from_psig(fill_psig + 250.0))
324 lo = dome_for_lockup(model, lockup_psia * PSI, from_psig(max(fill_psig - 250.0, lockup_psia)))
325 span = (fill_psig + 250.0) - max(fill_psig - 250.0, lockup_psia)
326 if hi is None or lo is None or span <= 0:
327 return None
328 # A readout to a thousandth of a psi per 1000 psi: two solved dials subtracted read 14.699999999999818.
329 return round((hi - lo) / span * 1000.0, 3)
330 except Exception: # noqa: BLE001 - a readout
331 return None
332
333
334def _stand_ids(prep: Any) -> Dict[str, Optional[str]]:
335 """The drawing ids the stand settings belong to: the dome loader (the dial), the dome-loaded
336 regulator (lockup), the bottle (fill)."""
337 from engine.layerx.prepare import _dome_regulators
338
339 out: Dict[str, Optional[str]] = {"loader": None, "regulator": None, "bottle": prep.derived.get("copv_id")}
340 try:
341 loaders = list(getattr(prep.model.built, "dome_loaders", {}).values())
342 out["loader"] = loaders[0].id if loaders else None
343 regs = _dome_regulators(prep.model)
344 out["regulator"] = regs[0][0] if regs else None
345 except Exception: # noqa: BLE001 - labels only
346 pass
347 return out
348
349
350def burn_point(args: Dict[str, Any]) -> Dict[str, Any]:
351 """Burn one point, start to finish, in whatever process runs it, and grade it.
352
353 ``args``: config, drawing, settings, overrides, and optionally lockup_psia, fill_psig, replay,
354 extra_overrides (drawing restatements for this point only), grading {margin_psi, meop_psi},
355 index, tag. Never raises: a failure is ``ok: False`` with the error."""
356 from engine.layerx.analysis import run_prepared
357 from engine.layerx.prepare import at_operating_point, prepare
358
359 started = time.perf_counter()
360 base = {"index": args.get("index"), "tag": args.get("tag"), "lockup_psia": args.get("lockup_psia"),
361 "fill_psig": args.get("fill_psig"), "replay": bool(args.get("replay", True))}
362 try:
363 # At this lockup and fill whatever the rail's dome dial and bottle knob say: they win over a
364 # tank pressure and a gauge fill in prepare(), and every point burned the rail's otherwise.
365 settings = at_operating_point(replace(args["settings"], replay=base["replay"], card_center_psia=None),
366 lockup_psia=args.get("lockup_psia"), fill_psig=args.get("fill_psig"))
367 config = args["config"]
368 prep = prepare(config, None, args["drawing"], settings,
369 list(args.get("overrides") or []) + list(args.get("extra_overrides") or []))
370 d = prep.derived
371 if not prep.ok:
372 return {**base, "ok": False, "preflight": [f"{c.label}: {c.detail}" for c in prep.checks if c.status == "fail"],
373 "error": "preflight failed", "wall_s": time.perf_counter() - started}
374 lockup, fill = float(d["target_lockup_psia"]), float(d["copv_psig"])
375 dome_rate = _dome_per_1000psi_fill(prep, lockup, fill)
376 ids = _stand_ids(prep)
377 res = run_prepared(prep, replay=base["replay"], config=config)
378 figs = figures(res, lockup)
379 figs.update({"dome_psig": d["dome_psig"], "lockup_psia": lockup})
380 limits, limits_basis = grade_limits(res, prep, config, args.get("grading") or {})
381 return {**base, "ok": True, "preflight": [], "lockup_psia": lockup, "fill_psig": fill, "dome_psig": d["dome_psig"],
382 "dome_per_1000psi_fill": dome_rate, "figures": figs, "limits": limits, "limits_basis": limits_basis,
383 "tripped": res.get("tripped"), "converged": res.get("converged", True),
384 "derived": {"copv_drawn_psig": d.get("copv_drawn_psig"), "tank_mawp_psi": d.get("tank_mawp_psi"),
385 "ambient_pa": d.get("ambient_pa"), "pressurant_gas": d.get("pressurant_gas"),
386 "copv_id": d.get("copv_id"), "config_sha256": prep.config_sha256, "stand_ids": ids,
387 "drawing": {"id": prep.drawing.id, "name": prep.drawing.name, "sha256": prep.drawing.sha256}},
388 "wall_s": time.perf_counter() - started}
389 except Exception as exc: # noqa: BLE001 - one failed burn is reported; the solve decides what to do
390 # A trip in the settle (analysis.StandTripped) is still a trip: the solve reads ``tripped``.
391 record = getattr(exc, "record", None)
392 return {**base, "ok": False, "preflight": [], "error": f"{type(exc).__name__}: {exc}",
393 "tripped": record if isinstance(record, dict) else None, "wall_s": time.perf_counter() - started}
394
395
396def usable(p: Optional[Dict[str, Any]]) -> bool:
397 """A burn whose mean thrust can be used: it ran, passed preflight, did not trip, and reports one."""
398 if not p or not p.get("ok") or p.get("preflight") or p.get("tripped"):
399 return False
400 m = (p.get("figures") or {}).get("mean_thrust_N")
401 return isinstance(m, (int, float)) and math.isfinite(m)
402
403
404def feasible(p: Optional[Dict[str, Any]]) -> bool:
405 return usable(p) and not any(r.get("grade") == "bad" for r in p.get("limits") or [])
406
407
408# ---------------------------------------------------------------------- the solve
409
410
411Evaluate = Callable[[List[Tuple[float, Optional[float], bool]]], List[Dict[str, Any]]]
412
413
414@dataclass
415class _State:
416 history: List[Dict[str, Any]] = field(default_factory=list)
417 notes: List[str] = field(default_factory=list)
418
419
420def _fit_slope(points: Sequence[Tuple[float, float]]) -> Optional[float]:
421 """Least-squares slope through ``(x, y)``; with two points it is the secant."""
422 if len(points) < 2:
423 return None
424 n = float(len(points))
425 mx = sum(p[0] for p in points) / n
426 my = sum(p[1] for p in points) / n
427 sxx = sum((p[0] - mx) ** 2 for p in points)
428 if sxx <= 1e-12:
429 return None
430 return sum((p[0] - mx) * (p[1] - my) for p in points) / sxx
431
432
433def solve(evaluate: Evaluate, *, start_lockup: float, start_fill: float, max_fill: float, target: float,
434 request: SetpointRequest, bounds: Tuple[float, float], first_guess: Optional[float] = None,
435 say: Progress = lambda s, f: None, cancelled: Callable[[], bool] = lambda: False,
436 seed: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
437 """The root, by whole burns through ``evaluate``. Pure in everything but ``evaluate``, so it is
438 tested against a closed-form stand.
439
440 ``evaluate([(lockup, fill, replay), ...])`` burns the points (in parallel when it can) and returns
441 one :func:`burn_point`-shaped dict each. ``first_guess`` is the second lockup of the opening pair
442 (burned alongside the starting point); ``None``: proportional to the thrust missing. ``seed``: burns
443 already made at the starting point (Hardware mode's candidate), used instead of burning it again;
444 they do not count against ``max_burns``."""
445 from engine.layerx.pool import Cancelled
446
447 st = _State()
448 lo, hi = bounds
449 replay = bool(request.replay)
450 tol_F = request.thrust_tol_rel * target
451 seeded = [{**p, "stage": "seed", "asked": {"lockup_psia": p.get("lockup_psia"), "fill_psig": p.get("fill_psig")}}
452 for p in (seed or [])]
453 st.history.extend(seeded)
454 budget = max(1, int(request.max_burns)) + len(seeded)
455
456 def burn(points: List[Tuple[float, float]], stage: str, *, rp: Optional[bool] = None) -> List[Dict[str, Any]]:
457 if cancelled():
458 raise Cancelled()
459 left = budget - len(st.history)
460 if left <= 0:
461 return []
462 points = points[:left]
463 out = evaluate([(L, F, replay if rp is None else rp) for L, F in points])
464 for (L, F), p in zip(points, out):
465 p = {**p, "stage": stage, "asked": {"lockup_psia": L, "fill_psig": F}}
466 st.history.append(p)
467 return st.history[-len(points):]
468
469 def thrust_err(p: Dict[str, Any]) -> float:
470 return float(p["figures"]["mean_thrust_N"]) - target
471
472 # The fill's band is one-sided, [margin, margin + tol]: aim at its middle and accept half the tol
473 # either way, so a "solved" fill never leaves less than the margin (which the shared limit grades).
474 half_band = 0.5 * float(request.margin_tol_psi)
475 spare_aim = float(request.margin_psi) + half_band
476
477 def spare_err(p: Dict[str, Any]) -> Optional[float]:
478 s = p["figures"].get("copv_spare_psi")
479 return None if s is None else float(s) - spare_aim
480
481 def clamp(L: float, ref: float) -> float:
482 step = L - ref
483 cap = MAX_LOCKUP_STEP * ref
484 if abs(step) > cap:
485 L = ref + math.copysign(cap, step)
486 return min(max(L, lo), hi)
487
488 # ---- 1. lockup at the current fill ------------------------------------------------------
489 L0, F0 = min(max(start_lockup, lo), hi), start_fill
490 if first_guess is None or not math.isfinite(first_guess):
491 first_guess = L0 * 1.03
492 L1 = clamp(first_guess, L0)
493 if abs(L1 - L0) < 0.5:
494 L1 = clamp(L0 * 1.02, L0)
495 say("Burning the current settings and a first guess", 0.05)
496 pair = seeded + burn([(L1, F0)], "bracket") if seeded else burn([(L0, F0), (L1, F0)], "bracket")
497 before = pair[0] if pair else None
498 good = [p for p in pair if usable(p)]
499 if not good:
500 errs = "; ".join(p.get("error") or "; ".join(p.get("preflight") or []) or "tripped" for p in pair)
501 raise ValueError(f"neither opening burn could be used: {errs}")
502
503 def at_fill(F: float) -> List[Dict[str, Any]]:
504 return [p for p in st.history if usable(p) and abs(float(p["fill_psig"]) - F) < 0.5 and p["replay"] == replay]
505
506 def best_of(ps: List[Dict[str, Any]]) -> Dict[str, Any]:
507 return min(ps, key=lambda p: abs(thrust_err(p)))
508
509 current = best_of(good)
510 bad_retries = 0
511 while abs(thrust_err(current)) > tol_F and len(st.history) < budget:
512 same = at_fill(F0)
513 slope = _fit_slope([(float(p["lockup_psia"]), float(p["figures"]["mean_thrust_N"])) for p in same])
514 if slope is None or slope <= 0:
515 if slope is not None:
516 st.notes.append(f"Mean thrust does not rise with lockup between the burns (slope {slope:.2f} N/psi); "
517 "the solve stopped.")
518 break
519 slope = float(current["figures"]["mean_thrust_N"]) / float(current["lockup_psia"]) # proportional
520 anchor = best_of(same)
521 L_a = float(anchor["lockup_psia"])
522 L_new = clamp(L_a - thrust_err(anchor) / slope, L_a)
523 # A lockup whose burn could not be used (a trip, a failed solve) bounds the next step: go no
524 # further than halfway to it.
525 failed = [float(p["asked"]["lockup_psia"]) for p in st.history
526 if not usable(p) and abs(float(p["asked"]["fill_psig"]) - F0) < 0.5]
527 above = [L for L in failed if L > L_a]
528 below = [L for L in failed if L < L_a]
529 if above and L_new >= min(above):
530 L_new = 0.5 * (L_a + min(above))
531 if below and L_new <= max(below):
532 L_new = 0.5 * (L_a + max(below))
533 if any(abs(float(p["asked"]["lockup_psia"]) - L_new) < 1e-6 and abs(float(p["asked"]["fill_psig"]) - F0) < 0.5
534 for p in st.history):
535 st.notes.append("The next lockup is one already burned (a bound, or the slope's resolution): stopped.")
536 break
537 say(f"Lockup {L_new:.1f} psia (burn {len(st.history) + 1})", 0.1 + 0.5 * len(st.history) / budget)
538 got = burn([(L_new, F0)], "lockup")
539 if not got:
540 break
541 if usable(got[0]):
542 current = best_of(at_fill(F0))
543 else:
544 bad_retries += 1
545 st.notes.append(f"The burn at {L_new:.1f} psia could not be used ({got[0].get('error') or 'tripped'}); "
546 "stepping back toward the last good lockup.")
547 if bad_retries > 2:
548 break
549 lockup_done = abs(thrust_err(current)) <= tol_F
550
551 # ---- 2. lockup and fill together (Broyden) -------------------------------------------------
552 fill_status = "held"
553 if request.solve_fill and lockup_done and spare_err(current) is not None:
554 x = [float(current["lockup_psia"]), float(current["fill_psig"])]
555 r = [thrust_err(current), float(spare_err(current))]
556 same = at_fill(F0)
557 a = _fit_slope([(float(p["lockup_psia"]), float(p["figures"]["mean_thrust_N"])) for p in same])
558 c = _fit_slope([(float(p["lockup_psia"]), float(p["figures"]["copv_spare_psi"])) for p in same
559 if p["figures"].get("copv_spare_psi") is not None])
560 J = [[a if a and a > 0 else float(current["figures"]["mean_thrust_N"]) / x[0], D_THRUST_D_FILL_GUESS],
561 [c if c is not None else -1.0, D_SPARE_D_FILL_GUESS]]
562 fill_status = "solving"
563 damp = 1.0
564 while not (abs(r[0]) <= tol_F and abs(r[1]) <= half_band) and len(st.history) < budget:
565 det = J[0][0] * J[1][1] - J[0][1] * J[1][0]
566 if not math.isfinite(det) or abs(det) < 1e-12:
567 st.notes.append("The fill and lockup no longer move thrust and spare independently: stopped.")
568 break
569 dx = [-damp * (J[1][1] * r[0] - J[0][1] * r[1]) / det, -damp * (-J[1][0] * r[0] + J[0][0] * r[1]) / det]
570 F_new = x[1] + dx[1]
571 f_floor = x[0] + request.margin_psi # below this no burn can keep the margin
572 if F_new > max_fill:
573 if x[1] >= max_fill - 0.5 and r[1] + half_band < 0: # a full bottle under the margin itself
574 fill_status = "bottle_short"
575 st.notes.append(f"A full bottle ({max_fill:.0f} psig, as drawn) leaves {r[1] + spare_aim:.0f} psi "
576 f"at burnout, under the {request.margin_psi:.0f} psi margin: the drawn bottle cannot "
577 "keep the margin at this thrust.")
578 break
579 F_new = max_fill
580 if F_new < f_floor:
581 F_new = 0.5 * (x[1] + f_floor)
582 L_new = clamp(x[0] + dx[0], x[0])
583 say(f"Lockup {L_new:.1f} psia, fill {F_new:.0f} psig (burn {len(st.history) + 1})",
584 0.1 + 0.8 * len(st.history) / budget)
585 got = burn([(L_new, F_new)], "lockup+fill")
586 if not got:
587 break
588 p = got[0]
589 if not usable(p) or spare_err(p) is None:
590 # A trip or a failed solve on the way down in fill: halve the step from the last good point.
591 st.notes.append(f"The burn at {L_new:.1f} psia / {F_new:.0f} psig could not be used "
592 f"({p.get('error') or 'tripped'}); halving the step.")
593 damp *= 0.5
594 if damp < 0.2:
595 break
596 continue
597 damp = 1.0
598 x_new = [float(p["lockup_psia"]), float(p["fill_psig"])]
599 r_new = [thrust_err(p), float(spare_err(p))]
600 s = [x_new[0] - x[0], x_new[1] - x[1]]
601 ss = s[0] ** 2 + s[1] ** 2
602 if ss > 1e-12:
603 y = [r_new[0] - r[0], r_new[1] - r[1]]
604 Js = [J[0][0] * s[0] + J[0][1] * s[1], J[1][0] * s[0] + J[1][1] * s[1]]
605 for i in range(2):
606 for j in range(2):
607 J[i][j] += (y[i] - Js[i]) * s[j] / ss
608 x, r, current = x_new, r_new, p
609 if fill_status == "solving":
610 fill_status = "solved" if abs(r[1]) <= half_band and abs(r[0]) <= tol_F else "unsolved"
611
612 # ---- 3. verify on the replay basis (search burned without it) ------------------------------
613 verified = None
614 if not replay and request.verify and usable(current):
615 say("Verifying the answer with the erosion replay", 0.92)
616 L, F = float(current["lockup_psia"]), float(current["fill_psig"])
617 got = burn([(L, F)], "verify", rp=True)
618 if got and usable(got[0]):
619 verified = got[0]
620 offset = thrust_err(verified)
621 slope = _fit_slope([(float(p["lockup_psia"]), float(p["figures"]["mean_thrust_N"]))
622 for p in at_fill(float(current["fill_psig"]))]) or \
623 float(current["figures"]["mean_thrust_N"]) / L
624 if abs(offset) > tol_F and slope > 0:
625 # The replay moves every lockup by nearly the same amount: correct once for it.
626 got = burn([(clamp(L - offset / slope, L), F)], "verify (corrected)", rp=True)
627 if got and usable(got[0]):
628 verified = got[0]
629 current = verified
630
631 final = current
632 converged = abs(thrust_err(final)) <= tol_F and (not request.solve_fill or fill_status in ("solved", "bottle_short"))
633 if len(st.history) >= budget and not converged:
634 st.notes.append(f"Stopped at the {budget}-burn budget before both targets were met; the last burn is shown.")
635 return {"final": final, "before": before, "history": st.history, "notes": st.notes, "converged": converged,
636 "lockup_converged": lockup_done or abs(thrust_err(final)) <= tol_F, "fill_status": fill_status}
637
638
639# ---------------------------------------------------------------------- the job
640
641
642def _binding_estimate(history: List[Dict[str, Any]], final: Dict[str, Any], target: float) -> List[Dict[str, Any]]:
643 """For each limit the answer breaks: the lockup where it is reached, and the mean thrust there,
644 linear through the burns at the answer's fill. An estimate: not burned."""
645 out = []
646 F = float(final["fill_psig"])
647 same = [p for p in history if usable(p) and abs(float(p["fill_psig"]) - F) < 0.5]
648 thrust_line = _fit_slope([(float(p["lockup_psia"]), float(p["figures"]["mean_thrust_N"])) for p in same])
649 for row in final.get("limits") or []:
650 if row.get("grade") != "bad" or row.get("limit") is None or row.get("value") is None:
651 continue
652 pts = [(float(p["lockup_psia"]), float(r["value"])) for p in same for r in p.get("limits") or []
653 if r.get("key") == row["key"] and isinstance(r.get("value"), (int, float))]
654 k = _fit_slope(pts)
655 if not k:
656 out.append({"key": row["key"], "label": row["label"], "lockup_psia_est": None,
657 "note": "the limit does not move with lockup between the burns"})
658 continue
659 L_lim = float(final["lockup_psia"]) + (float(row["limit"]) - float(row["value"])) / k
660 F_lim = (float(final["figures"]["mean_thrust_N"]) + thrust_line * (L_lim - float(final["lockup_psia"]))
661 if thrust_line else None)
662 out.append({"key": row["key"], "label": row["label"], "lockup_psia_est": L_lim, "mean_thrust_N_est": F_lim,
663 "note": "linear in lockup through the burns at this fill; not burned"})
664 return out
665
666
667def _model_block(target: float, target_source: str, request: SetpointRequest, start: Dict[str, Any],
668 max_fill: float, design_of: Optional[float], of_source: str) -> Dict[str, Any]:
669 return {
670 "name": "Layer X set point: lockup by secant (least-squares slope), then Broyden on (lockup, fill)",
671 "source": ("Secant/false position: Press, Teukolsky, Vetterling & Flannery, Numerical Recipes, 3rd ed. "
672 "(2007), sec. 9.2. Broyden, C. G. (1965), 'A class of methods for solving nonlinear simultaneous "
673 "equations', Mathematics of Computation 19(92), 577-593."),
674 "assumptions": [
675 "Mean thrust rises monotonically with lockup at a fixed fill; the slope is measured from the burns "
676 "(LE4: linear, 10.0-10.2 N/psi, docs/layerx/AUDIT.md 9.10 3.2).",
677 f"First Broyden column for the fill: spare at burnout rises {D_SPARE_D_FILL_GUESS:g} psi per psi of fill, "
678 f"and fill moves mean thrust {D_THRUST_D_FILL_GUESS:g} N/psi (AUDIT 9.10 3.4); each burn updates both.",
679 "Every point is a whole Layer X burn with the rail's settings, graded with the shared limits; the answer "
680 "is the last burn, not an interpolation.",
681 "One dome regulator presses both tanks: the set point does not set O/F.",
682 "Fuel lead is not modelled: both mains open together at Fire.",
683 "The bottle cannot be filled above the pressure the drawing states for it.",
684 ],
685 "inputs": {
686 "target_mean_thrust_N": {"value": target, "unit": "N", "provenance": target_source},
687 "thrust_tol_rel": {"value": request.thrust_tol_rel, "unit": "",
688 "provenance": "request (default 1e-3: ~2x the engine card's fit error)"},
689 "bottle_margin_psi": {"value": request.margin_psi if request.solve_fill else None, "unit": "psi",
690 "provenance": ("request" if request.margin_psi != 100.0 else
691 "assumed: the 100 psi the old optimiser and the sweep use, for the "
692 "regulator model's single back-fit")},
693 "margin_tol_psi": {"value": request.margin_tol_psi, "unit": "psi",
694 "provenance": "request: the accepted band above the margin (one-sided)"},
695 "meop_psi": {"value": request.meop_psi, "unit": "psi",
696 "provenance": "request (user-stated)" if request.meop_psi else "not given"},
697 "max_fill_psig": {"value": max_fill, "unit": "psig", "provenance": "drawing: the bottle's stated pressure"},
698 "start_lockup_psia": {"value": start.get("lockup_psia"), "unit": "psia", "provenance": start.get("source")},
699 "start_fill_psig": {"value": start.get("fill_psig"), "unit": "psig", "provenance": start.get("fill_source")},
700 "design_of": {"value": design_of, "unit": "", "provenance": of_source},
701 },
702 }
703
704
705def _changes(before: Dict[str, Any], final: Dict[str, Any], start: Dict[str, Any], history_n: int) -> List[Dict[str, Any]]:
706 from engine.layerx import diff
707
708 ids = (final.get("derived") or {}).get("stand_ids") or {}
709 prov = f"solved: Layer X set point, {history_n} whole burns; the last one is this setting"
710 rows = []
711 for target, component, field_, unit, b, a, node in (
712 ("op:dome_psig", "Dome loader (the dial)", "dome_psig", "psig", before.get("dome_psig"), final.get("dome_psig"),
713 ids.get("loader")),
714 ("op:lockup_psia", "Tank lockup (dome-loaded regulator)", "lockup_psia", "psia",
715 before.get("lockup_psia"), final.get("lockup_psia"), ids.get("regulator")),
716 ("op:copv_psig", "Bottle fill", "copv_psig", "psig", before.get("fill_psig"), final.get("fill_psig"),
717 ids.get("bottle")),
718 ):
719 if a is None or (isinstance(b, (int, float)) and abs(float(a) - float(b)) < 0.05):
720 continue
721 rows.append(diff.change(component=component, field=field_, before=None if b is None else round(float(b), 2),
722 after=round(float(a), 2), unit=unit, provenance=prov, cad_impact="setting only",
723 target=target, pid_node_id=node, source="solved",
724 before_provenance=start.get("source") if field_ == "lockup_psia" else
725 start.get("fill_source") if field_ == "copv_psig" else "solved for the starting lockup"))
726 return rows
727
728
729def lockup_ceiling_psia(derived: Dict[str, Any]) -> float:
730 """The highest lockup the set point searches [psia]: the lowest pressure a tank trips the stand
731 at, as it is (``derived.tank_mawp_psi`` is absolute: ``prepare.vessel_trip``); ``inf`` when the
732 drawing rates no tank. It used to add the site's atmosphere on top, searching past the trip."""
733 return min((float(v) for v in (derived.get("tank_mawp_psi") or {}).values()), default=math.inf)
734
735
736def run_setpoint(config: Any, drawing: Any, settings: Any, overrides: List[Any], request: SetpointRequest, *,
737 progress: Optional[Progress] = None, cancelled: Callable[[], bool] = lambda: False,
738 workers: Optional[int] = None, evaluate: Optional[Evaluate] = None,
739 stand: Optional[Dict[str, float]] = None) -> Dict[str, Any]:
740 """The set point for ``request`` on this drawing and design.
741
742 ``evaluate`` replaces the burns, and ``stand`` the preflight's readings of the drawing
743 (``lockup_psia``, ``fill_psig``, ``max_fill_psig``, ``reference_thrust_N``, ``mawp_cap_psia``):
744 both for tests, which hand it a closed-form stand."""
745 from engine.layerx import diff
746 from engine.layerx.pool import WorkerPool, default_workers
747
748 say = progress or (lambda stage, fraction: None)
749 started = time.perf_counter()
750 target, target_source = target_thrust(config, request)
751 design_of, of_source = design_of_ratio(config, request)
752 grading = {"margin_psi": request.margin_psi, "meop_psi": dict(request.meop_psi or {}), "design_of": design_of}
753
754 say("Preparing", 0.01)
755 lockup0 = getattr(settings, "tank_pressure_psia", None)
756 cfg_lockup = float(getattr(getattr(config, "lox_tank", None), "initial_pressure_psi", 0.0) or 0.0)
757 # The rail's bottle knob writes the absolute fill (2026-10-07), which prepare() reads first.
758 fill_psia0 = getattr(settings, "copv_pressure_psia", None)
759 fill_psig0 = fill_psia0 - 101325.0 / PSI if fill_psia0 is not None else getattr(settings, "copv_pressure_psig", None)
760 start = {"lockup_psia": lockup0 or cfg_lockup,
761 "source": "the rail's tank pressure" if lockup0 else "config lox_tank.initial_pressure_psi",
762 "fill_psig": fill_psig0,
763 "fill_source": "the rail's bottle pressure" if fill_psig0 is not None else "the drawing's bottle pressure"}
764 reference_F = None
765 max_fill = None
766 mawp_cap = math.inf
767 if evaluate is None:
768 from engine.layerx.prepare import prepare
769
770 nominal = prepare(config, None, drawing, replace(settings, card_center_psia=None), overrides)
771 if not nominal.ok:
772 raise ValueError("Preflight failed: " + "; ".join(c.label for c in nominal.checks if c.status == "fail"))
773 d = nominal.derived
774 start["lockup_psia"] = float(d["target_lockup_psia"])
775 # Where the starting lockup came from, as prepare() decided it (the dome dial wins on the rail).
776 start["source"] = {"set on the rail": "the lockup the rail's dome dial gives",
777 "solved for the rail's tank pressure": "the rail's tank pressure",
778 "as drawn": "the lockup the drawing's dome dial gives",
779 "solved for the design's tank pressure": "config lox_tank.initial_pressure_psi",
780 }.get(str(d.get("dome_source")), start["source"])
781 start["fill_psig"] = float(d["copv_psig"])
782 max_fill = float(d.get("copv_drawn_psig") or d["copv_psig"])
783 reference_F = float((nominal.link.reference or {}).get("F") or 0.0) if nominal.link else None
784 mawp_cap = lockup_ceiling_psia(d)
785 elif stand:
786 start["lockup_psia"] = float(stand.get("lockup_psia") or start["lockup_psia"])
787 if stand.get("fill_psig") is not None:
788 start["fill_psig"] = float(stand["fill_psig"])
789 max_fill = stand.get("max_fill_psig")
790 reference_F = stand.get("reference_thrust_N")
791 mawp_cap = float(stand.get("mawp_cap_psia") or math.inf)
792 if start["fill_psig"] is None:
793 raise ValueError("No bottle pressure: the rail and the drawing state none.")
794 max_fill = max(max_fill or float(start["fill_psig"]), float(start["fill_psig"]))
795 L0 = float(start["lockup_psia"])
796 if not L0:
797 raise ValueError("No starting tank pressure: the rail and the config state none.")
798 lo, hi = request.lockup_bounds_psia or (0.5 * L0, 1.5 * L0)
799 hi = min(hi, mawp_cap)
800 # The first guess: thrust proportional to tank pressure, from EngineDesign's T-0 thrust at the
801 # starting lockup (the mean is a few % above it; the secant corrects that on the next burn).
802 guess = L0 * target / reference_F if reference_F else None
803
804 pool = None
805 if evaluate is None:
806 n_workers = default_workers(2, workers)
807 pool = WorkerPool(n_workers)
808 base_args = {"config": config, "drawing": drawing, "settings": settings, "overrides": list(overrides),
809 "grading": grading}
810 counter = [0]
811
812 def evaluate(points: List[Tuple[float, Optional[float], bool]]) -> List[Dict[str, Any]]:
813 args = []
814 for L, F, rp in points:
815 args.append({**base_args, "lockup_psia": L, "fill_psig": F, "replay": rp, "index": counter[0]})
816 counter[0] += 1
817 return pool.map(burn_point, args, cancelled=cancelled)
818 else:
819 n_workers = 1
820 try:
821 out = solve(evaluate, start_lockup=L0, start_fill=float(start["fill_psig"]), max_fill=max_fill, target=target,
822 request=request, bounds=(lo, hi), first_guess=guess, say=say, cancelled=cancelled)
823 finally:
824 if pool is not None:
825 pool.close(cancelled())
826 final, before, history = out["final"], out["before"], out["history"]
827 notes = list(out["notes"])
828 ok_final = usable(final)
829 if not ok_final:
830 raise ValueError("no usable burn: " + "; ".join(p.get("error") or "" for p in history))
831 fig = final["figures"]
832 of = fig.get("of_mean")
833 # O/F against lockup, from the burns that share a fill (the lockup-only ones, usually).
834 groups: Dict[float, List[Tuple[float, float]]] = {}
835 for p in history:
836 if usable(p) and p["figures"].get("of_mean") is not None:
837 groups.setdefault(round(float(p["fill_psig"]), 0), []).append(
838 (float(p["lockup_psia"]), float(p["figures"]["of_mean"])))
839 lockup_pts = max(groups.values(), key=len) if groups else []
840 of_block = {
841 "of_mean": of, "design_of": design_of, "design_of_source": of_source,
842 "offset_rel": (of / design_of - 1.0) if of and design_of else None,
843 "per_100psi_lockup": (_fit_slope(lockup_pts) or 0.0) * 100.0 if len(lockup_pts) >= 2 else None,
844 "settable_here": False,
845 "note": ("One dome regulator presses both tanks through one manifold, so lockup and fill move O/F only "
846 "through second-order effects. O/F is set by hardware: the holes (Injector holes), the lines, or a "
847 "trim orifice (Hardware mode)."),
848 }
849 is_feasible = feasible(final)
850 binding = _binding_estimate(history, final, target) if not is_feasible else []
851 if not is_feasible:
852 bad = [r["label"] for r in final.get("limits") or [] if r.get("grade") == "bad"]
853 notes.append("The set point breaks " + ", ".join(bad) + ": the target cannot be met within the limits "
854 "on this drawing (see `binding` for where each limit is reached, estimated).")
855 tripped_above = [float(p["asked"]["lockup_psia"]) for p in history
856 if p.get("tripped") and float(p["asked"]["lockup_psia"]) > float(final["lockup_psia"])]
857 if tripped_above and not out["converged"]:
858 notes.append(f"The target lies beyond a lockup that trips the stand ({min(tripped_above):.1f} psia trips): "
859 f"the highest usable lockup found, {float(final['lockup_psia']):.1f} psia, is shown.")
860 if out["fill_status"] == "held" and request.solve_fill:
861 notes.append("The fill was not solved: the lockup did not converge first.")
862 if not request.replay:
863 notes.append("The search burned without the erosion replay" + (
864 "; the answer was verified with it." if request.verify else "; its numbers exclude the eroding throat."))
865 if fig.get("card_outside_steps"):
866 notes.append("The answer's burn asked the engine card about points outside its fitted box.")
867 dome = final.get("dome_psig")
868 dome_rate = final.get("dome_per_1000psi_fill")
869 derived = final.get("derived") or {}
870 card = {
871 "dome_psig": dome,
872 "lockup_psia": final.get("lockup_psia"),
873 "copv_fill_psig": final.get("fill_psig"),
874 "dome_per_1000psi_fill": dome_rate,
875 "dome_at_full_bottle_psig": (dome + dome_rate * (max_fill - float(final["fill_psig"])) / 1000.0
876 if dome is not None and dome_rate is not None else None),
877 "full_bottle_psig": max_fill,
878 "fuel_lead_s": getattr(settings, "fuel_lead_s", None),
879 "fuel_lead": ("the rail's, burned as given (not solved)" if getattr(settings, "fuel_lead_s", None) is not None
880 else "not modelled: both mains open together at Fire"),
881 "pressurant": derived.get("pressurant_gas"),
882 "drawing": derived.get("drawing"),
883 "config_sha256": derived.get("config_sha256"),
884 "ids": derived.get("stand_ids"),
885 }
886 changes = _changes(before or {}, final, start, len(history))
887 before_fig = (before or {}).get("figures") if usable(before) else None
888 if before_fig is not None and before is not None:
889 before_fig = {**before_fig, "dome_psig": before.get("dome_psig"), "lockup_psia": before.get("lockup_psia")}
890 same_basis = before is not None and bool(before.get("replay")) == bool(final.get("replay"))
891 change_list = diff.build("setpoint", changes, before=before_fig if same_basis else None, after=fig,
892 limits=final.get("limits"),
893 basis={"config_sha256": derived.get("config_sha256"), "drawing": derived.get("drawing"),
894 "target_mean_thrust_N": target, "replay": final.get("replay")},
895 notes=[] if same_basis else ["The current settings burned on another basis: no effect "
896 "is quoted."])
897 change_list["exports"] = {"settings_patch": diff.settings_patch(change_list), "design_write": None,
898 "pid_designer": None}
899 say("Done", 1.0)
900 return {
901 "mode": "setpoint",
902 "target": {"mean_thrust_N": target, "source": target_source, "tol_rel": request.thrust_tol_rel,
903 "margin_psi": request.margin_psi if request.solve_fill else None},
904 "converged": out["converged"],
905 "fill_status": out["fill_status"],
906 "feasible": is_feasible,
907 "settings_card": card,
908 "solution": fig,
909 "before": before_fig,
910 "of": of_block,
911 "limits": final.get("limits"),
912 "limits_basis": final.get("limits_basis"),
913 "binding": binding,
914 "history": [{k: v for k, v in p.items() if k not in ("derived",)} for p in history],
915 "change_list": change_list,
916 "model": _model_block(target, target_source, request, start, max_fill, design_of, of_source),
917 "unmeasured": UNMEASURED,
918 "notes": notes,
919 "burns": len(history),
920 "workers": n_workers,
921 "wall_s": time.perf_counter() - started,
922 "summary": {"mean_thrust_N": fig.get("mean_thrust_N"), "total_impulse_Ns": fig.get("total_impulse_Ns"),
923 "burn_time_s": fig.get("burn_time_s"), "of_mean": of, "lockup_psia": final.get("lockup_psia"),
924 "dome_psig": dome, "copv_psig": final.get("fill_psig"), "feasible": is_feasible},
925 "basis": ("Whole Layer X burns with the rail's settings" + (", the erosion replay on" if final.get("replay") else "")
926 + ": a secant on lockup for the mean thrust, then Broyden on lockup and fill for the bottle margin. "
927 "The answer is the last burn."),
928 }
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
"SetpointRequest" from_dict(cls, Dict[str, Any] raw)
Definition setpoint.py:120
Tuple[List[Dict[str, Any]], str] grade_limits(Dict[str, Any] res, Any prep, Any config, Dict[str, Any] grading)
Definition setpoint.py:272
List[Dict[str, Any]] _fallback_limits(Dict[str, Any] res, Any prep, Any config, Dict[str, Any] grading)
Definition setpoint.py:228
Dict[str, Any] _row(str key, str label, str group, Optional[float] value, Optional[float] limit, str direction, str unit, str basis, str hint, *, Optional[float] warn=None, bool soft=False)
Definition setpoint.py:215
List[Dict[str, Any]] _binding_estimate(List[Dict[str, Any]] history, Dict[str, Any] final, float target)
Definition setpoint.py:642
Dict[str, Any] solve(Evaluate evaluate, *, float start_lockup, float start_fill, float max_fill, float target, SetpointRequest request, Tuple[float, float] bounds, Optional[float] first_guess=None, Progress say=lambda s, None f, Callable[[], bool] cancelled=lambda:False, Optional[List[Dict[str, Any]]] seed=None)
Definition setpoint.py:436
Dict[str, Any] _model_block(float target, str target_source, SetpointRequest request, Dict[str, Any] start, float max_fill, Optional[float] design_of, str of_source)
Definition setpoint.py:668
Dict[str, Optional[str]] _stand_ids(Any prep)
Definition setpoint.py:334
Optional[float] _dome_per_1000psi_fill(Any prep, float lockup_psia, float fill_psig)
Definition setpoint.py:312
bool feasible(Optional[Dict[str, Any]] p)
Definition setpoint.py:404
Optional[float] _fit_slope(Sequence[Tuple[float, float]] points)
Definition setpoint.py:420
Dict[str, Any] run_setpoint(Any config, Any drawing, Any settings, List[Any] overrides, SetpointRequest request, *, Optional[Progress] progress=None, Callable[[], bool] cancelled=lambda:False, Optional[int] workers=None, Optional[Evaluate] evaluate=None, Optional[Dict[str, float]] stand=None)
Definition setpoint.py:739
Tuple[Optional[float], str] design_of_ratio(Any config, Any request)
Definition setpoint.py:143
Dict[str, Any] figures(Dict[str, Any] res, Optional[float] lockup_psia)
Definition setpoint.py:155
bool usable(Optional[Dict[str, Any]] p)
Definition setpoint.py:396
List[Dict[str, Any]] _changes(Dict[str, Any] before, Dict[str, Any] final, Dict[str, Any] start, int history_n)
Definition setpoint.py:705
float lockup_ceiling_psia(Dict[str, Any] derived)
Definition setpoint.py:729
Tuple[float, str] target_thrust(Any config, SetpointRequest request)
Definition setpoint.py:131
Dict[str, Any] burn_point(Dict[str, Any] args)
Definition setpoint.py:350