105def factors(prep: Any, config: Any) -> Tuple[List[Factor], List[str]]:
106 """The factors for this burn, each with its low and high cases, and notes about any dropped."""
109 out: List[Factor] = []
110 notes: List[str] = []
111 measured = {o[
"key"]: o
for o
in prep.overrides
if o.get(
"provenance") ==
"measured"}
114 domes = [n
for n
in prep.model.diagram.nodes
if n.type ==
"PR" and n.options.get(
"domeLoaded") ==
"yes"]
115 for node
in domes[:1]:
116 for param, label
in ((
"supply_coefficient",
"Regulator supply-pressure effect"), (
"flow_droop",
"Regulator droop")):
117 p = node.params.get(param)
120 key = f
"node:{node.id}.{param}"
121 value, unit = float(p.value), str(p.unit)
122 m = measured.get(key)
123 if m
is not None and m.get(
"uncertainty")
is None:
124 notes.append(f
"{label} is measured with no uncertainty stated; held exact.")
126 if m
is None and p.source.value ==
"measured":
128 notes.append(f
"{label} ({value:g} {unit}) is marked measured on the drawing with no uncertainty "
129 f
"stated; held exact. Restate it with a ± under Parameters and measurements to sweep it.")
132 lo, hi = value - float(m[
"uncertainty"]), value + float(m[
"uncertainty"])
133 basis = f
"measured {value:g} ± {m['uncertainty']:g} {unit} ({m['source']})"
135 lo, hi = 0.5 * value, 1.5 * value
136 basis = (f
"×0.5–1.5 of the drawing's {value:g} {unit} ({p.source.value}); the range is assumed, "
137 "wide enough for a regulator nobody has flow-tested")
138 def case(side: str, v: float, param=param, unit=unit, key=key) -> Case:
139 return Case(key, side, f
"{v:g} {unit}", overrides=[Override(
140 target=f
"node:{node.id}", parameter=param, value=v, unit=unit,
141 source=f
"Layer X uncertainty sweep ({side})", provenance=
"estimated")])
142 out.append(
Factor(key, label,
"feed", basis, [case(
"low", lo), case(
"high", hi)], value, unit))
145 collapse_on = bool(getattr(getattr(prep,
"setup",
None),
"ullage_collapse", prep.settings.ullage_collapse))
146 out.append(
Factor(
"ullage_collapse",
"Ullage collapse",
"feed",
147 f
"the collapse model switched {'off' if collapse_on else 'on'} (the feed twin's semi-infinite "
148 "conduction; a stated lower bound)",
149 [
Case(
"ullage_collapse",
"off" if collapse_on
else "on",
"off" if collapse_on
else "on",
150 settings={
"ullage_collapse":
not collapse_on})]))
153 ref = prep.link.reference
if prep.link
is not None else {}
154 ms = getattr(config,
"measurements",
None)
155 model_cd: Dict[str, float] = {}
156 if prep.link
is not None and prep.link.sampler
is not None:
160 full = prep.link.sampler.runner.evaluate(ref[
"p_O"], ref[
"p_F"], silent=
True,
161 P_ambient=prep.ambient_pa)
162 model_cd = {
"O": float(full.get(
"Cd_O", float(
"nan"))),
"F": float(full.get(
"Cd_F", float(
"nan")))}
163 except Exception
as exc:
164 notes.append(f
"Orifice Cd left out: EngineDesign's solve at T-0 failed ({type(exc).__name__}).")
165 for side, key, label
in ((
"O",
"cd_O",
"LOX orifice Cd"), (
"F",
"cd_F",
"Fuel orifice Cd")):
166 mv = getattr(ms, key,
None)
if ms
is not None else None
167 nominal = float(mv.value)
if mv
is not None else model_cd.get(side, float(
"nan"))
168 if not math.isfinite(nominal):
169 notes.append(f
"{label} left out: no nominal value to vary.")
171 if mv
is not None and mv.uncertainty
is None:
172 notes.append(f
"{label} is measured with no uncertainty stated; held exact.")
174 d = float(mv.uncertainty)
if mv
is not None else 0.03 * nominal
175 basis = (f
"measured {nominal:.3f} ± {d:.3f} ({mv.source})" if mv
is not None
176 else f
"±3 % of the model's {nominal:.3f}: the Lichtarowicz correlation's scatter")
177 out.append(
Factor(f
"engine.{key}", label,
"engine", basis, [
178 Case(f
"engine.{key}",
"low", f
"{nominal - d:.3f}", engine={
"measure": key,
"value": nominal - d}),
179 Case(f
"engine.{key}",
"high", f
"{nominal + d:.3f}", engine={
"measure": key,
"value": nominal + d}),
181 em = getattr(ms,
"em",
None)
if ms
is not None else None
182 if em
is not None and em.uncertainty
is None:
183 notes.append(
"Mixing E_m is measured with no uncertainty stated; held exact.")
185 e, u = float(em.value), float(em.uncertainty)
186 out.append(
Factor(
"engine.em",
"Mixing E_m",
"engine", f
"measured {e:.3f} ± {u:.3f} ({em.source})", [
187 Case(
"engine.em",
"low", f
"{e - u:.3f}", engine={
"measure":
"em",
"value": e - u}),
188 Case(
"engine.em",
"high", f
"{e + u:.3f}", engine={
"measure":
"em",
"value": e + u})], e))
190 e0 = float(config.combustion.efficiency.rupe_Em_opt)
191 out.append(
Factor(
"engine.em",
"Mixing E_m",
"engine",
192 f
"Rupe E_m_opt 0.70 / 0.85 about the config's {e0:.2f}: the literature range the spray report uses", [
193 Case(
"engine.em",
"low",
"0.70", engine={
"field":
"combustion.efficiency.rupe_Em_opt",
"value": 0.70}),
194 Case(
"engine.em",
"high",
"0.85", engine={
"field":
"combustion.efficiency.rupe_Em_opt",
"value": 0.85})], e0))
195 zn = getattr(ms,
"nozzle_efficiency",
None)
if ms
is not None else None
196 cfg_z = getattr(config.chamber_geometry,
"nozzle_efficiency",
None)
197 z0 = float(zn.value)
if zn
is not None else (float(cfg_z)
if cfg_z
else None)
199 notes.append(
"Nozzle efficiency left out: the config states none to vary about.")
200 elif zn
is not None and zn.uncertainty
is None:
201 notes.append(
"Nozzle efficiency is measured with no uncertainty stated; held exact.")
204 dz = float(zn.uncertainty)
205 lo_z, hi_z = z0 - dz, z0 + dz
206 basis = f
"measured {z0:.3f} ± {dz:.3f} ({zn.source})"
210 lo_z, hi_z = z0 - 0.02,
min(
max(z0 + 0.02, 0.99), 0.995)
211 basis = f
"{lo_z:.2f}–{hi_z:.2f} about the config's unsourced {z0:.2f}; an 80 % bell is typically 0.975–0.985"
212 out.append(
Factor(
"engine.nozzle",
"Nozzle efficiency",
"engine", basis, [
213 Case(
"engine.nozzle",
"low", f
"{lo_z:.3f}", engine={
"measure":
"nozzle_efficiency",
"value": lo_z}),
214 Case(
"engine.nozzle",
"high", f
"{hi_z:.3f}", engine={
"measure":
"nozzle_efficiency",
"value": hi_z})], z0))
220 def guessed(param: Any, key: str) -> bool:
221 return (str(getattr(param.source,
"value", param.source)).lower()
in (
"estimated",
"default")
222 and key
not in measured)
224 diagram = prep.model.diagram
226 (
"valve_cv",
"Valve Cv",
"Cv", [(f
"node:{n.id}", n.params[
"Cv"])
for n
in diagram.nodes
227 if n.type ==
"SOL" and "Cv" in n.params
and guessed(n.params[
"Cv"], f
"node:{n.id}.Cv")]),
228 (
"line_k",
"Line fitting losses",
"K_minor", [(f
"edge:{e.id}", e.params[
"K_minor"])
for e
in diagram.edges
229 if "K_minor" in (e.params
or {})
and float(e.params[
"K_minor"].value) > 0
230 and guessed(e.params[
"K_minor"], f
"edge:{e.id}.K_minor")]),
231 (
"line_length",
"Line lengths",
"length", [(f
"edge:{e.id}", e.params[
"length"])
for e
in diagram.edges
232 if "length" in (e.params
or {})
and guessed(e.params[
"length"], f
"edge:{e.id}.length")]),
234 for key, label, param, items
in groups:
238 def scaled(side: str, k: float, param=param, items=items, key=key) -> Case:
239 return Case(f
"drawing.{key}", side, f
"×{k:g}", overrides=[Override(
240 target=t, parameter=param, value=float(p.value) * k, unit=str(p.unit),
241 source=f
"Layer X uncertainty sweep ({side})", provenance=
"estimated")
for t, p
in items])
243 cases = [scaled(
"low", 0.5), scaled(
"high", 1.5)]
244 out.append(
Factor(f
"drawing.{key}", label,
"feed",
245 f
"×0.5–1.5 of every estimated {param} on the drawing together ({len(items)} of them); "
246 "measure them, or restate them, to drop this", cases, unit=
""))
249 copv_id = (getattr(prep,
"derived",
None)
or {}).get(
"copv_id")
250 for b
in [n
for n
in diagram.nodes
if n.type ==
"KBOTTLE" and (copv_id
is None or n.id == copv_id)][:1]:
251 p = b.params.get(
"wall_conductance")
252 if p
is not None and guessed(p, f
"node:{b.id}.wall_conductance"):
254 out.append(
Factor(
"drawing.bottle_wall",
"Bottle wall heat transfer",
"feed",
255 f
"×0.25–2 of the drawing's estimated {v:g} {p.unit}: the bottle's spare at burnout moves with it",
256 [
Case(
"drawing.bottle_wall", side, f
"{v * k:g} {p.unit}", overrides=[Override(
257 target=f
"node:{b.id}", parameter=
"wall_conductance", value=v * k, unit=str(p.unit),
258 source=f
"Layer X uncertainty sweep ({side})", provenance=
"estimated")])
259 for side, k
in ((
"low", 0.25), (
"high", 2.0))], v, str(p.unit)))
263 for flag, label, why
in ((
"line_walls",
"Line-wall heat",
"tubes and fittings warming the pressurant"),
264 (
"ullage_vapour",
"Propellant vapour",
"boil-off into the ullage")):
265 on = bool(getattr(getattr(prep,
"setup",
None), flag, getattr(prep.settings, flag,
False)))
266 out.append(
Factor(flag, label,
"feed", f
"switched {'off' if on else 'on'}: {why}",
267 [
Case(flag,
"off" if on
else "on",
"off" if on
else "on", settings={flag:
not on})]))
270 lockup = float(prep.derived[
"target_lockup_psia"])
271 fill = float(prep.derived[
"copv_psig"])
275 out.append(
Factor(
"op.lockup",
"Tank pressure as set",
"operation",
276 "±5 psi: the dome set by hand against a gauge (assumed)",
277 [
Case(
"op.lockup",
"low", f
"{lockup - 5:.0f} psia",
278 settings={
"tank_pressure_psia": lockup - 5,
"dome_psia":
None}),
279 Case(
"op.lockup",
"high", f
"{lockup + 5:.0f} psia",
280 settings={
"tank_pressure_psia": lockup + 5,
"dome_psia":
None})], lockup,
"psia"))
281 out.append(
Factor(
"op.fill",
"Bottle fill as filled",
"operation",
282 "−150 / +0 psig: a fill that stops short, or cools after filling (assumed)",
283 [
Case(
"op.fill",
"low", f
"{fill - 150:.0f} psig",
284 settings={
"copv_pressure_psig": fill - 150,
"copv_pressure_psia":
None})], fill,
"psig"))
293 cfg = config.model_copy(deep=
True)
294 if "measure" in engine:
295 ms = cfg.measurements
or MeasurementsConfig()
296 ms = ms.model_copy(update={engine[
"measure"]: MeasuredValue(value=float(engine[
"value"]),
297 source=
"Layer X uncertainty sweep")})
298 cfg.measurements = ms
300 *head, last = engine[
"field"].
split(
".")
303 target = target[part]
if isinstance(target, dict)
else getattr(target, part)
304 setattr(target, last, float(engine[
"value"]))
327def _burn_case(args: Tuple[Any, Any, Any, List[Any], Dict[str, Any]]) -> Dict[str, Any]:
328 """One case, start to finish, in whatever process runs it."""
329 config, drawing, settings, overrides, case = args
333 started = time.perf_counter()
338 st = replace(settings, flight=
False, **(case.get(
"settings")
or {}))
339 prep = prepare(cfg,
None, drawing, st, list(overrides) + list(case.get(
"overrides")
or []))
341 failing = [c.label
for c
in prep.checks
if c.status ==
"fail"]
342 return {
"ok":
False,
"error":
"preflight: " +
"; ".join(failing),
"case": case[
"id"]}
343 res = run_prepared(prep, replay=
False)
344 if res.get(
"tripped"):
347 return {
"ok":
False,
"case": case[
"id"],
"tripped": res[
"tripped"],
"error":
_trip_words(res[
"tripped"]),
348 "wall_s": time.perf_counter() - started}
350 thrust = [(t, f)
for t, f, on
in zip(res[
"series"][
"t"], res[
"series"][
"chamber"][
"thrust_N"],
351 res[
"series"][
"firing"])
if on]
352 depleted = s.get(
"depleted_side")
or ""
354 other = (s.get(
"fuel")
if depleted ==
"oxidiser" else s.get(
"ox")
if depleted ==
"fuel" else None)
or {}
355 left = other.get(
"residual_kg")
356 return {
"ok":
True,
"case": case[
"id"],
"metrics":
_metrics(s),
"thrust": thrust,
357 "depleted": depleted,
"lockup_psia": prep.derived.get(
"target_lockup_psia"),
358 "residual_kg": float(left)
if isinstance(left, (int, float))
and math.isfinite(left)
else None,
359 "wall_s": time.perf_counter() - started}
360 except Exception
as exc:
361 out = {
"ok":
False,
"case": case[
"id"],
"error": f
"{type(exc).__name__}: {exc}"}
362 record = getattr(exc,
"record",
None)
363 if isinstance(record, dict):
364 out.update({
"tripped": record,
"error":
_trip_words(record)})
377def run_sweep(config: Any, drawing: Any, settings: Any, overrides: List[Any], *,
378 progress: Optional[Callable[[str, float],
None]] =
None,
379 cancelled: Callable[[], bool] =
lambda:
False,
380 workers: Optional[int] =
None) -> Dict[str, Any]:
381 """The nominal burn and every factor's cases, in parallel; the tornado and the band."""
385 say = progress
or (
lambda stage, fraction:
None)
386 started = time.perf_counter()
387 say(
"Preparing the nominal burn", 0.02)
388 flown = bool(getattr(settings,
"flight",
False))
389 st = replace(settings, replay=
False, flight=
False)
if hasattr(settings,
"replay")
else settings
390 nominal_prep = prepare(config,
None, drawing, st, overrides)
391 if not nominal_prep.ok:
392 raise ValueError(
"preflight has failing checks; fix them before sweeping")
393 found, notes =
factors(nominal_prep, config)
395 notes.append(
"Swept on the pad at one g: the flight's own effect is the run's pad-against-flight table.")
397 tasks: List[Dict[str, Any]] = [{
"id":
"nominal"}]
400 tasks.append({
"id": f
"{f.key}|{c.side}",
"overrides": c.overrides,
"settings": c.settings,
402 n_workers = default_workers(len(tasks), workers)
403 args = [(config, drawing, st, overrides, t)
for t
in tasks]
404 say(f
"Burning {len(tasks)} cases on {n_workers} workers", 0.05)
405 with WorkerPool(n_workers)
as pool:
406 done = pool.map(_burn_case, args, cancelled=cancelled,
407 on_result=
lambda k, r: say(f
"Case {k} of {len(args)} done", 0.05 + 0.9 * k / len(args)))
408 results: Dict[str, Dict[str, Any]] = {r[
"case"]: r
for r
in done}
410 base = results.get(
"nominal")
411 if not base
or not base.get(
"ok"):
413 raise RuntimeError(f
"the nominal case failed: {base.get('error') if base else 'no result'}")
414 nominal = base[
"metrics"]
417 entry = {
"key": f.key,
"label": f.label,
"group": f.group,
"basis": f.basis,
"cases": {}}
419 r = results.get(f
"{f.key}|{c.side}")
or {
"ok":
False,
"error":
"no result"}
421 entry[
"cases"][c.side] = {
"label": c.label,
"ok":
False,
"error": r.get(
"error")}
423 entry[
"cases"][c.side][
"tripped"] = r[
"tripped"]
425 delta = {k: (v - nominal[k])
if v
is not None and nominal.get(k)
is not None else None
426 for k, v
in r[
"metrics"].items()}
427 entry[
"cases"][c.side] = {
"label": c.label,
"ok":
True,
"metrics": r[
"metrics"],
"delta": delta,
428 "thrust": r[
"thrust"]}
431 ds = [abs(cs[
"delta"][k])
for cs
in entry[
"cases"].values()
if cs.get(
"ok")
and cs[
"delta"].get(k)
is not None]
432 swing[k] =
max(ds)
if ds
else None
433 entry[
"swing"] = swing
435 rows.sort(key=
lambda e: -(e[
"swing"].get(
"total_impulse_Ns")
or 0.0))
437 band_low: Dict[str, Optional[float]] = {}
438 band_high: Dict[str, Optional[float]] = {}
440 parts = [e[
"swing"][k]
for e
in rows
if e[
"swing"].get(k)
is not None]
441 band[k] = math.sqrt(sum(p * p
for p
in parts))
if parts
else None
445 ds = [cs[
"delta"][k]
for cs
in e[
"cases"].values()
if cs.get(
"ok")
and cs[
"delta"].get(k)
is not None]
447 downs.append(
min(
min(ds), 0.0))
448 ups.append(
max(
max(ds), 0.0))
449 band_low[k] = math.sqrt(sum(d * d
for d
in downs))
if downs
else None
450 band_high[k] = math.sqrt(sum(u * u
for u
in ups))
if ups
else None
455 band_cfg = nominal_prep.derived.get(
"stiffness_band")
or {}
456 floor = {
"ox": (band_cfg.get(
"oxidiser")
or [STIFFNESS_FLOOR])[0],
"fuel": (band_cfg.get(
"fuel")
or [STIFFNESS_FLOOR])[0]}
457 nominal_side = base.get(
"depleted",
"")
458 crossings: List[Dict[str, Any]] = []
460 for side, cs
in e[
"cases"].items():
461 if cs.get(
"tripped"):
463 cs[
"breaks"] = [cs.get(
"error")
or "vessel trip"]
464 crossings.append({
"factor": e[
"label"],
"side": side,
"case": cs[
"label"],
"breaks": cs[
"breaks"],
469 r = results.get(f
"{e['key']}|{side}")
or {}
472 for name, key
in ((
"LOX ΔP/Pc",
"ox"), (
"Fuel ΔP/Pc",
"fuel")):
473 v = m.get(f
"{key}_stiffness_min")
474 if v
is not None and v < floor[key]:
475 breaks.append(f
"{name} {v * 100:.1f} % < {floor[key] * 100:.0f} %")
476 spare = (m[
"copv_end_psia"] - float(r.get(
"lockup_psia")
or 0.0))
if m.get(
"copv_end_psia")
is not None else None
477 if spare
is not None and spare < SPARE_PSI:
478 breaks.append(f
"bottle spare {spare:.0f} psi < {SPARE_PSI:.0f}")
479 if r.get(
"depleted")
and nominal_side
and r[
"depleted"] != nominal_side:
482 left = r.get(
"residual_kg")
483 if left
is None or left > RESIDUAL_KG:
484 word = {
"oxidiser":
"LOX",
"fuel":
"fuel"}
485 held =
"fuel" if r[
"depleted"] ==
"oxidiser" else "LOX"
486 breaks.append(f
"{word.get(nominal_side, nominal_side)}-first becomes "
487 f
"{word.get(r['depleted'], r['depleted'])}-first"
488 + (f
", {left:.2f} kg of {held} left > {RESIDUAL_KG:g} kg" if left
is not None else ""))
489 cs[
"breaks"] = breaks
491 crossings.append({
"factor": e[
"label"],
"side": side,
"case": cs[
"label"],
"breaks": breaks})
492 failed = [e[
"key"] +
"|" + side
for e
in rows
for side, cs
in e[
"cases"].items()
493 if not cs.get(
"ok")
and not cs.get(
"tripped")]
494 trips = [e[
"key"] +
"|" + side
for e
in rows
for side, cs
in e[
"cases"].items()
if cs.get(
"tripped")]
498 "nominal_thrust": base[
"thrust"],
501 "band_low": band_low,
502 "band_high": band_high,
503 "crossings": crossings,
504 "notes": notes + ([f
"{len(failed)} case(s) failed: {', '.join(failed)}"]
if failed
else [])
505 + ([f
"{len(trips)} case(s) tripped the stand and are left out of the swings (see the crossings): "
506 f
"{', '.join(trips)}"]
if trips
else []),
508 "workers": n_workers,
509 "wall_s": time.perf_counter() - started,
510 "basis": (
"One factor at a time, low and high, everything else nominal. Below and above are each the "
511 "root-sum-square of the factors' swings that way; ranges are bounds unless a measurement "
512 "states a one-sigma. Burns on the pad, without the erosion replay."),
Dict[str, Any] run_sweep(Any config, Any drawing, Any settings, List[Any] overrides, *, Optional[Callable[[str, float], None]] progress=None, Callable[[], bool] cancelled=lambda:False, Optional[int] workers=None)