1"""Phase 4: the feed hardware that gets the most out of a fixed propellant load.
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
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``).
12The pressurant gas is the drawing's, so to compare GN2 against helium, run each drawing.
14The objective is **total impulse** (the tanks burnt dry) or, with the flight on, **apogee**, both at
16Every constraint is checked over the whole burn:
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.
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
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
40from __future__
import annotations
44from dataclasses
import dataclass, field, replace
45from typing
import Any, Callable, Dict, List, Optional, Tuple
47PSI = 6894.757293168361
58OBJECTIVES = (
"impulse",
"apogee")
61@dataclass(frozen=True)
71 """Where the bounds came from."""
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
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)}")
101 """The decision variables and their bounds, each bound with its reason."""
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
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():
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])
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.")]
123 copv = float(d[
"copv_psig"])
124 drawn = d.get(
"copv_drawn_psig")
or copv
125 mawp = d.get(
"copv_mawp_psi")
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 "") +
"."))
134 vol = d.get(
"copv_volume_L")
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)."))
145 raw = request.variables.get(v.key)
or {}
146 lo = float(raw.get(
"lo", v.lo))
147 hi = float(raw.get(
"hi", v.hi))
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))
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]]:
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"))
170 return at_operating_point(replace(settings, **changes), lockup_psia=x.get(
"lockup_psia"),
171 fill_psig=x.get(
"copv_psig")), extra
174def _figures(res: Dict[str, Any]) -> Dict[str, Any]:
175 """What a candidate's burn says, flat."""
177 f = res.get(
"flight")
or {}
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"),
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")})
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
212 started = time.perf_counter()
214 st, extra =
_candidate(settings, variables, x, copv_id, objective, center)
215 prep = prepare(config,
None, drawing, st, list(overrides) + extra)
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:
225 return {
"index": index,
"x": x,
"ok":
False,
"error": f
"{type(exc).__name__}: {exc}",
226 "wall_s": time.perf_counter() - started}
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]] = []
238 return {
"objective":
None,
"violation": math.inf,
"constraints": [
239 {
"key":
"burn",
"label":
"The burn runs",
"ok":
False,
"detail": e.get(
"error",
"failed")}]}
241 return {
"objective":
None,
"violation": 1e3,
"constraints": [
242 {
"key":
"preflight",
"label":
"Preflight",
"ok":
False,
"detail":
"; ".join(e[
"preflight"])}]}
245 def add(key: str, label: str, value: Optional[float], limit: float, kind: str, scale: float, unit: str) ->
None:
247 rows.append({
"key": key,
"label": label,
"ok":
False,
"violation": 1.0,
"detail":
"not computed"})
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})
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")):
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:
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")
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")
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"})
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,
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))
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]:
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)
315 raise ValueError(
"preflight has failing checks; fix them before optimising")
317 free = [v
for v
in variables
if v.enabled]
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)
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")
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)
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)
344 def key(u: Tuple[float, ...]) -> Tuple[float, ...]:
345 return tuple(round(
min(
max(ui, 0.0), 1.0), 6)
for ui
in u)
347 def evaluate(batch: List[Tuple[float, ...]], iteration: int) ->
None:
351 if k
not in seen
and k
not in [key(f)
for f
in fresh]
and len(seen) + len(fresh) < budget:
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}
362 history.append(entry)
365 center = key(start_u)
369 def neighbours(c: Tuple[float, ...], h: float) -> List[Tuple[float, ...]]:
371 for i
in range(len(free)):
372 for sgn
in (1.0, -1.0):
374 u[i] =
min(
max(u[i] + sgn * h, 0.0), 1.0)
375 out.append(key(tuple(u)))
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:
383 best =
min(seen.values(), key=_rank)
384 if tuple(best[
"u"]) != center
and _rank(best) <
_rank(seen[center]):
385 center = tuple(best[
"u"])
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))
394 evaluate(neighbours(center, step), iteration)
395 if len(seen) == before
and step < MIN_STEP * 2:
398 pool.close(cancelled())
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:
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.")
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)
426 clean =
lambda e: {k: v
for k, v
in e.items()
if k
not in (
"u",)}
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,
436 "history": [clean(e)
for e
in history],
437 "verified": verified,
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."),
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."""
457 out: Dict[str, Any] = {}
458 for label, point
in ((
"best", x), (
"start", start_entry[
"x"]
if start_entry
else None)):
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)
465 out[label] = {
"ok":
False,
"error":
"; ".join(c.label
for c
in prep.checks
if c.status ==
"fail")}
467 res = run_prepared(prep, replay=
True, config=config
if request.objective ==
"apogee" else None)
469 dv = (res.get(
"delivered")
or {}).get(
"summary")
or {}
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)}
490HW_OBJECTIVES = (
"target_thrust_error",
"thrust_flatness",
"of_error",
"impulse",
"bottle_margin")
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
508 combine: bool =
False
509 max_candidates: int = 8
510 margin_psi: float = 100.0
511 meop_psi: Optional[Dict[str, float]] =
None
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."""
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"])
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>")
537 if rows
is not None and (
not isinstance(rows, (list, tuple))
or not all(isinstance(r, str)
for r
in rows)):
539 raise ValueError(f
"component {t!r} rows: a list of catalogue row ids")
544 """The drawing as this burn reads it: the person's restatements written in."""
547 payload, _, _ = apply_overrides(prep.drawing.payload, list(prep.measurements
or []))
551def _element(payload: Dict[str, Any], target: str) -> Optional[Dict[str, Any]]:
554 return el(payload, target)
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
562def _mm(p: Optional[Dict[str, Any]]) -> Optional[float]:
563 if not p
or not isinstance(p.get(
"value"), (int, float)):
565 return float(p[
"value"]) * {
"mm": 1.0,
"m": 1e3,
"in": 25.4}.get(str(p.get(
"unit")
or "mm"), 1.0)
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():
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
584 return float(discharge_coefficient_to_K(D=line_bore_m, Do=orifice_bore_m, C=C))
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
593 return float(C_Reader_Harris_Gallagher(D=line_bore_m, Do=orifice_bore_m, rho=rho, mu=mu, m=mdot, taps=taps))
596def _trim_model(line: str, D_m: float, rho: float, mu: float, mdot: float, C_given: Optional[float]) -> Dict[str, Any]:
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"),
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 "
610 "C is evaluated at the baseline burn's mean flow on that side; K does not follow the flow during the burn.",
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)"},
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."""
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
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)):
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})
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']}"}
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).
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
661 from itertools
import product
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")
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"}]
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})"})
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:
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":
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."})
719 options.append({
"target": target,
"label": f
"Trim orifice in {label}",
"pending":
True,
720 "summary":
"sized after the baseline burn"})
722 options +=
_trim_options(prep, payload, element, target, label, side, D_mm, baseline, request,
724 elif target.startswith(
"edge:"):
725 rows = cat.rows_for(catalogs,
"tubes",
"line")
727 rows = [r
for r
in rows
if r[
"id"]
in only]
730 raise ValueError(f
"{target} states no bore")
731 for r
in cat.neighbours(rows,
"bore", bore, n):
734 o[
"cad_impact"] =
"new part"
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).")
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)
743 rows = [r
for r
in rows
if r[
"id"]
in only]
744 cur =
_param(element, key)
746 raise ValueError(f
"{target} ({ctype}) states no {key}")
747 for r
in cat.neighbours(rows, key, float(cur[
"value"]), n):
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.")
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])]
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"])}
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."""
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)
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
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
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.")
799 dp_needed = (1.0 - shrink) * p_drop * PSI
800 K_needed = dp_needed / q
802 def K_of(Do: float) -> float:
806 lo_b, hi_b = 0.30 * D, 0.97 * D
807 if K_of(hi_b) > K_needed:
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
818 for r
in cat.neighbours(rows,
"d", Do_star * 1e3, n):
819 Do = cat.param_si(r,
"d") * 1e-3
823 K_by_taps =
None if request.trim_C
else {
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")
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)",
843def _objective(figs: Optional[Dict[str, Any]], request: HardwareRequest, target_F: Optional[float],
844 design_of: Optional[float]) -> Optional[float]:
845 """Smaller is better."""
848 o = request.objective
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
857 v = -figs[
"total_impulse_Ns"]
if figs.get(
"total_impulse_Ns")
is not None else None
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
863def _hw_rank(c: Dict[str, Any]) -> Tuple[int, int, float]:
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)
873def _hw_changes(options: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
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)))
900 geo: Dict[str, Any] = {}
901 dis: Dict[str, Any] = {}
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:
910 out: Dict[str, Any] = {}
912 out[
"injector"] = {
"geometry": geo}
914 out[
"discharge"] = dis
918def _merged_patch(options: List[Dict[str, Any]]) -> Optional[Dict[str, Dict[str, float]]]:
919 patch: Dict[str, Dict[str, float]] = {}
921 for side, fields
in (o.get(
"design_patch")
or {}).items():
922 patch.setdefault(side, {}).update(fields)
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``.
933 ``evaluate(args_list)`` replaces the burns and ``prep`` the preflight (tests)."""
938 from engine.layerx.setpoint import (SetpointRequest, burn_point, design_of_ratio, solve, target_thrust, usable)
940 say = progress
or (
lambda stage, fraction:
None)
941 started = time.perf_counter()
942 catalogs = catalogs
or load_catalogs(
None)
946 prep = prepare(config,
None, drawing, replace(settings, card_center_psia=
None), overrides)
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)
951 target_F, target_source = target_thrust(config, SetpointRequest(target_thrust_N=request.target_thrust_N))
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}
964 n_workers = default_workers(1 + len(plan[
"candidates"]), workers)
965 pool = WorkerPool(n_workers)
967 def evaluate(args_list: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
968 return pool.map(burn_point, args_list, cancelled=cancelled)
972 def args_for(options: List[Dict[str, Any]], **extra: Any) -> Dict[str, Any]:
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}
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)
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:])]
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 []
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")
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.")
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"])
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)])
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")
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"]
1048 if pool
is not None:
1049 pool.close(cancelled())
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")})
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}
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)}
1089 models = [o[
"model"]
for c
in candidates
for o
in c[
"options"]
if o.get(
"model")][:1]
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 []),
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)",
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).",
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)"},
1126 "trim_model": models[0]
if models
else None,
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 "
"HardwareRequest" from_dict(cls, Dict[str, Any] raw)
"OptimizeRequest" from_dict(cls, Dict[str, Any] raw)
static std::vector< string_t > split(const string_t &reference_string)
split the string input to reference tokens
Optional[float] _mm(Optional[Dict[str, Any]] p)
Optional[Dict[str, Dict[str, float]]] _merged_patch(List[Dict[str, Any]] options)
Dict[str, Any] _trim_model(str line, float D_m, float rho, float mu, float mdot, Optional[float] C_given)
float trim_orifice_K(float line_bore_m, float orifice_bore_m, float C)
Tuple[Any, List[Any]] _candidate(Any settings, List[Variable] variables, Dict[str, float] x, Optional[str] copv_id, str objective, float center_psia)
Tuple[int, int, float] _hw_rank(Dict[str, Any] c)
Optional[Dict[str, Any]] _option_from_row(str target, Dict[str, Any] element, Dict[str, Any] row, str label)
Optional[Dict[str, Any]] _design_updates(List[Dict[str, Any]] options)
List[Variable] resolve_variables(Any prep, Any config, OptimizeRequest request)
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)
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)
Dict[str, Any] plan_candidates(Any prep, Any config, HardwareRequest request, Dict[str, Any] catalogs, Optional[Dict[str, Any]] baseline=None)
float trim_orifice_C(float line_bore_m, float orifice_bore_m, float rho, float mu, float mdot, str taps=TRIM_TAPS)
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)
Dict[str, Any] _figures(Dict[str, Any] res)
List[Variable] default_variables(Any prep, Any config)
Optional[str] _edge_side(Dict[str, Any] payload, Dict[str, Any] edge, Dict[str, str] species)
List[Dict[str, Any]] _hw_changes(List[Dict[str, Any]] options)
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)
Dict[str, Any] grade(Dict[str, Any] e, OptimizeRequest request, Dict[str, Optional[List[float]]] band, Optional[float] design_of)
Dict[str, Any] _evaluate(Tuple[Any,...] args)
Optional[float] _objective(Optional[Dict[str, Any]] figs, HardwareRequest request, Optional[float] target_F, Optional[float] design_of)
Optional[Dict[str, Any]] _element(Dict[str, Any] payload, str target)
Optional[Dict[str, Any]] _param(Dict[str, Any] element, str name)
Dict[str, Any] _payload_with(Any prep)
Tuple[int, float, float] _rank(Dict[str, Any] g)