STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
optimize.py
Go to the documentation of this file.
1"""Phase 4: the feed hardware that gets the most out of a fixed propellant load.
2
3Layer 1 sizes the engine at one operating point. This searches the *hardware around it*, with
4the whole burn simulated for every candidate. The decision variables are things a person can
5set or buy:
6
7* **tank lockup** [psia]: the dome dial, solved per candidate from the drawing's regulators;
8* **COPV fill** [psig]: how full the bottle is at T-0;
9* **COPV volume** [L], off by default: a different bottle. Its structure scales with its volume
10 in the flight (``flight._flight_config``).
11
12The pressurant gas is the drawing's, so to compare GN2 against helium, run each drawing.
13
14The objective is **total impulse** (the tanks burnt dry) or, with the flight on, **apogee**, both at
15the fixed load.
16Every constraint is checked over the whole burn:
17
18* **regulator headroom**: the bottle at burnout stays above the lockup by a stated margin;
19* **injector stiffness**: each side's lowest ΔP/Pc stays above the config's band floor (chug);
20* **O/F band** (optional): the burn's O/F within a stated fraction of the design O/F;
21* **apogee ceiling**, when the config declares one (``design_requirements.max_apogee_m``);
22* everything preflight fails on, such as a tank or bottle MAWP or a load that does not fit.
23
24**Search.** A parallel compass search over the variables scaled to [0, 1]. Each iteration burns
25the centre's 2k neighbours at once on a process pool. It moves to the best one when that beats
26the centre; otherwise it halves the step. It stops when the step falls below 1/32 of the range or
27the evaluation budget runs out. Candidates are ranked feasible-first, then by total constraint
28violation, then by objective (Deb's rules), so no penalty weight has to be tuned. The search is
29derivative-free because a burn's answer moves in steps: the burn ends on a time step, and a
30regulator either holds lockup or drops out. Deterministic: the same request gives the same
31path.
32
33**Fidelity.** Candidates burn on one engine card, centred on the lockup range, without the
34erosion replay. The replay moves every candidate by nearly the same amount, so leaving it out
35does not reorder them. The winner is then **verified**: burned again with its own card, the
36replay, and the flight if the objective is apogee. The verified numbers are the ones reported
37as the answer.
38"""
39
40from __future__ import annotations
41
42import math
43import time
44from dataclasses import dataclass, field, replace
45from typing import Any, Callable, Dict, List, Optional, Tuple
46
47PSI = 6894.757293168361
48
49#: Compass search: first step and stopping step, as fractions of each variable's range.
50FIRST_STEP = 0.25
51MIN_STEP = 1.0 / 32.0
52
53#: A violation below this (relative) counts as satisfied: float noise, nothing more. The burn
54#: ends on depletion (BurnPlan.end_on_depletion), so end-of-burn quantities are not quantised
55#: to a step and need no allowance here.
56FEASIBLE = 1e-6
57
58OBJECTIVES = ("impulse", "apogee")
59
60
61@dataclass(frozen=True)
63 key: str
64 label: str
65 unit: str
66 lo: float
67 hi: float
68 start: float
69 enabled: bool = True
70 basis: str = ""
71 """Where the bounds came from."""
72
73
74@dataclass(frozen=True)
76 objective: str = "impulse"
77 variables: Dict[str, Dict[str, Any]] = field(default_factory=dict)
78 """``key -> {"enabled", "lo", "hi"}``; anything unsent keeps its default."""
79 dropout_margin_psi: float = 100.0
80 """Bottle above lockup at burnout [psi]. The twin's regulator already loses lockup by itself
81 when the bottle runs down. This margin is for what the twin does not know: its regulator
82 model comes from one back-fit."""
83 stiffness: bool = True
84 of_band_rel: Optional[float] = None
85 max_evaluations: int = 40
86 verify: bool = True
87
88 @classmethod
89 def from_dict(cls, raw: Dict[str, Any]) -> "OptimizeRequest":
90 known = set(cls.__dataclass_fields__)
91 out = cls(**{k: v for k, v in raw.items() if k in known and v is not None})
92 if out.objective not in OBJECTIVES:
93 raise ValueError(f"objective {out.objective!r}; expected one of {', '.join(OBJECTIVES)}")
94 return out
95
96
97# ---------------------------------------------------------------------- variables
98
99
100def default_variables(prep: Any, config: Any) -> List[Variable]:
101 """The decision variables and their bounds, each bound with its reason."""
102 d = prep.derived
103 lockup = float(d["target_lockup_psia"])
104 req = getattr(config, "design_requirements", None)
105 caps: List[Tuple[float, str]] = [(lockup * 1.15, "+15 % of the current lockup")]
106 for side, key in (("LOX", "max_lox_tank_pressure_psi"), ("fuel", "max_fuel_tank_pressure_psi")):
107 cap = getattr(req, key, None) if req is not None else None
108 if cap:
109 caps.append((float(cap), f"design_requirements.{key} ({side})"))
110 ratings = d.get("vessel_ratings") or {}
111 for tank_id, trip in (d.get("tank_mawp_psi") or {}).items():
112 # The absolute pressure the twin trips the stand at (prepare.vessel_trip): the cap as it is.
113 # It used to add the site's atmosphere on top, one atmosphere and more over the trip.
114 why = (ratings.get(tank_id) or {}).get("words") or f"{trip:.1f} psia"
115 caps.append((float(trip), f"{(ratings.get(tank_id) or {}).get('label') or tank_id} trips the stand at {why}"))
116 hi, hi_why = min(caps, key=lambda c: c[0])
117 hi = round(hi, 1)
118 lo = round(min(lockup * 0.85, hi * 0.95))
119 out = [Variable("lockup_psia", "Tank pressure", "psia", lo, hi, min(max(lockup, lo), hi),
120 basis=f"Regulator lockup before firing. {lo:.0f} psia (−15 %, an assumed window) to {hi:.0f} psia "
121 f"({hi_why}). The dome setting is solved for each.")]
122
123 copv = float(d["copv_psig"])
124 drawn = d.get("copv_drawn_psig") or copv
125 mawp = d.get("copv_mawp_psi")
126 c_hi = float(drawn)
127 c_lo = round(0.6 * c_hi, -1)
128 out.append(Variable("copv_psig", "Bottle fill", "psig", c_lo, c_hi, min(max(copv, c_lo), c_hi),
129 basis=f"{c_lo:.0f} psig (an assumed floor, 60 % of the top) to {c_hi:.0f} psig, the bottle pressure "
130 "the drawing states: a fill above what the bottle is drawn for is not searched"
131 + (f" (the {mawp:.0f} psia it trips the stand at is a proof margin, not a fill)"
132 if mawp else "") + "."))
133
134 vol = d.get("copv_volume_L")
135 if vol:
136 out.append(Variable("copv_volume_L", "Bottle size", "L", round(0.5 * vol, 2), round(2.0 * vol, 2), vol, enabled=False,
137 basis=f"½× to 2× the drawing's {vol:.2f} L. A different bottle; its structure scales "
138 "with its volume in the flight (estimate)."))
139 return out
140
141
142def resolve_variables(prep: Any, config: Any, request: OptimizeRequest) -> List[Variable]:
143 out = []
144 for v in default_variables(prep, config):
145 raw = request.variables.get(v.key) or {}
146 lo = float(raw.get("lo", v.lo))
147 hi = float(raw.get("hi", v.hi))
148 if hi < lo:
149 lo, hi = hi, lo
150 out.append(replace(v, lo=lo, hi=hi, start=min(max(v.start, lo), hi),
151 enabled=bool(raw.get("enabled", v.enabled)) and hi > lo))
152 return out
153
154
155# ---------------------------------------------------------------------- one candidate
156
157
158def _candidate(settings: Any, variables: List[Variable], x: Dict[str, float], copv_id: Optional[str],
159 objective: str, center_psia: float) -> Tuple[Any, List[Any]]:
160 from engine.layerx.measurements import Override
161 from engine.layerx.prepare import at_operating_point
162
163 changes: Dict[str, Any] = {"replay": False, "flight": objective == "apogee", "card_center_psia": center_psia}
164 extra: List[Any] = []
165 volume = next((v for v in variables if v.key == "copv_volume_L"), None)
166 if volume is not None and volume.enabled and "copv_volume_L" in x and copv_id:
167 extra.append(Override(target=f"node:{copv_id}", parameter="volume", value=float(x["copv_volume_L"]),
168 unit="L", source="Layer X optimiser candidate", provenance="estimated"))
169 # The candidate's lockup and fill whatever the rail's dome dial and bottle knob say (they win in prepare()).
170 return at_operating_point(replace(settings, **changes), lockup_psia=x.get("lockup_psia"),
171 fill_psig=x.get("copv_psig")), extra
172
173
174def _figures(res: Dict[str, Any]) -> Dict[str, Any]:
175 """What a candidate's burn says, flat."""
176 s = res["summary"]
177 f = res.get("flight") or {}
178 out = {
179 "total_impulse_Ns": s.get("total_impulse_Ns"),
180 "impulse_to_depletion_Ns": s.get("impulse_to_depletion_Ns"),
181 "burn_time_s": s.get("burn_time_s"),
182 "depletion_s": s.get("depletion_s"),
183 "mean_thrust_N": s.get("mean_thrust_N"),
184 "pc_mean_psia": s.get("pc_mean_psia"),
185 "of_mean": s.get("of_mean"),
186 "isp_mean_s": s.get("isp_mean_s"),
187 "copv_end_psia": s.get("copv_end_psia"),
188 "ox_stiffness_min": s["ox"].get("stiffness_min"),
189 "fuel_stiffness_min": s["fuel"].get("stiffness_min"),
190 "ox_min_psia": s["ox"].get("min_psia"),
191 "fuel_min_psia": s["fuel"].get("min_psia"),
192 "ox_residual_kg": s["ox"].get("residual_kg"),
193 "fuel_residual_kg": s["fuel"].get("residual_kg"),
194 "depleted_side": s.get("depleted_side"),
195 "failed_steps": s.get("failed_steps"),
196 "card_outside_steps": s.get("card_outside_steps"),
197 "converged": res.get("converged", True),
198 "tripped": res.get("tripped"),
199 }
200 if f.get("ok"):
201 out.update({"apogee_agl_m": f["apogee_agl_m"], "max_velocity_m_s": f["max_velocity_m_s"],
202 "max_accel_g": f["max_accel_g"], "ceiling": f.get("ceiling")})
203 return out
204
205
206def _evaluate(args: Tuple[Any, ...]) -> Dict[str, Any]:
207 """Burn one candidate, start to finish, in whatever process runs it."""
208 config, drawing, settings, overrides, variables, x, copv_id, objective, center, index = args
209 from engine.layerx.analysis import run_prepared
210 from engine.layerx.prepare import prepare
211
212 started = time.perf_counter()
213 try:
214 st, extra = _candidate(settings, variables, x, copv_id, objective, center)
215 prep = prepare(config, None, drawing, st, list(overrides) + extra)
216 if not prep.ok:
217 failing = [f"{c.label}: {c.detail}" for c in prep.checks if c.status == "fail"]
218 return {"index": index, "x": x, "ok": True, "preflight": failing, "figures": None,
219 "wall_s": time.perf_counter() - started}
220 res = run_prepared(prep, replay=False, config=config if objective == "apogee" else None)
221 return {"index": index, "x": x, "ok": True, "preflight": [], "figures": _figures(res),
222 "lockup_psia": prep.derived["target_lockup_psia"], "dome_psig": prep.derived["dome_psig"],
223 "wall_s": time.perf_counter() - started}
224 except Exception as exc: # noqa: BLE001 - one failed candidate is reported, the search goes on
225 return {"index": index, "x": x, "ok": False, "error": f"{type(exc).__name__}: {exc}",
226 "wall_s": time.perf_counter() - started}
227
228
229# ---------------------------------------------------------------------- grading
230
231
232def grade(e: Dict[str, Any], request: OptimizeRequest, band: Dict[str, Optional[List[float]]],
233 design_of: Optional[float]) -> Dict[str, Any]:
234 """Objective and constraints for one evaluated candidate. Violations are relative, so they add
235 without weights; ``violation`` is their sum."""
236 rows: List[Dict[str, Any]] = []
237 if not e.get("ok"):
238 return {"objective": None, "violation": math.inf, "constraints": [
239 {"key": "burn", "label": "The burn runs", "ok": False, "detail": e.get("error", "failed")}]}
240 if e["preflight"]:
241 return {"objective": None, "violation": 1e3, "constraints": [
242 {"key": "preflight", "label": "Preflight", "ok": False, "detail": "; ".join(e["preflight"])}]}
243 f = e["figures"]
244
245 def add(key: str, label: str, value: Optional[float], limit: float, kind: str, scale: float, unit: str) -> None:
246 if value is None:
247 rows.append({"key": key, "label": label, "ok": False, "violation": 1.0, "detail": "not computed"})
248 return
249 short = (limit - value) if kind == "min" else (value - limit)
250 v = max(short, 0.0) / max(abs(scale), 1e-12)
251 rows.append({"key": key, "label": label, "value": value, "limit": limit, "kind": kind, "unit": unit,
252 "ok": v <= FEASIBLE, "violation": v})
253
254 lockup = float(e.get("lockup_psia") or e["x"].get("lockup_psia") or 0.0)
255 add("dropout", "Bottle above the tanks at burnout", f["copv_end_psia"] - lockup if f["copv_end_psia"] is not None else None,
256 request.dropout_margin_psi, "min", max(request.dropout_margin_psi, 1.0), "psi")
257 if request.stiffness:
258 for side, name in (("oxidiser", "ox"), ("fuel", "fuel")):
259 b = band.get(side)
260 if b:
261 add(f"stiffness_{name}", f"{'LOX' if name == 'ox' else 'Fuel'} injector ΔP/Pc (lowest)",
262 f[f"{name}_stiffness_min"], float(b[0]), "min", float(b[0]), "")
263 if request.of_band_rel is not None and design_of:
264 of = f["of_mean"]
265 rel = abs(of / design_of - 1.0) if of else None
266 add("of", f"O/F within ±{request.of_band_rel * 100:.1f} % of {design_of:.3f}", rel, request.of_band_rel,
267 "max", request.of_band_rel, "")
268 ceiling = f.get("ceiling")
269 if ceiling:
270 add("ceiling", "Apogee under the ceiling", f["apogee_agl_m"], ceiling["limit_agl_m"], "max",
271 ceiling["limit_agl_m"], "m")
272 trip = f.get("tripped")
273 if trip:
274 # A vessel over its trip pressure stopped the burn (result.tripped): not a regulator dropout.
275 rows.append({"key": "vessel_trip", "label": "No vessel trips", "ok": False, "violation": 1.0,
276 "detail": f"{trip.get('label') or trip.get('vessel')} reached {trip.get('p_psia', 0.0):.1f} psia "
277 f"against the {trip.get('mawp_psia', 0.0):.1f} psia it trips at, at "
278 f"t = {trip.get('t', 0.0):.2f} s; the burn stopped there"})
279 elif f.get("impulse_to_depletion_Ns") is None:
280 rows.append({"key": "depletion", "label": "A tank runs dry", "ok": False, "violation": 1.0,
281 "detail": "the burn reached the horizon with propellant in both tanks (a regulator that dropped "
282 "out, or a horizon too short)"})
283 if f.get("failed_steps"):
284 rows.append({"key": "solve", "label": "Every step solves", "ok": False, "violation": 1.0,
285 "detail": f"{f['failed_steps']} step(s) held their last flows"})
286 # The burn ends on depletion (BurnPlan.end_on_depletion) and burns the tanks dry (dry_kg), so the
287 # total impulse is continuous in the design and needs no extrapolation.
288 objective = f.get("apogee_agl_m") if request.objective == "apogee" else f.get("total_impulse_Ns")
289 violation = sum(r.get("violation", 0.0) for r in rows if not r["ok"])
290 return {"objective": objective, "violation": violation if objective is not None else math.inf,
291 "constraints": rows}
292
293
294def _rank(g: Dict[str, Any]) -> Tuple[int, float, float]:
295 feasible = g["violation"] <= FEASIBLE
296 return (0 if feasible else 1, 0.0 if feasible else g["violation"], -(g["objective"] or -math.inf))
297
298
299# ---------------------------------------------------------------------- the search
300
301
302def run_optimize(config: Any, drawing: Any, settings: Any, overrides: List[Any], request: OptimizeRequest, *,
303 progress: Optional[Callable[[str, float], None]] = None,
304 cancelled: Callable[[], bool] = lambda: False,
305 workers: Optional[int] = None) -> Dict[str, Any]:
306 from engine.layerx.pool import Cancelled, WorkerPool, default_workers
307 from engine.layerx.prepare import prepare
308
309 say = progress or (lambda stage, fraction: None)
310 started = time.perf_counter()
311 say("Preparing", 0.01)
312 base_settings = replace(settings, replay=False, flight=False, card_center_psia=None)
313 nominal = prepare(config, None, drawing, base_settings, overrides)
314 if not nominal.ok:
315 raise ValueError("preflight has failing checks; fix them before optimising")
316 variables = resolve_variables(nominal, config, request)
317 free = [v for v in variables if v.enabled]
318 if not free:
319 raise ValueError("no variable is enabled")
320 lock = next(v for v in variables if v.key == "lockup_psia")
321 center_psia = 0.5 * (lock.lo + lock.hi) if lock.enabled else lock.start
322 say("Building the engine card for the lockup range", 0.03)
323 centred = prepare(config, None, drawing, replace(base_settings, card_center_psia=center_psia), overrides)
324 if not centred.ok:
325 raise ValueError("the engine card for the lockup range could not be built")
326 band = nominal.derived.get("stiffness_band") or {}
327 req_cfg = getattr(config, "design_requirements", None)
328 design_of = getattr(req_cfg, "optimal_of_ratio", None) if req_cfg is not None else None
329 copv_id = nominal.derived.get("copv_id")
330
331 def to_x(u: Tuple[float, ...]) -> Dict[str, float]:
332 x = {v.key: v.start for v in variables}
333 for v, ui in zip(free, u):
334 x[v.key] = v.lo + ui * (v.hi - v.lo)
335 return x
336
337 start_u = tuple((v.start - v.lo) / (v.hi - v.lo) for v in free)
338 seen: Dict[Tuple[float, ...], Dict[str, Any]] = {}
339 history: List[Dict[str, Any]] = []
340 budget = max(int(request.max_evaluations), 2 * len(free) + 1)
341 n_workers = default_workers(2 * len(free), workers)
342 pool = WorkerPool(n_workers)
343
344 def key(u: Tuple[float, ...]) -> Tuple[float, ...]:
345 return tuple(round(min(max(ui, 0.0), 1.0), 6) for ui in u)
346
347 def evaluate(batch: List[Tuple[float, ...]], iteration: int) -> None:
348 fresh = []
349 for u in batch:
350 k = key(u)
351 if k not in seen and k not in [key(f) for f in fresh] and len(seen) + len(fresh) < budget:
352 fresh.append(k)
353 if not fresh:
354 return
355 args = [(config, drawing, settings, overrides, variables, to_x(u), copv_id, request.objective,
356 center_psia, len(seen) + i) for i, u in enumerate(fresh)]
357 done = pool.map(_evaluate, args, cancelled=cancelled)
358 for u, e in zip(fresh, done):
359 g = grade(e, request, band, design_of)
360 entry = {**e, **g, "u": list(u), "iteration": iteration}
361 seen[u] = entry
362 history.append(entry)
363
364 try:
365 center = key(start_u)
366 step = FIRST_STEP
367 iteration = 0
368
369 def neighbours(c: Tuple[float, ...], h: float) -> List[Tuple[float, ...]]:
370 out = []
371 for i in range(len(free)):
372 for sgn in (1.0, -1.0):
373 u = list(c)
374 u[i] = min(max(u[i] + sgn * h, 0.0), 1.0)
375 out.append(key(tuple(u)))
376 return out
377
378 say(f"Burning the start and its {2 * len(free)} neighbours", 0.05)
379 evaluate([center] + neighbours(center, step), iteration)
380 while step >= MIN_STEP and len(seen) < budget:
381 if cancelled():
382 raise Cancelled()
383 best = min(seen.values(), key=_rank)
384 if tuple(best["u"]) != center and _rank(best) < _rank(seen[center]):
385 center = tuple(best["u"])
386 else:
387 step /= 2.0
388 if step < MIN_STEP:
389 break
390 iteration += 1
391 say(f"Iteration {iteration}: step {step:.3f} of the range, {len(seen)} burns",
392 0.05 + 0.8 * min(len(seen) / budget, 1.0))
393 before = len(seen)
394 evaluate(neighbours(center, step), iteration)
395 if len(seen) == before and step < MIN_STEP * 2:
396 break
397 finally:
398 pool.close(cancelled())
399
400 start_entry = seen.get(key(start_u))
401 best = min(seen.values(), key=_rank)
402 notes: List[str] = []
403 if best["violation"] > FEASIBLE:
404 notes.append("No candidate met every constraint; the one shown violates the least.")
405 if any(v.enabled and (abs(best["x"][v.key] - v.lo) < 1e-9 or abs(best["x"][v.key] - v.hi) < 1e-9) for v in free):
406 at = [v.label for v in free if abs(best["x"][v.key] - v.lo) < 1e-9 or abs(best["x"][v.key] - v.hi) < 1e-9]
407 notes.append(f"{', '.join(at)} finished on a bound: the answer is the bound's reason, not an optimum.")
408 if any((e.get("figures") or {}).get("card_outside_steps") for e in seen.values()):
409 notes.append("Some candidates ran outside the engine card's sampled box; their engine is extrapolated.")
410 if len(seen) >= budget:
411 notes.append(f"Stopped at the {budget}-burn budget before the step reached 1/{int(1 / MIN_STEP)} of the range.")
412 copv_var = next((v for v in free if v.key == "copv_psig"), None)
413 if copv_var is not None and best["x"].get("copv_psig", copv_var.start) < copv_var.start - 1.0:
414 # A short fill is the one answer the unmodelled cold LOX-tank wall can turn over: it raises
415 # LOX-side gas use 7-60 % (estimate), and the bottle margin is what a short fill spends.
416 notes.append("This fills the bottle short. The LOX tank's cold wall is not modelled and raises its gas use; "
417 "measure bottle use on a firing before relying on the bottle margin this answer keeps.")
418
419 verified = None
420 if request.verify and not cancelled():
421 say("Verifying the winner: its own engine card, the erosion replay"
422 + (", the flight" if request.objective == "apogee" else ""), 0.88)
423 verified = _verify(config, drawing, settings, overrides, variables, best["x"], copv_id, request, band,
424 design_of, start_entry)
425 say("Done", 1.0)
426 clean = lambda e: {k: v for k, v in e.items() if k not in ("u",)} # noqa: E731
427 return {
428 "objective": request.objective,
429 "request": {"dropout_margin_psi": request.dropout_margin_psi, "stiffness": request.stiffness,
430 "of_band_rel": request.of_band_rel, "max_evaluations": budget},
431 "variables": [{"key": v.key, "label": v.label, "unit": v.unit, "lo": v.lo, "hi": v.hi, "start": v.start,
432 "enabled": v.enabled, "basis": v.basis} for v in variables],
433 "card_center_psia": center_psia,
434 "start": clean(start_entry) if start_entry else None,
435 "best": clean(best),
436 "history": [clean(e) for e in history],
437 "verified": verified,
438 "notes": notes,
439 "evaluations": len(seen),
440 "workers": n_workers,
441 "wall_s": time.perf_counter() - started,
442 "summary": {"objective": request.objective, "best_x": best["x"], "best_objective": best["objective"]},
443 "basis": ("Parallel compass search over the enabled variables scaled to their ranges; feasible "
444 "candidates first, then least violation, then objective. Candidates burn on one engine card "
445 "without the erosion replay; the winner is verified with both."),
446 }
447
448
449def _verify(config: Any, drawing: Any, settings: Any, overrides: List[Any], variables: List[Variable],
450 x: Dict[str, float], copv_id: Optional[str], request: OptimizeRequest,
451 band: Dict[str, Optional[List[float]]], design_of: Optional[float],
452 start_entry: Optional[Dict[str, Any]]) -> Dict[str, Any]:
453 """The winner, and the starting point, burned the way a Layer X run burns them."""
454 from engine.layerx.analysis import run_prepared
455 from engine.layerx.prepare import prepare
456
457 out: Dict[str, Any] = {}
458 for label, point in (("best", x), ("start", start_entry["x"] if start_entry else None)):
459 if point is None:
460 continue
461 st, extra = _candidate(settings, variables, point, copv_id, request.objective, 0.0)
462 st = replace(st, replay=True, card_center_psia=None)
463 prep = prepare(config, None, drawing, st, list(overrides) + extra)
464 if not prep.ok:
465 out[label] = {"ok": False, "error": "; ".join(c.label for c in prep.checks if c.status == "fail")}
466 continue
467 res = run_prepared(prep, replay=True, config=config if request.objective == "apogee" else None)
468 figures = _figures(res)
469 dv = (res.get("delivered") or {}).get("summary") or {}
470 if dv:
471 # Graded on the replay's own numbers, the eroding nozzle included, not the card's.
472 figures.update({"total_impulse_Ns": dv.get("total_impulse_Ns"), "mean_thrust_N": dv.get("mean_thrust_N"),
473 "pc_mean_psia": dv.get("pc_mean_psia"), "isp_mean_s": dv.get("isp_mean_s"),
474 "impulse_to_depletion_Ns": dv.get("impulse_to_depletion_Ns")})
475 e = {"ok": True, "preflight": [], "figures": figures, "x": point,
476 "lockup_psia": prep.derived["target_lockup_psia"], "dome_psig": prep.derived["dome_psig"]}
477 out[label] = {**e, **grade(e, request, band, design_of)}
478 return out
479
480
481# ====================================================================== Hardware mode
482#
483# The compass search above is the legacy Optimise (kept for its saved runs and the page that still
484# calls it). Hardware mode replaces it (docs/layerx/AUDIT.md D6, 9.10 4b): discrete parts only, from
485# the catalogues in engine/layerx/catalog.py, each candidate a whole burn on an in-memory copy of
486# the drawing (measurements.Override) or of the design (patch.apply_design_patch), graded with the
487# same limits as Set point and ranked by the person's objective. The answer is a change list
488# (engine/layerx/diff.py), never a write.
489
490HW_OBJECTIVES = ("target_thrust_error", "thrust_flatness", "of_error", "impulse", "bottle_margin")
491
492#: The trim orifice's discharge coefficient comes from Reader-Harris/Gallagher at these taps: the
493#: permanent loss of a plate depends on its C (ISO 5167-2:2003 5.4), and the taps are where C is
494#: defined. Corner taps are fluids' default; the audit's alternatives (flange 0.79, D-D/2 0.61)
495#: bound it.
496TRIM_TAPS = "corner"
497
498
499@dataclass(frozen=True)
501 components: Tuple[Dict[str, Any], ...] = ()
502 """The components that may change: ``{"target": "node:<id>" | "edge:<id>" | "design:<side>.d_jet",
503 "kind"?: "trim_orifice", "rows"?: [catalogue row ids]}``."""
504 objective: str = "of_error"
505 target_thrust_N: Optional[float] = None
506 design_of: Optional[float] = None
507 neighbours: int = 2
508 combine: bool = False
509 max_candidates: int = 8
510 margin_psi: float = 100.0
511 meop_psi: Optional[Dict[str, float]] = None
512 verify: bool = True
513 """Re-solve the set point (lockup only) for the winner, so its thrust is the target's."""
514 trim_C: Optional[float] = None
515 """A measured discharge coefficient for a trim orifice; ``None``: Reader-Harris/Gallagher."""
516
517 @classmethod
518 def from_dict(cls, raw: Dict[str, Any]) -> "HardwareRequest":
519 known = set(cls.__dataclass_fields__)
520 vals = {k: v for k, v in raw.items() if k in known and v is not None}
521 if "components" in vals:
522 vals["components"] = tuple(dict(c) for c in vals["components"])
523 out = cls(**vals)
524 if out.objective not in HW_OBJECTIVES:
525 raise ValueError(f"objective {out.objective!r}; expected one of {', '.join(HW_OBJECTIVES)}")
526 if not out.components:
527 raise ValueError("mark at least one component that may change")
528 for c in out.components:
529 t = str(c.get("target") or "")
530 if not (t.startswith(("node:", "edge:")) or t in ("design:oxidizer.d_jet", "design:fuel.d_jet")):
531 raise ValueError(f"component target {t!r}: node:<id>, edge:<id>, design:oxidizer.d_jet or design:fuel.d_jet")
532 if c.get("kind") not in (None, "part", "trim_orifice"):
533 raise ValueError(f"component kind {c.get('kind')!r}: 'part' or 'trim_orifice'")
534 if c.get("kind") == "trim_orifice" and not t.startswith("edge:"):
535 raise ValueError("a trim orifice goes in a line: target edge:<id>")
536 rows = c.get("rows")
537 if rows is not None and (not isinstance(rows, (list, tuple)) or not all(isinstance(r, str) for r in rows)):
538 # A string here would be matched by substring ("id in rows"): refuse it.
539 raise ValueError(f"component {t!r} rows: a list of catalogue row ids")
540 return out
541
542
543def _payload_with(prep: Any) -> Dict[str, Any]:
544 """The drawing as this burn reads it: the person's restatements written in."""
545 from engine.layerx.measurements import apply_overrides
546
547 payload, _, _ = apply_overrides(prep.drawing.payload, list(prep.measurements or []))
548 return payload
549
550
551def _element(payload: Dict[str, Any], target: str) -> Optional[Dict[str, Any]]:
552 from engine.layerx.measurements import _element as el
553
554 return el(payload, target)
555
556
557def _param(element: Dict[str, Any], name: str) -> Optional[Dict[str, Any]]:
558 p = ((element.get("data") or {}).get("params") or {}).get(name)
559 return p if isinstance(p, dict) else None
560
561
562def _mm(p: Optional[Dict[str, Any]]) -> Optional[float]:
563 if not p or not isinstance(p.get("value"), (int, float)):
564 return None
565 return float(p["value"]) * {"mm": 1.0, "m": 1e3, "in": 25.4}.get(str(p.get("unit") or "mm"), 1.0)
566
567
568def _edge_side(payload: Dict[str, Any], edge: Dict[str, Any], species: Dict[str, str]) -> Optional[str]:
569 nodes = {str(n.get("id")): n for n in payload.get("nodes") or [] if isinstance(n, dict)}
570 for end in ("source", "target"):
571 fluid = str(((nodes.get(str(edge.get(end))) or {}).get("data") or {}).get("fluid") or "").lower()
572 for side, sp in (species or {}).items():
573 if fluid and fluid == str(sp).lower():
574 return side
575 return None
576
577
578def trim_orifice_K(line_bore_m: float, orifice_bore_m: float, C: float) -> float:
579 """Permanent-loss coefficient of a thin orifice plate, referred to the line's velocity head:
580 K = [sqrt(1 - beta^4 (1 - C^2)) / (C beta^2) - 1]^2 (``fluids.flow_meter.discharge_coefficient_to_K``,
581 after ASME MFC-3M-2004 and ISO 5167-2:2003)."""
582 from fluids.flow_meter import discharge_coefficient_to_K
583
584 return float(discharge_coefficient_to_K(D=line_bore_m, Do=orifice_bore_m, C=C))
585
586
587def trim_orifice_C(line_bore_m: float, orifice_bore_m: float, rho: float, mu: float, mdot: float,
588 taps: str = TRIM_TAPS) -> float:
589 """Reader-Harris/Gallagher discharge coefficient (``fluids.flow_meter.C_Reader_Harris_Gallagher``,
590 ISO 5167-2:2003). Outside its range here (D < 50 mm, beta > 0.75): an extrapolation."""
591 from fluids.flow_meter import C_Reader_Harris_Gallagher
592
593 return float(C_Reader_Harris_Gallagher(D=line_bore_m, Do=orifice_bore_m, rho=rho, mu=mu, m=mdot, taps=taps))
594
595
596def _trim_model(line: str, D_m: float, rho: float, mu: float, mdot: float, C_given: Optional[float]) -> Dict[str, Any]:
597 return {
598 "name": "trim orifice: thin plate, permanent loss, as K_minor on the line",
599 "source": ("fluids 1.3 flow_meter.discharge_coefficient_to_K (ASME MFC-3M-2004; ISO 5167-2:2003 permanent "
600 "pressure loss) with C from flow_meter.C_Reader_Harris_Gallagher (ISO 5167-2:2003), "
601 f"{TRIM_TAPS} taps"),
602 "assumptions": [
603 "The plate's permanent loss is charged as extra K_minor on the line it sits in (the drawing has no "
604 "orifice element; feedtwin's OrificeCd/ISO5167 price the tap differential, ~6x the permanent loss here).",
605 "Reader-Harris/Gallagher is used outside ISO 5167-2's range (D >= 50 mm, beta <= 0.75): its C is an "
606 "extrapolation, and the taps it is taken at move the loss: corner against D and D/2 taps is 1.5x at "
607 "beta 0.89 and 3x at beta 0.94 on l_ox1 (fluids 1.3; flange taps are meaningless on a 10.9 mm line, "
608 "AUDIT 5.3). Each candidate carries both (trim.K_by_taps). Size the bore from a cold flow before "
609 "drilling.",
610 "C is evaluated at the baseline burn's mean flow on that side; K does not follow the flow during the burn.",
611 ],
612 "inputs": {
613 "line": {"value": line, "unit": "", "provenance": "the person marked it"},
614 "line_bore_m": {"value": D_m, "unit": "m", "provenance": "drawing (bore of the line)"},
615 "rho": {"value": rho, "unit": "kg/m^3", "provenance": "feedtwin network conditions at the injector inlet"},
616 "mu": {"value": mu, "unit": "Pa s", "provenance": "feedtwin network conditions at the injector inlet"},
617 "mdot": {"value": mdot, "unit": "kg/s", "provenance": "baseline burn: propellant used over burn time"},
618 "C": {"value": C_given, "unit": "", "provenance": "request (measured)" if C_given else
619 f"Reader-Harris/Gallagher, {TRIM_TAPS} taps (extrapolated)"},
620 },
621 }
622
623
624def _option_from_row(target: str, element: Dict[str, Any], row: Dict[str, Any], label: str) -> Optional[Dict[str, Any]]:
625 """A catalogue row as one option: the drawing restatements it makes, and the change records."""
626 from engine.layerx.measurements import Override
627
628 lengths = {"mm": 1.0, "m": 1e3, "in": 25.4}
629 overrides, changes = [], []
630 for name, p in (row.get("params") or {}).items():
631 before = _param(element, name)
632 b_val = float(before["value"]) if before and isinstance(before.get("value"), (int, float)) else None
633 b_unit, a_unit = str((before or {}).get("unit") or ""), str(p["unit"])
634 if b_val is not None and b_unit != a_unit and b_unit in lengths and a_unit in lengths:
635 b_val, b_unit = b_val * lengths[b_unit] / lengths[a_unit], a_unit # said in the row's unit
636 if b_val is not None and b_unit == a_unit and abs(b_val - float(p["value"])) < 1e-9 * max(1.0, abs(b_val)):
637 continue
638 # A row's numbers need not share one provenance (a bottle's volume measured, its mass
639 # estimated): a parameter's own ``source`` wins over the row's.
640 src = str(p.get("source") or row["source"])
641 overrides.append(Override(target=target, parameter=name, value=float(p["value"]), unit=str(p["unit"]),
642 source=f"catalog {row['id']}: {row['provenance']}"[:300], provenance=src))
643 changes.append({"field": name, "before": b_val, "before_unit": b_unit or None,
644 "before_provenance": (f"{before.get('source')}: {before.get('reference') or ''}".strip(": ")
645 if before else "not on the drawing"),
646 "after": float(p["value"]), "unit": str(p["unit"]), "source": src})
647 if not overrides:
648 return None
649 return {"target": target, "label": label, "row": {k: row.get(k) for k in ("id", "label", "source", "provenance", "origin")},
650 "overrides": overrides, "design_patch": None, "changes": changes, "cad_impact": "new part",
651 "summary": f"{label}: {row['label']}"}
652
653
654def plan_candidates(prep: Any, config: Any, request: HardwareRequest, catalogs: Dict[str, Any],
655 baseline: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
656 """Every component's options, and the candidates (one option each, or combinations).
657
658 Raises ``ValueError`` for a component the drawing or design does not have, or one no catalogue
659 row fits. A trim orifice is sized from the ``baseline`` burn's flow; without one it is listed as
660 pending."""
661 from itertools import product
662
663 from engine.layerx import catalog as cat
664 from engine.layerx.reconcile import _passage_length
665
666 payload = _payload_with(prep)
667 species = {side: str(getattr(prep, "derived", {}).get("species", {}).get(side, "")) for side in ("oxidiser", "fuel")}
668 per_component: List[Dict[str, Any]] = []
669 needs_pid: List[Dict[str, Any]] = []
670 notes: List[str] = []
671 n = max(1, int(request.neighbours))
672 for comp in request.components:
673 target, kind, only = str(comp["target"]), comp.get("kind") or "part", comp.get("rows")
674 options: List[Dict[str, Any]] = []
675 if target.startswith("design:"):
676 side = target.split(":", 1)[1].split(".")[0]
677 d_mm = float(getattr(config.injector.geometry, side).d_jet) * 1e3
678 rows = cat.rows_for(catalogs, "drills", "hole")
679 if only:
680 rows = [r for r in rows if r["id"] in only]
681 L = _passage_length(config, side)
682 word = "LOX" if side == "oxidizer" else "Fuel"
683 for r in cat.neighbours(rows, "d", d_mm, n):
684 d_new = cat.param_si(r, "d")
685 patch: Dict[str, float] = {"d_jet": d_new * 1e-3}
686 ch = [{"field": "d_jet", "before": round(d_mm, 5), "after": round(d_new, 5), "unit": "mm",
687 "before_provenance": "the design"}]
688 if L is not None:
689 patch["orifice_l_over_d"] = L / (d_new * 1e-3)
690 ch.append({"field": "orifice_l_over_d", "before": round(L / (d_mm * 1e-3), 4),
691 "after": round(patch["orifice_l_over_d"], 4), "unit": "",
692 "before_provenance": "the design (passage length kept)"})
693 options.append({"target": target, "label": f"{word} injector holes", "row": {k: r.get(k) for k in (
694 "id", "label", "source", "provenance", "drill")}, "overrides": [], "design_patch": {side: patch},
695 "changes": ch, "cad_impact": "re-drill" if d_new > d_mm else "new plate",
696 "summary": f"{word} holes {r['drill']} ({d_new:.4f} mm, was {d_mm:.4f})"})
697 else:
698 element = _element(payload, target)
699 if element is None:
700 raise ValueError(f"the drawing has no {target}")
701 ctype = str((element.get("data") or {}).get("componentType") or "")
702 label = str((element.get("data") or {}).get("label") or target.split(":", 1)[1])
703 segs = (element.get("data") or {}).get("segments")
704 if target.startswith("edge:") and isinstance(segs, list) and segs:
705 # feedtwin.pid.network drops a segmented line's own bore and K_minor (the segments
706 # carry the geometry), so a tube or trim restated on the line would burn as drawn.
707 raise ValueError(f"{target} is itemised into {len(segs)} segment(s): its bore and K_minor are "
708 "superseded by the segments, so a tube or trim change here would not be burned. "
709 "Change the segment in pid-designer.")
710 if kind == "trim_orifice":
711 D_mm = _mm(_param(element, "bore"))
712 side = _edge_side(payload, element, species)
713 if D_mm is None or side is None:
714 raise ValueError(f"{target}: a trim orifice needs a liquid line with a bore")
715 needs_pid.append({"target": target, "what": f"an orifice plate in {label}",
716 "why": "the drawing has no orifice element; this run charges the plate's permanent "
717 "loss as K_minor on the line. Add an orifice symbol in pid-designer to keep it."})
718 if baseline is None:
719 options.append({"target": target, "label": f"Trim orifice in {label}", "pending": True,
720 "summary": "sized after the baseline burn"})
721 else:
722 options += _trim_options(prep, payload, element, target, label, side, D_mm, baseline, request,
723 catalogs, n, notes)
724 elif target.startswith("edge:"):
725 rows = cat.rows_for(catalogs, "tubes", "line")
726 if only:
727 rows = [r for r in rows if r["id"] in only]
728 bore = _mm(_param(element, "bore"))
729 if bore is None:
730 raise ValueError(f"{target} states no bore")
731 for r in cat.neighbours(rows, "bore", bore, n):
732 o = _option_from_row(target, element, r, label)
733 if o:
734 o["cad_impact"] = "new part"
735 options.append(o)
736 notes.append(f"{label}: a different tube changes the line only; if it is the line into the injector, "
737 "the design's feed_system d_exit still prices the manifold dump (preflight warns).")
738 else:
739 kind_rows = "bottles" if ctype == "KBOTTLE" else "valves"
740 key = "volume" if ctype == "KBOTTLE" else "Cv"
741 rows = cat.rows_for(catalogs, kind_rows, ctype)
742 if only:
743 rows = [r for r in rows if r["id"] in only]
744 cur = _param(element, key)
745 if cur is None:
746 raise ValueError(f"{target} ({ctype}) states no {key}")
747 for r in cat.neighbours(rows, key, float(cur["value"]), n):
748 o = _option_from_row(target, element, r, label)
749 if o:
750 options.append(o)
751 if ctype == "KBOTTLE":
752 notes.append("A different bottle changes the drawing's bottle; the flight's mass comes from the "
753 "config's press_tank, which this does not change.")
754 if not options:
755 raise ValueError(f"{target}: no catalogue row differs from what is drawn. Add rows to the catalogue "
756 f"(<userdata>/layerx/catalog/) with their data sheets.")
757 per_component.append({"target": target, "kind": kind, "options": options})
758 ready = [[o for o in c["options"] if not o.get("pending")] for c in per_component]
759 if request.combine and len(ready) > 1:
760 combos = [list(t) for t in product(*[r for r in ready if r])]
761 else:
762 combos = [[o] for r in ready for o in r]
763 capped = combos[: max(1, int(request.max_candidates))]
764 if len(combos) > len(capped):
765 notes.append(f"{len(combos)} candidates; the {len(capped)} nearest the drawn parts are burned (max_candidates).")
766 return {"components": per_component, "candidates": capped, "needs_pid_designer": needs_pid, "notes": notes,
767 "pending": any(o.get("pending") for c in per_component for o in c["options"])}
768
769
770def _trim_options(prep: Any, payload: Dict[str, Any], element: Dict[str, Any], target: str, label: str, side: str,
771 D_mm: float, baseline: Dict[str, Any], request: HardwareRequest, catalogs: Dict[str, Any],
772 n: int, notes: List[str]) -> List[Dict[str, Any]]:
773 """Trim bores around the one that would take O/F to the design's, priced as K_minor."""
774 from engine.layerx import catalog as cat
775 from engine.layerx.measurements import Override
776
777 f = baseline.get("figures") or {}
778 burn_s = f.get("burn_time_s") or 0.0
779 used = f.get("ox_used_kg" if side == "oxidiser" else "fuel_used_kg")
780 if not burn_s or not used:
781 raise ValueError(f"{target}: the baseline burn reports no flow on the {side} side to size a trim against")
782 mdot = float(used) / float(burn_s)
783 D = D_mm * 1e-3
784 inlet = (getattr(prep, "inlet_nodes", None) or {}).get(side) or prep.derived.get("inlet_nodes", {}).get(side)
785 cond = prep.model.built.network.conditions(inlet, float(prep.derived["target_lockup_psia"]) * PSI)
786 rho, mu = float(cond.rho), float(cond.mu)
787 v = mdot / (rho * math.pi * D * D / 4.0)
788 q = 0.5 * rho * v * v
789 # The loss that would take O/F to the design's at a fixed tank pressure and chamber pressure: the
790 # side's flow scales as sqrt(drop), so the drop must fall by (target/current)^2 (fuel: inverted).
791 of, design_of = f.get("of_mean"), baseline.get("design_of")
792 p_drop = max(float(f.get("lockup_psia") or prep.derived["target_lockup_psia"]) - float(f.get("pc_mean_psia") or 0.0), 1.0)
793 ratio = (design_of / of) if of and design_of else 0.99
794 shrink = ratio ** 2 if side == "oxidiser" else (1.0 / ratio) ** 2
795 if shrink >= 1.0:
796 notes.append(f"A trim in the {side} line can only lower its flow: O/F {of:.4f} against {design_of} needs the "
797 "other side trimmed. Offered anyway, sized for 1 % less flow.")
798 shrink = 0.98
799 dp_needed = (1.0 - shrink) * p_drop * PSI
800 K_needed = dp_needed / q
801
802 def K_of(Do: float) -> float:
803 C = request.trim_C or trim_orifice_C(D, Do, rho, mu, mdot)
804 return trim_orifice_K(D, Do, C)
805
806 lo_b, hi_b = 0.30 * D, 0.97 * D
807 if K_of(hi_b) > K_needed:
808 Do_star = hi_b
809 else:
810 for _ in range(60):
811 mid = 0.5 * (lo_b + hi_b)
812 lo_b, hi_b = (mid, hi_b) if K_of(mid) > K_needed else (lo_b, mid)
813 Do_star = 0.5 * (lo_b + hi_b)
814 rows = [r for r in cat.rows_for(catalogs, "drills", "orifice") if (cat.param_si(r, "d") or 0) < 0.97 * D_mm]
815 k_old = _param(element, "K_minor")
816 K0 = float(k_old["value"]) if k_old else 0.0
817 out = []
818 for r in cat.neighbours(rows, "d", Do_star * 1e3, n):
819 Do = cat.param_si(r, "d") * 1e-3
820 C = request.trim_C or trim_orifice_C(D, Do, rho, mu, mdot)
821 K = trim_orifice_K(D, Do, C)
822 # The extrapolated C's spread by tap location, as numbers (fluids' 'D' is D and D/2 taps).
823 K_by_taps = None if request.trim_C else {
824 t: trim_orifice_K(D, Do, trim_orifice_C(D, Do, rho, mu, mdot, taps=t)) for t in ("corner", "D")}
825 prov = (f"trim orifice {r['drill']} ({Do * 1e3:.3f} mm, beta {Do / D:.3f}) in {label}: K {K:.4f} from fluids "
826 f"(C {C:.3f}, {('given' if request.trim_C else TRIM_TAPS + ' taps, extrapolated')}) at {mdot:.3f} kg/s")
827 out.append({
828 "target": target, "label": f"Trim orifice in {label}", "row": {k: r.get(k) for k in ("id", "label", "source", "provenance", "drill")},
829 "overrides": [Override(target=target, parameter="K_minor", value=K0 + K, unit="-", source=prov[:300],
830 provenance="estimated")],
831 "design_patch": None,
832 "changes": [{"field": "K_minor", "before": K0, "after": K0 + K, "unit": "-",
833 "before_provenance": (f"{k_old.get('source')}: {k_old.get('reference') or ''}" if k_old else "none")}],
834 "cad_impact": "new part",
835 "trim": {"bore_mm": Do * 1e3, "beta": Do / D, "C": C, "K": K, "dp_psi_at_baseline": K * q / PSI,
836 "sized_for_psi": dp_needed / PSI, "K_by_taps": K_by_taps},
837 "model": _trim_model(target, D, rho, mu, mdot, request.trim_C),
838 "summary": f"trim {r['drill']} in {label} (K +{K:.3f}, ~{K * q / PSI:.1f} psi)",
839 })
840 return out
841
842
843def _objective(figs: Optional[Dict[str, Any]], request: HardwareRequest, target_F: Optional[float],
844 design_of: Optional[float]) -> Optional[float]:
845 """Smaller is better."""
846 if not figs:
847 return None
848 o = request.objective
849 v: Any
850 if o == "target_thrust_error":
851 v = abs(figs["mean_thrust_N"] - target_F) if target_F and figs.get("mean_thrust_N") is not None else None
852 elif o == "thrust_flatness":
853 v = figs.get("thrust_spread_pct")
854 elif o == "of_error":
855 v = abs(figs["of_mean"] / design_of - 1.0) if design_of and figs.get("of_mean") else None
856 elif o == "impulse":
857 v = -figs["total_impulse_Ns"] if figs.get("total_impulse_Ns") is not None else None
858 else:
859 v = -figs["copv_spare_psi"] if figs.get("copv_spare_psi") is not None else None
860 return float(v) if isinstance(v, (int, float)) and math.isfinite(v) else None
861
862
863def _hw_rank(c: Dict[str, Any]) -> Tuple[int, int, float]:
864 from engine.layerx.setpoint import usable
865
866 if not usable(c.get("burn")):
867 return (2, 0, math.inf)
868 bad = sum(1 for r in c["burn"].get("limits") or [] if r.get("grade") == "bad")
869 obj = c.get("objective_value")
870 return (0 if bad == 0 else 1, bad, obj if obj is not None else math.inf)
871
872
873def _hw_changes(options: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
874 from engine.layerx import diff
875
876 out = []
877 for o in options:
878 for ch in o["changes"]:
879 row = o.get("row") or {}
880 is_design = o["target"].startswith("design:")
881 own = ch.get("source") or row.get("source")
882 src = "estimated" if o.get("trim") else (own if own in diff.SOURCES else "catalog")
883 out.append(diff.change(
884 component=o["label"], field=ch["field"], before=ch.get("before"), after=ch["after"], unit=ch["unit"],
885 provenance=(f"catalog {row.get('id')}: {row.get('provenance')}" if not o.get("trim") else
886 f"{o['overrides'][0].source}"),
887 cad_impact=o["cad_impact"] if ch["field"] != "orifice_l_over_d" else "none",
888 target=(f"design:injector.geometry.{o['target'].split(':', 1)[1]}" if is_design and ch["field"] == "d_jet"
889 else f"design:discharge.{o['target'].split(':', 1)[1].split('.')[0]}.orifice_l_over_d"
890 if is_design else o["target"]),
891 source=src, before_provenance=ch.get("before_provenance"),
892 catalog=None if is_design or o.get("trim") else row,
893 drill=({"drill": row.get("drill"), "d_mm": ch["after"]} if is_design and ch["field"] == "d_jet" else
894 ({"drill": row.get("drill"), **(o.get("trim") or {})} if o.get("trim") else None)),
895 note=("trim orifice priced as K_minor: add an orifice symbol in pid-designer" if o.get("trim") else None)))
896 return out
897
898
899def _design_updates(options: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
900 geo: Dict[str, Any] = {}
901 dis: Dict[str, Any] = {}
902 for o in options:
903 for side, fields in (o.get("design_patch") or {}).items():
904 if "d_jet" in fields:
905 geo.setdefault(side, {})["d_jet"] = float(fields["d_jet"])
906 if "orifice_l_over_d" in fields:
907 dis.setdefault(side, {})["orifice_l_over_d"] = round(float(fields["orifice_l_over_d"]), 4)
908 if not geo and not dis:
909 return None
910 out: Dict[str, Any] = {}
911 if geo:
912 out["injector"] = {"geometry": geo}
913 if dis:
914 out["discharge"] = dis
915 return out
916
917
918def _merged_patch(options: List[Dict[str, Any]]) -> Optional[Dict[str, Dict[str, float]]]:
919 patch: Dict[str, Dict[str, float]] = {}
920 for o in options:
921 for side, fields in (o.get("design_patch") or {}).items():
922 patch.setdefault(side, {}).update(fields)
923 return patch or None
924
925
926def run_hardware(config: Any, drawing: Any, settings: Any, overrides: List[Any], request: HardwareRequest, *,
927 catalogs: Optional[Dict[str, Any]] = None, progress: Optional[Callable[[str, float], None]] = None,
928 cancelled: Callable[[], bool] = lambda: False, workers: Optional[int] = None,
929 evaluate: Optional[Callable[[List[Dict[str, Any]]], List[Dict[str, Any]]]] = None,
930 prep: Any = None) -> Dict[str, Any]:
931 """Catalogue parts for the marked components, each a whole burn, ranked by ``request.objective``.
932
933 ``evaluate(args_list)`` replaces the burns and ``prep`` the preflight (tests)."""
934 from engine.layerx import diff
935 from engine.layerx.catalog import load_catalogs
936 from engine.layerx.patch import apply_design_patch
937 from engine.layerx.pool import Cancelled, WorkerPool, default_workers
938 from engine.layerx.setpoint import (SetpointRequest, burn_point, design_of_ratio, solve, target_thrust, usable)
939
940 say = progress or (lambda stage, fraction: None)
941 started = time.perf_counter()
942 catalogs = catalogs or load_catalogs(None)
943 if prep is None:
944 from engine.layerx.prepare import prepare
945
946 prep = prepare(config, None, drawing, replace(settings, card_center_psia=None), overrides)
947 if not prep.ok:
948 raise ValueError("Preflight failed: " + "; ".join(c.label for c in prep.checks if c.status == "fail"))
949 design_of, of_source = design_of_ratio(config, request)
950 try:
951 target_F, target_source = target_thrust(config, SetpointRequest(target_thrust_N=request.target_thrust_N))
952 except ValueError:
953 target_F, target_source = None, "none"
954 if request.objective == "target_thrust_error" and not target_F:
955 raise ValueError("objective target_thrust_error needs a target thrust")
956 if request.objective == "of_error" and not design_of:
957 raise ValueError("objective of_error needs a design O/F: the design states no optimal_of_ratio; enter one")
958 grading = {"margin_psi": request.margin_psi, "meop_psi": dict(request.meop_psi or {}), "design_of": design_of}
959 plan = plan_candidates(prep, config, request, catalogs)
960
961 pool = None
962 counter = [0]
963 if evaluate is None:
964 n_workers = default_workers(1 + len(plan["candidates"]), workers)
965 pool = WorkerPool(n_workers)
966
967 def evaluate(args_list: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
968 return pool.map(burn_point, args_list, cancelled=cancelled)
969 else:
970 n_workers = 1
971
972 def args_for(options: List[Dict[str, Any]], **extra: Any) -> Dict[str, Any]:
973 counter[0] += 1
974 patch = _merged_patch(options)
975 return {"config": apply_design_patch(config, patch) if patch else config, "drawing": drawing,
976 "settings": settings, "overrides": list(overrides),
977 "extra_overrides": [ov for o in options for ov in o.get("overrides") or []],
978 "replay": bool(getattr(settings, "replay", True)), "grading": grading, "index": counter[0], **extra}
979
980 try:
981 say(f"Burning the drawn hardware and {len(plan['candidates'])} candidate(s)", 0.05)
982 batch = [args_for([], tag="baseline")] + [args_for(c, tag=f"candidate {i + 1}") for i, c in enumerate(plan["candidates"])]
983 burns = evaluate(batch)
984 n_burns = len(batch)
985 baseline = burns[0]
986 if not usable(baseline):
987 raise ValueError("the drawn hardware's own burn could not be used: "
988 + (baseline.get("error") or "; ".join(baseline.get("preflight") or []) or "tripped"))
989 candidates = [{"options": c, "burn": b} for c, b in zip(plan["candidates"], burns[1:])]
990 if plan["pending"]:
991 if cancelled():
992 raise Cancelled()
993 say("Sizing the trim orifice from the baseline burn", 0.45)
994 sized = plan_candidates(prep, config, request, catalogs, baseline={**baseline, "design_of": design_of})
995 trims = [c for c in sized["candidates"] if any(o.get("trim") for o in c)]
996 plan["components"], plan["notes"] = sized["components"], plan["notes"] + [
997 n for n in sized["notes"] if n not in plan["notes"]]
998 room = max(0, int(request.max_candidates) - len(candidates))
999 trims = trims[:room] if room else []
1000 if trims:
1001 more = evaluate([args_for(c, tag=f"trim {i + 1}") for i, c in enumerate(trims)])
1002 n_burns += len(trims)
1003 candidates += [{"options": c, "burn": b} for c, b in zip(trims, more)]
1004 for c in candidates:
1005 c["objective_value"] = _objective((c["burn"] or {}).get("figures"), request, target_F, design_of)
1006 baseline_value = _objective(baseline.get("figures"), request, target_F, design_of)
1007 ranked = sorted(candidates, key=_hw_rank)
1008 best = ranked[0] if ranked else None
1009 notes = list(plan["notes"])
1010 def n_bad(b: Optional[Dict[str, Any]]) -> int:
1011 return sum(1 for r in (b or {}).get("limits") or [] if r.get("grade") == "bad")
1012
1013 # A better objective bought by breaking a limit the drawn hardware keeps is not an improvement.
1014 improves = best is not None and usable(best["burn"]) and best["objective_value"] is not None \
1015 and baseline_value is not None and best["objective_value"] < baseline_value \
1016 and n_bad(best["burn"]) <= n_bad(baseline)
1017 if best is not None and not improves:
1018 notes.append("No candidate beats the drawn hardware on this objective within the limits the drawn "
1019 "hardware keeps; the best one is shown.")
1020 setpoint = None
1021 final = best["burn"] if best else None
1022 if best is not None and request.verify and usable(best["burn"]) and request.objective != "target_thrust_error" \
1023 and target_F and not cancelled():
1024 say("Re-solving the set point for the best candidate", 0.6)
1025 base_args = args_for(best["options"])
1026
1027 def evaluate_sp(points: List[Tuple[float, Optional[float], bool]]) -> List[Dict[str, Any]]:
1028 return evaluate([{**base_args, "lockup_psia": L, "fill_psig": F, "replay": rp,
1029 "index": base_args["index"] + k + 1} for k, (L, F, rp) in enumerate(points)])
1030
1031 # The candidate's own burn is the first point; the solve burns a second and steps.
1032 sp_req = SetpointRequest(target_thrust_N=target_F, solve_fill=False, max_burns=4,
1033 replay=bool(getattr(settings, "replay", True)), margin_psi=request.margin_psi)
1034 L0 = float(best["burn"]["lockup_psia"])
1035 F0 = float(best["burn"]["fill_psig"])
1036 out = solve(evaluate_sp, start_lockup=L0, start_fill=F0, max_fill=F0, target=target_F, request=sp_req,
1037 bounds=(0.5 * L0, 1.5 * L0),
1038 first_guess=L0 * target_F / float(best["burn"]["figures"]["mean_thrust_N"]),
1039 say=lambda s, f: say(s, 0.6 + 0.35 * f), cancelled=cancelled, seed=[best["burn"]])
1040 sp_burns = sum(1 for h in out["history"] if h.get("stage") != "seed")
1041 n_burns += sp_burns
1042 setpoint = {"converged": out["converged"], "burns": sp_burns, "notes": out["notes"],
1043 "lockup_psia": out["final"].get("lockup_psia"), "dome_psig": out["final"].get("dome_psig"),
1044 "fill_psig": out["final"].get("fill_psig")}
1045 if usable(out["final"]):
1046 final = out["final"]
1047 finally:
1048 if pool is not None:
1049 pool.close(cancelled())
1050
1051 changes = _hw_changes(best["options"]) if best else []
1052 if setpoint and final is not None:
1053 for tgt, comp, field_, unit, b, a in (
1054 ("op:lockup_psia", "Tank lockup", "lockup_psia", "psia", baseline.get("lockup_psia"), final.get("lockup_psia")),
1055 ("op:dome_psig", "Dome loader (the dial)", "dome_psig", "psig", baseline.get("dome_psig"), final.get("dome_psig"))):
1056 if a is not None and b is not None and abs(float(a) - float(b)) >= 0.05:
1057 changes.append(diff.change(component=comp, field=field_, before=round(float(b), 2), after=round(float(a), 2),
1058 unit=unit, provenance="solved: Layer X set point re-solved for this hardware",
1059 cad_impact="setting only", target=tgt, source="solved"))
1060 before_fig = {**baseline["figures"], "dome_psig": baseline.get("dome_psig"), "lockup_psia": baseline.get("lockup_psia")}
1061 after_fig = ({**final["figures"], "dome_psig": final.get("dome_psig"), "lockup_psia": final.get("lockup_psia")}
1062 if usable(final) else None)
1063 sha = str((baseline.get("derived") or {}).get("config_sha256") or getattr(prep, "config_sha256", "") or "")
1064 change_list = diff.build("hardware", changes, before=before_fig, after=after_fig,
1065 limits=(final or {}).get("limits"), needs_pid_designer=plan["needs_pid_designer"],
1066 basis={"config_sha256": sha, "drawing": (baseline.get("derived") or {}).get("drawing"),
1067 "objective": request.objective, "replay": baseline.get("replay")})
1068 updates = _design_updates(best["options"]) if best else None
1069 try:
1070 pid_export = diff.pid_designer_export(prep.drawing.payload, change_list,
1071 name=f"{prep.drawing.name} (Layer X hardware)", source=prep.drawing.id)
1072 except ValueError as exc:
1073 pid_export = {"error": str(exc)}
1074 change_list["exports"] = {"settings_patch": diff.settings_patch(change_list),
1075 "design_write": diff.design_write(updates, sha) if updates and len(sha) == 64 else None,
1076 "pid_designer": pid_export}
1077 say("Done", 1.0)
1078
1079 def view(c: Dict[str, Any]) -> Dict[str, Any]:
1080 b = c.get("burn") or {}
1081 return {"summary": "; ".join(o["summary"] for o in c["options"]), "targets": [o["target"] for o in c["options"]],
1082 "ok": usable(b), "error": b.get("error") or ("; ".join(b.get("preflight") or []) or None),
1083 "figures": b.get("figures"), "objective_value": c.get("objective_value"),
1084 "limits_bad": [r.get("label") for r in b.get("limits") or [] if r.get("grade") == "bad"],
1085 "limits_warn": [r.get("label") for r in b.get("limits") or [] if r.get("grade") == "warn"],
1086 "rank": ranked.index(c) + 1 if c in ranked else None,
1087 "trim": next((o.get("trim") for o in c["options"] if o.get("trim")), None)}
1088
1089 models = [o["model"] for c in candidates for o in c["options"] if o.get("model")][:1]
1090 return {
1091 "mode": "hardware",
1092 "objective": request.objective,
1093 "target": {"mean_thrust_N": target_F, "source": target_source, "design_of": design_of, "design_of_source": of_source},
1094 "components": [{"target": c["target"], "kind": c["kind"],
1095 "options": [o.get("summary") for o in c["options"]]} for c in plan["components"]],
1096 "baseline": {"figures": baseline["figures"], "objective_value": baseline_value,
1097 "limits_bad": [r.get("label") for r in baseline.get("limits") or [] if r.get("grade") == "bad"]},
1098 "candidates": [view(c) for c in candidates],
1099 "winner": view(best) if best else None,
1100 "improves": improves,
1101 "setpoint": setpoint,
1102 "final": {"figures": (final or {}).get("figures"), "limits": (final or {}).get("limits"),
1103 "limits_basis": (final or {}).get("limits_basis")},
1104 "change_list": change_list,
1105 "needs_pid_designer": plan["needs_pid_designer"],
1106 "catalog_problems": list(catalogs.get("problems") or []),
1107 "model": {
1108 "name": "Layer X hardware mode: discrete catalogue parts, one whole burn each, ranked",
1109 "source": "catalogues: engine/layerx/catalog.py (drills: ASME B94.11M via reconcile.NUMBER_DRILLS_IN); "
1110 "ranking: feasible first, then fewest broken limits, then the objective (after Deb's feasibility "
1111 "rules, as the legacy search, with a count of broken limits in place of the violation sum)",
1112 "assumptions": [
1113 "Candidates burn at the rail's settings; the winner's set point is re-solved (lockup only) so its thrust "
1114 "is the target's" if request.verify else "Candidates burn at the rail's settings; no set point re-solve.",
1115 "A drawing change is a restatement on an in-memory copy (measurements.Override); the drawing is not written.",
1116 "A trim orifice is charged as K_minor on its line (no orifice element exists on the drawing).",
1117 ],
1118 "inputs": {
1119 "objective": {"value": request.objective, "unit": "", "provenance": "request"},
1120 "target_mean_thrust_N": {"value": target_F, "unit": "N", "provenance": target_source},
1121 "design_of": {"value": design_of, "unit": "", "provenance": of_source},
1122 "neighbours": {"value": request.neighbours, "unit": "", "provenance": "request"},
1123 "margin_psi": {"value": request.margin_psi, "unit": "psi", "provenance": "request (assumed 100 psi default)"},
1124 },
1125 },
1126 "trim_model": models[0] if models else None,
1127 "notes": notes,
1128 "burns": n_burns,
1129 "workers": n_workers,
1130 "wall_s": time.perf_counter() - started,
1131 "summary": {"objective": request.objective, "winner": view(best)["summary"] if best else None,
1132 "improves": improves, "mean_thrust_N": ((final or {}).get("figures") or {}).get("mean_thrust_N"),
1133 "of_mean": ((final or {}).get("figures") or {}).get("of_mean")},
1134 "basis": ("Each candidate is a whole Layer X burn of an in-memory copy of the drawing or design with the rail's "
1135 "settings, graded with the shared limits; the change list's effects are baseline -> the winner's "
1136 "final burn."),
1137 }
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
"HardwareRequest" from_dict(cls, Dict[str, Any] raw)
Definition optimize.py:518
"OptimizeRequest" from_dict(cls, Dict[str, Any] raw)
Definition optimize.py:89
static std::vector< string_t > split(const string_t &reference_string)
split the string input to reference tokens
Definition json.hpp:14471
Optional[float] _mm(Optional[Dict[str, Any]] p)
Definition optimize.py:562
Optional[Dict[str, Dict[str, float]]] _merged_patch(List[Dict[str, Any]] options)
Definition optimize.py:918
Dict[str, Any] _trim_model(str line, float D_m, float rho, float mu, float mdot, Optional[float] C_given)
Definition optimize.py:596
float trim_orifice_K(float line_bore_m, float orifice_bore_m, float C)
Definition optimize.py:578
Tuple[Any, List[Any]] _candidate(Any settings, List[Variable] variables, Dict[str, float] x, Optional[str] copv_id, str objective, float center_psia)
Definition optimize.py:159
Tuple[int, int, float] _hw_rank(Dict[str, Any] c)
Definition optimize.py:863
Optional[Dict[str, Any]] _option_from_row(str target, Dict[str, Any] element, Dict[str, Any] row, str label)
Definition optimize.py:624
Optional[Dict[str, Any]] _design_updates(List[Dict[str, Any]] options)
Definition optimize.py:899
List[Variable] resolve_variables(Any prep, Any config, OptimizeRequest request)
Definition optimize.py:142
Dict[str, Any] _verify(Any config, Any drawing, Any settings, List[Any] overrides, List[Variable] variables, Dict[str, float] x, Optional[str] copv_id, OptimizeRequest request, Dict[str, Optional[List[float]]] band, Optional[float] design_of, Optional[Dict[str, Any]] start_entry)
Definition optimize.py:452
Dict[str, Any] run_hardware(Any config, Any drawing, Any settings, List[Any] overrides, HardwareRequest request, *, Optional[Dict[str, Any]] catalogs=None, Optional[Callable[[str, float], None]] progress=None, Callable[[], bool] cancelled=lambda:False, Optional[int] workers=None, Optional[Callable[[List[Dict[str, Any]]], List[Dict[str, Any]]]] evaluate=None, Any prep=None)
Definition optimize.py:930
Dict[str, Any] plan_candidates(Any prep, Any config, HardwareRequest request, Dict[str, Any] catalogs, Optional[Dict[str, Any]] baseline=None)
Definition optimize.py:655
float trim_orifice_C(float line_bore_m, float orifice_bore_m, float rho, float mu, float mdot, str taps=TRIM_TAPS)
Definition optimize.py:588
Dict[str, Any] run_optimize(Any config, Any drawing, Any settings, List[Any] overrides, OptimizeRequest request, *, Optional[Callable[[str, float], None]] progress=None, Callable[[], bool] cancelled=lambda:False, Optional[int] workers=None)
Definition optimize.py:305
Dict[str, Any] _figures(Dict[str, Any] res)
Definition optimize.py:174
List[Variable] default_variables(Any prep, Any config)
Definition optimize.py:100
Optional[str] _edge_side(Dict[str, Any] payload, Dict[str, Any] edge, Dict[str, str] species)
Definition optimize.py:568
List[Dict[str, Any]] _hw_changes(List[Dict[str, Any]] options)
Definition optimize.py:873
List[Dict[str, Any]] _trim_options(Any prep, Dict[str, Any] payload, Dict[str, Any] element, str target, str label, str side, float D_mm, Dict[str, Any] baseline, HardwareRequest request, Dict[str, Any] catalogs, int n, List[str] notes)
Definition optimize.py:772
Dict[str, Any] grade(Dict[str, Any] e, OptimizeRequest request, Dict[str, Optional[List[float]]] band, Optional[float] design_of)
Definition optimize.py:233
Dict[str, Any] _evaluate(Tuple[Any,...] args)
Definition optimize.py:206
Optional[float] _objective(Optional[Dict[str, Any]] figs, HardwareRequest request, Optional[float] target_F, Optional[float] design_of)
Definition optimize.py:844
Optional[Dict[str, Any]] _element(Dict[str, Any] payload, str target)
Definition optimize.py:551
Optional[Dict[str, Any]] _param(Dict[str, Any] element, str name)
Definition optimize.py:557
Dict[str, Any] _payload_with(Any prep)
Definition optimize.py:543
Tuple[int, float, float] _rank(Dict[str, Any] g)
Definition optimize.py:294