diff --git a/EngineDesign/frontend/src/components/InjectorPatternPlot.test.ts b/EngineDesign/frontend/src/components/InjectorPatternPlot.test.ts
new file mode 100644
index 000000000..a1f23c310
--- /dev/null
+++ b/EngineDesign/frontend/src/components/InjectorPatternPlot.test.ts
@@ -0,0 +1,184 @@
+import { describe, it, expect } from 'vitest';
+import { deriveInjectorLayout } from './InjectorPatternPlot';
+
+/**
+ * The drawing makes claims about hardware. These pin the claims.
+ *
+ * Both fixtures are real designs this repo produced. The "before" one passed all seven
+ * Layer-1 gates while being unbuildable, which is the whole reason these views exist.
+ */
+
+// configs/ethalox_8kN_FINAL.yaml -- passed every gate, could not be built
+const BEFORE = {
+ oxidizer: { n_elements: 28, d_jet: 0.0015821116699998301, impingement_angle: 40, spacing: 0.0030182972100858507 },
+ fuel: { n_elements: 28, d_jet: 0.001375132476863662, impingement_angle: 69, spacing: 0.009875029755263455 },
+ boreDiameter: 0.127,
+};
+
+// configs/ethalox_8kN_SHIP.yaml -- same thrust, same bore, buildable
+const AFTER = {
+ oxidizer: { n_elements: 27, d_jet: 0.0017094, impingement_angle: 40.0, spacing: 0.0081070167 },
+ fuel: { n_elements: 27, d_jet: 0.0015024, impingement_angle: 50.0, spacing: 0.0111428181 },
+ boreDiameter: 0.127,
+};
+
+// The requirements the shipped config actually declares.
+const REQS = {
+ centerClearDiameter: 0.0381, minWeb: 0.002, wallClearance: 0.008,
+ ldMin: 3, ldMax: 5, plateThickness: 0.0127,
+ counterboreDiameter: 0.004, orificeLandLOverD: 4,
+};
+
+const texts = (w: { text: string }[]) => w.map((x) => x.text).join(' | ');
+
+describe('the old design, which every gate passed', () => {
+ const { g, warnings } = deriveInjectorLayout({ ...BEFORE, ...REQS });
+
+ it('crams every element onto a circle narrower than the throat', () => {
+ expect(g.rImp * 2000).toBeCloseTo(41.79, 1); // vs a 49.57 mm throat
+ expect(g.coreFrac).toBeLessThan(0.11); // 10.8 % of the chamber area
+ });
+
+ it('leaves no room at the axis for a 3/8 NPT igniter', () => {
+ expect(g.centreClear * 1000).toBeCloseTo(24.84, 1);
+ expect(texts(warnings)).toContain('centre clear');
+ });
+
+ it('calls out the 1.44 mm LOX web against a 2 mm floor', () => {
+ expect(g.webO * 1000).toBeCloseTo(1.436, 2);
+ expect(texts(warnings)).toContain('web 1.44 mm < 2.00 mm required');
+ });
+
+ it('flags the entry as too shallow to start a drill', () => {
+ // theta is from the AXIS, so a 69 deg fuel jet meets the face at 21 deg.
+ expect(texts(warnings)).toContain('meets the face at 21°');
+ });
+
+ it('needs a counterbore to be drillable at all', () => {
+ // 12.7 mm plate at 69 deg = 35.44 mm of passage. Read at the orifice diameter --
+ // no counterbore -- that is 35.44 - 5.50 land = 29.94 mm at ⌀1.375 = L/d 21.8.
+ // The 4 mm counterbore in REQS is exactly what rescues it, which is the point.
+ const bare = deriveInjectorLayout({
+ ...BEFORE, ...REQS, counterboreDiameter: 0,
+ });
+ expect(texts(bare.warnings)).toContain('L/d 21.8');
+ expect(texts(warnings)).not.toContain('feed passage');
+ });
+
+ it('flags the SP-8089 included angle', () => {
+ expect(g.included).toBe(109);
+ expect(texts(warnings)).toContain('NASA SP-8089');
+ });
+});
+
+describe('the design that replaced it', () => {
+ const { g, warnings } = deriveInjectorLayout({ ...AFTER, ...REQS });
+
+ it('clears the igniter boss, the web floor and the wall land', () => {
+ expect(g.centreClear).toBeGreaterThan(REQS.centerClearDiameter);
+ expect(Math.min(g.webO, g.webF)).toBeGreaterThan(REQS.minWeb);
+ expect(g.wallLand).toBeGreaterThan(REQS.wallClearance);
+ });
+
+ it('spreads the spray over a real share of the chamber', () => {
+ expect(g.rImp * 2000).toBeGreaterThan(78);
+ expect(g.coreFrac).toBeGreaterThan(0.35);
+ });
+
+ it('stays under the SP-8089 face-heating threshold', () => {
+ expect(g.included).toBe(90);
+ expect(texts(warnings)).not.toContain('NASA SP-8089');
+ });
+
+ it('raises no blocking warning at all', () => {
+ expect(warnings.filter((w) => w.level === 'bad')).toEqual([]);
+ });
+});
+
+describe('the elliptical face trace', () => {
+ it('is what the clearances are measured on, not the drill diameter', () => {
+ // Same hole, same ring, only the inclination changes. A 69 deg hole reaches
+ // d/cos(69) = 2.79x its own diameter radially; a nearly-axial one reaches ~d.
+ const base = {
+ oxidizer: { n_elements: 24, d_jet: 0.0016, impingement_angle: 40, spacing: 0.0118 },
+ fuel: { n_elements: 24, d_jet: 0.0016, impingement_angle: 40, spacing: 0.0039 },
+ boreDiameter: 0.127,
+ };
+ const shallow = deriveInjectorLayout({ ...base, fuel: { ...base.fuel, impingement_angle: 5 } });
+ const steep = deriveInjectorLayout({ ...base, fuel: { ...base.fuel, impingement_angle: 69 } });
+ expect(steep.g.centreClear).toBeLessThan(shallow.g.centreClear);
+ expect(shallow.g.centreClear - steep.g.centreClear).toBeCloseTo(
+ 0.0016 / Math.cos((69 * Math.PI) / 180) - 0.0016 / Math.cos((5 * Math.PI) / 180), 5);
+ });
+});
+
+describe('rings on one circle', () => {
+ it('is named as a non-injector rather than drawn as a doublet', () => {
+ const same = {
+ oxidizer: { n_elements: 20, d_jet: 0.002, impingement_angle: 50, spacing: 0.006 },
+ fuel: { n_elements: 20, d_jet: 0.002, impingement_angle: 60, spacing: 0.006 },
+ boreDiameter: 0.0813,
+ };
+ const { g, warnings } = deriveInjectorLayout(same);
+ expect(g.degenerate).toBe(true);
+ expect(g.lImp).toBe(0);
+ expect(texts(warnings)).toContain('face-eroding non-injector');
+ // and it must NOT also emit the derived-quantity noise that means nothing here
+ expect(texts(warnings)).not.toContain('outside the');
+ expect(texts(warnings)).not.toContain('of the chamber area is fed');
+ });
+});
+
+
+describe('an orifice is a land on a counterbore, not a hole through the plate', () => {
+ const PLATE = { plateThickness: 0.0127, orificeLandLOverD: 4 };
+
+ it('reads the passage at the orifice diameter when no counterbore is declared', () => {
+ // Conservative fallback. On the old 69 deg fuel jet: 35.44 mm of passage, a 5.50 mm land,
+ // and 29.94 mm left at ⌀1.375 = L/d 21.8.
+ const { warnings } = deriveInjectorLayout({ ...BEFORE, ...PLATE });
+ expect(texts(warnings)).toContain('feed passage');
+ expect(texts(warnings)).toContain('L/d 21.8');
+ });
+
+ it('clears once the counterbore is opened out', () => {
+ // 29.94 mm at ⌀4.0 is L/d 7.5 -- an ordinary peck cycle.
+ const { warnings } = deriveInjectorLayout({
+ ...BEFORE, ...PLATE, counterboreDiameter: 0.004,
+ });
+ expect(texts(warnings)).not.toContain('feed passage');
+ });
+
+ it('still catches a counterbore too small for that depth', () => {
+ // ⌀2.5 leaves L/d 12.0 on the roughing pass -- still past practice.
+ const { warnings } = deriveInjectorLayout({
+ ...BEFORE, ...PLATE, counterboreDiameter: 0.0025,
+ });
+ expect(texts(warnings)).toContain('feed passage');
+ });
+
+ it('never calls the orifice land itself too deep -- it is short by construction', () => {
+ for (const d of [BEFORE, AFTER]) {
+ const { warnings } = deriveInjectorLayout({ ...d, ...PLATE });
+ expect(texts(warnings)).not.toContain('orifice land');
+ }
+ });
+
+ it('the shipped design needs no counterbore at all to be drillable', () => {
+ // Its steepest jet is 50 deg, so the worst passage is 13.75 mm at ⌀1.502 = L/d 9.2,
+ // inside twist-drill practice even read at the orifice diameter.
+ const { warnings } = deriveInjectorLayout({ ...AFTER, ...REQS, ...PLATE });
+ expect(warnings).toEqual([]);
+ });
+});
+
+describe('units', () => {
+ it('reports every face dimension in mm, not metres', () => {
+ // centreClear is derived in METRES like every other length here; the face readout
+ // printed it raw and showed "centre clear ⌀0.07" next to "wall land 14.45 mm".
+ const { g } = deriveInjectorLayout({ ...AFTER, ...REQS });
+ expect(g.centreClear).toBeLessThan(1); // metres internally
+ expect(g.centreClear * 1000).toBeGreaterThan(60); // ~67 mm on the shipped design
+ expect(g.wallLand * 1000).toBeGreaterThan(8);
+ });
+});
diff --git a/EngineDesign/frontend/src/components/InjectorPatternPlot.tsx b/EngineDesign/frontend/src/components/InjectorPatternPlot.tsx
new file mode 100644
index 000000000..04c622d17
--- /dev/null
+++ b/EngineDesign/frontend/src/components/InjectorPatternPlot.tsx
@@ -0,0 +1,426 @@
+import { useMemo } from 'react';
+
+/**
+ * Two engineering views of an unlike-doublet injector, drawn from the design variables.
+ *
+ * Nothing here is stored directly -- it is all derived, and the derivation IS the doublet:
+ *
+ * D_pitch = n * spacing / pi each stream sits on its own pitch circle
+ * dr = |D_pitch_O - D_pitch_F| / 2
+ * L_imp = dr / (tan th_O + tan th_F) the jets close that radial gap and collide
+ * r_imp = r_inner + L_imp * tan(th_inner)
+ *
+ * TWO THINGS THIS DRAWING EXISTS TO MAKE VISIBLE, because the numbers hid them:
+ *
+ * 1. WHERE THE SPRAY ACTUALLY GOES. Every doublet collides on the SAME circle, r_imp. On
+ * the first design that survived every gate, that circle was 41.79 mm across inside a
+ * 127.00 mm bore -- narrower than the 49.57 mm throat, feeding 10.8 % of the chamber
+ * area. No constraint saw it. The face view draws the impingement circle against the
+ * bore so the mass distribution is a picture, not an inference.
+ *
+ * 2. THE HOLES ARE NOT ROUND ON THE FACE. A drill of diameter d inclined th from the
+ * chamber axis cuts an axis-normal face as an ELLIPSE: minor axis d circumferentially,
+ * major axis d/cos(th) radially. At 69 deg that is 2.79x the drill diameter, and it is
+ * the radial number that decides whether a centre boss or the chamber wall is clear.
+ * Drawing round holes understates the footprint of every steep orifice.
+ */
+
+export interface InjectorStream {
+ n_elements: number;
+ d_jet: number; // m
+ impingement_angle: number; // deg from the chamber axis
+ spacing: number; // m, circumferential centre-to-centre on its own pitch circle
+}
+
+interface Props {
+ oxidizer: InjectorStream;
+ fuel: InjectorStream;
+ boreDiameter: number; // m
+ /** Fuel ring outboard of the LOX ring. Sets which way a momentum imbalance tilts the fan. */
+ fuelOutboard?: boolean;
+ /** Reserved clear circle at the axis (igniter boss / centre port) [m]. */
+ centerClearDiameter?: number;
+ /** Minimum land between adjacent holes on a ring [m]. */
+ minWeb?: number;
+ /** Minimum radial land from the outer ring's face trace to the bore [m]. */
+ wallClearance?: number;
+ /** Standoff acceptance band in orifice diameters. */
+ ldMin?: number;
+ ldMax?: number;
+ /** Face plate thickness, for the drilled-depth callout [m]. */
+ plateThickness?: number;
+ /**
+ * Feed-passage (counterbore) diameter behind each orifice [m].
+ *
+ * An orifice is NOT a small hole through the whole plate. It is a short LAND at the face
+ * end of a much larger feed passage -- the land sets Cd (that is `discharge.orifice_l_over_d`)
+ * and the passage carries the flow. Reporting the drilled depth against the orifice
+ * diameter claimed L/d 13.5 on a design whose small drill only goes 6.6 mm. 0 falls back
+ * to the orifice diameter, which is the conservative reading.
+ */
+ counterboreDiameter?: number;
+ /** Orifice land length in orifice diameters -- discharge.orifice_l_over_d. */
+ orificeLandLOverD?: number;
+}
+
+const MM = 1000;
+const OX = '#38bdf8'; // oxidiser: cold
+const FU = '#fb923c'; // fuel: warm
+const WALL = 'var(--color-text-secondary)';
+const INK = 'var(--color-text-primary)';
+const WARN = '#fbbf24';
+const BAD = '#f87171';
+
+const fmt = (v: number, d = 2) => (Number.isFinite(v) ? v.toFixed(d) : '—');
+const rad = (deg: number) => (deg * Math.PI) / 180;
+
+/** Radial half-extent of an inclined orifice's elliptical trace on the face. */
+const halfMajor = (d: number, thetaDeg: number) =>
+ (0.5 * d) / Math.max(0.1, Math.abs(Math.cos(rad(thetaDeg))));
+
+export interface InjectorLayoutInput {
+ oxidizer: InjectorStream;
+ fuel: InjectorStream;
+ boreDiameter: number;
+ fuelOutboard?: boolean;
+ centerClearDiameter?: number;
+ minWeb?: number;
+ wallClearance?: number;
+ ldMin?: number;
+ ldMax?: number;
+ plateThickness?: number;
+ counterboreDiameter?: number;
+ orificeLandLOverD?: number;
+}
+
+export interface InjectorWarning {
+ level: 'warn' | 'bad';
+ text: string;
+}
+
+/**
+ * Everything the drawing asserts about the hardware, with no DOM in it.
+ *
+ * Kept separate from the SVG so it can be tested in plain node: the geometry is the part
+ * that carries engineering consequences, the SVG is presentation.
+ */
+export function deriveInjectorLayout({
+ oxidizer, fuel, boreDiameter, fuelOutboard = true,
+ centerClearDiameter = 0, minWeb = 0, wallClearance = 0,
+ ldMin = 5, ldMax = 7, plateThickness = 0.0127,
+ counterboreDiameter = 0, orificeLandLOverD = 4,
+}: InjectorLayoutInput) {
+ const n = Math.max(1, Math.round(oxidizer.n_elements));
+ const dPitchO = (n * oxidizer.spacing) / Math.PI;
+ const dPitchF = (Math.max(1, Math.round(fuel.n_elements)) * fuel.spacing) / Math.PI;
+ const dr = Math.abs(dPitchO - dPitchF) / 2;
+ const tanSum = Math.tan(rad(oxidizer.impingement_angle)) + Math.tan(rad(fuel.impingement_angle));
+ const lImp = tanSum > 1e-9 ? dr / tanSum : 0;
+ const dAvg = 0.5 * (oxidizer.d_jet + fuel.d_jet);
+
+ // Which ring is inboard is a fact about the pitch circles, not a declaration -- but when
+ // they coincide (dr = 0, a degenerate non-injector) fall back to the caller's intent.
+ const oxIsInner = dPitchO === dPitchF ? fuelOutboard : dPitchO < dPitchF;
+ const inner = oxIsInner ? oxidizer : fuel;
+ const outer = oxIsInner ? fuel : oxidizer;
+ const rInner = (oxIsInner ? dPitchO : dPitchF) / 2;
+ const rOuter = (oxIsInner ? dPitchF : dPitchO) / 2;
+ const rImp = rInner + lImp * Math.tan(rad(inner.impingement_angle));
+ const rBore = boreDiameter / 2;
+
+ // Face real estate, measured on the ELLIPTICAL traces.
+ const innerEdge = rInner - halfMajor(inner.d_jet, inner.impingement_angle);
+ const outerEdge = rOuter + halfMajor(outer.d_jet, outer.impingement_angle);
+
+ const g = {
+ n, rBore, rO: dPitchO / 2, rF: dPitchF / 2, dPitchO, dPitchF, dr, lImp,
+ lOverD: dAvg > 0 ? lImp / dAvg : 0,
+ included: oxidizer.impingement_angle + fuel.impingement_angle,
+ webO: oxidizer.spacing - oxidizer.d_jet,
+ webF: fuel.spacing - fuel.d_jet,
+ oxIsInner, inner, outer, rInner, rOuter, rImp,
+ centreClear: 2 * innerEdge,
+ wallLand: rBore - outerEdge,
+ // Fraction of the chamber cross-section inside the impingement circle.
+ coreFrac: rBore > 0 ? (rImp / rBore) ** 2 : 0,
+ overflow: outerEdge > rBore,
+ degenerate: !(lImp > 1e-6),
+ };
+
+ // Drilling: a hole at th from the axis meets the face at (90 - th); shallow entry walks a drill.
+ const drill = [
+ { tag: 'LOX', d: oxidizer.d_jet, th: oxidizer.impingement_angle, c: OX },
+ { tag: 'fuel', d: fuel.d_jet, th: fuel.impingement_angle, c: FU },
+ ].map((st) => {
+ // Total passage through the plate, along the hole axis.
+ const thru = plateThickness / Math.cos(rad(st.th));
+ // The small drill only cuts the LAND; the rest is opened out to the counterbore.
+ const land = Math.min(thru, orificeLandLOverD * st.d);
+ const bore = counterboreDiameter > st.d ? counterboreDiameter : st.d;
+ const boreLen = Math.max(0, thru - land);
+ return {
+ ...st, thru, land, bore, boreLen,
+ landLd: land / st.d, // sets Cd
+ boreLd: bore > 0 ? boreLen / bore : 0, // what the roughing drill sees
+ incidence: 90 - st.th,
+ };
+ });
+
+ const warnings: InjectorWarning[] = [];
+ if (g.overflow) {
+ warnings.push({ level: 'bad', text: 'outer ring falls outside the chamber wall — orifices would be drilled into the liner' });
+ }
+ if (g.degenerate) {
+ warnings.push({ level: 'bad', text: 'the two rings are on the same pitch circle — dr = 0, so the jets meet AT the face plate. That is a face-eroding non-injector, not a doublet.' });
+ }
+ if (centerClearDiameter > 0 && g.centreClear < centerClearDiameter) {
+ warnings.push({ level: 'bad', text: `centre clear ⌀${fmt(g.centreClear * MM)} < ⌀${fmt(centerClearDiameter * MM)} reserved` });
+ }
+ if (wallClearance > 0 && g.wallLand < wallClearance) {
+ warnings.push({ level: 'bad', text: `wall land ${fmt(g.wallLand * MM)} mm < ${fmt(wallClearance * MM)} mm required` });
+ }
+ if (minWeb > 0 && Math.min(g.webO, g.webF) < minWeb) {
+ warnings.push({ level: 'bad', text: `web ${fmt(Math.min(g.webO, g.webF) * MM)} mm < ${fmt(minWeb * MM)} mm required` });
+ }
+ if (g.included > 90) {
+ warnings.push({ level: 'warn', text: `included ${fmt(g.included, 0)}° > 90° — NASA SP-8089 face-heating threshold; copper face` });
+ }
+ if (!g.degenerate && (g.lOverD < ldMin || g.lOverD > ldMax)) {
+ warnings.push({ level: 'warn', text: `standoff L/d ${fmt(g.lOverD, 2)} outside the ${ldMin}–${ldMax} band` });
+ }
+ if (!g.degenerate && g.coreFrac < 0.25) {
+ warnings.push({ level: 'warn', text: `all ${g.n} elements collide on a ⌀${fmt(2 * g.rImp * MM)} circle — only ${fmt(g.coreFrac * 100, 0)}% of the chamber area is fed directly` });
+ }
+ // Two separate drills, two separate limits. The orifice land is short by construction;
+ // the roughing pass down to it is the one that can get deep.
+ const deepestLand = drill.reduce((a, b) => (a.landLd >= b.landLd ? a : b));
+ if (deepestLand.landLd > 10) {
+ warnings.push({ level: 'warn', text: `${deepestLand.tag} orifice land is ${fmt(deepestLand.land * MM, 1)} mm at ⌀${fmt(deepestLand.d * MM, 3)} — L/d ${fmt(deepestLand.landLd, 1)}, past twist-drill practice` });
+ }
+ const deepestBore = drill.reduce((a, b) => (a.boreLd >= b.boreLd ? a : b));
+ if (deepestBore.boreLd > 10) {
+ warnings.push({ level: 'warn', text: `${deepestBore.tag} feed passage is ${fmt(deepestBore.boreLen * MM, 1)} mm at ⌀${fmt(deepestBore.bore * MM, 2)} — L/d ${fmt(deepestBore.boreLd, 1)}; open the counterbore or use a gundrill` });
+ }
+ const shallowest = drill.reduce((a, b) => (a.incidence <= b.incidence ? a : b));
+ if (shallowest.incidence < 40) {
+ warnings.push({ level: 'warn', text: `${shallowest.tag} meets the face at ${fmt(shallowest.incidence, 0)}° — needs a spotface normal to the hole axis or the drill walks` });
+ }
+
+ return { g, drill, warnings };
+}
+
+export function InjectorPatternPlot({
+ oxidizer, fuel, boreDiameter, fuelOutboard = true,
+ centerClearDiameter = 0, minWeb = 0, wallClearance = 0,
+ ldMin = 5, ldMax = 7, plateThickness = 0.0127,
+}: Props) {
+ const { g, drill, warnings } = useMemo(
+ () => deriveInjectorLayout({
+ oxidizer, fuel, boreDiameter, fuelOutboard,
+ centerClearDiameter, minWeb, wallClearance, ldMin, ldMax, plateThickness,
+ }),
+ [oxidizer, fuel, boreDiameter, fuelOutboard, centerClearDiameter, minWeb,
+ wallClearance, ldMin, ldMax, plateThickness],
+ );
+ const degenerate = g.degenerate;
+
+ // ---- FACE VIEW -------------------------------------------------------------------
+ const FACE = 280;
+ const C = FACE / 2;
+ const fs = (C - 30) / g.rBore;
+ const px = (r: number, a: number) => C + r * fs * Math.cos(a);
+ const py = (r: number, a: number) => C + r * fs * Math.sin(a);
+
+ const pairs = Array.from({ length: g.n }, (_, i) => {
+ const a = (2 * Math.PI * i) / g.n - Math.PI / 2;
+ return { a, deg: (a * 180) / Math.PI };
+ });
+
+ /** Orifice as its true elliptical trace: major axis radial, minor circumferential. */
+ const hole = (r: number, a: number, d: number, th: number, fill: string, key: string) => (
+
+ );
+
+ // ---- SIDE SECTION ----------------------------------------------------------------
+ // The standoff is millimetres against a bore of tens of millimetres, so one scale makes
+ // the jets invisible. Independent x/y scales, labelled -- normal practice when the
+ // feature of interest is far smaller than the part.
+ const SW = 320, SH = 250;
+ const axisY = SH / 2;
+ const xSpan = Math.max(g.lImp * 2.6, 1e-4);
+ const ySpan = Math.max(g.rOuter * 1.22, 1e-4);
+ const sxS = (SW - 74) / xSpan;
+ const syS = (SH / 2 - 28) / ySpan;
+ const sx = (x: number) => 58 + x * sxS;
+ const sy = (r: number) => axisY - r * syS;
+ const meetX = sx(g.lImp);
+
+ const innerCol = g.oxIsInner ? OX : FU;
+ const outerCol = g.oxIsInner ? FU : OX;
+
+ return (
+
+ {/* ================= FACE VIEW ================= */}
+
+
+
Injector face
+ viewed from the chamber
+
+
+
+
● LOX {g.n}× ⌀{fmt(oxidizer.d_jet * MM, 3)} on ⌀{fmt(g.dPitchO * MM)} — web {fmt(g.webO * MM)} mm
+
● fuel {g.n}× ⌀{fmt(fuel.d_jet * MM, 3)} on ⌀{fmt(g.dPitchF * MM)} — web {fmt(g.webF * MM)} mm
+
centre clear ⌀{fmt(g.centreClear * MM)} · wall land {fmt(g.wallLand * MM)} mm
+ {!degenerate && (
+
spray reaches ⌀{fmt(2 * g.rImp * MM)} — {fmt(g.coreFrac * 100, 0)}% of the chamber area
+ )}
+
+
+
+ {/* ================= SIDE SECTION ================= */}
+
+
+
Section through one doublet
+ flow left → right
+
+ {degenerate ? (
+
+
+ No section to draw: the rings coincide (dr = 0), so the jets never converge.
+ Separate the pitch circles — that separation is the whole mechanism of a doublet.
+
+
+ ) : (
+
+ )}
+
+
included {fmt(g.included, 0)}° · standoff {fmt(g.lImp * MM)} mm · L/d {fmt(g.lOverD, 2)}
+
ring offset dr {fmt(g.dr * MM)} mm — the gap the jets close to meet
+ {drill.map((d) => (
+
+ ● {d.tag} {fmt(d.thru * MM, 1)} mm through the plate
+ {' '}= ⌀{fmt(d.bore * MM, 2)}×{fmt(d.boreLen * MM, 1)} + ⌀{fmt(d.d * MM, 3)}×{fmt(d.land * MM, 1)} land
+ {' '}(L/d {fmt(d.landLd, 1)}), face at {fmt(d.incidence, 0)}°
+
+ ))}
+
axial scale ×{fmt(sxS / syS, 1)} vs radial
+
+
+
+ {warnings.length > 0 && (
+
+ {warnings.map((w, i) => (
+
⚠ {w.text}
+ ))}
+
+ )}
+
+ );
+}
+
+export default InjectorPatternPlot;
diff --git a/EngineDesign/scripts/config_provenance_audit.py b/EngineDesign/scripts/config_provenance_audit.py
new file mode 100755
index 000000000..a0cef4920
--- /dev/null
+++ b/EngineDesign/scripts/config_provenance_audit.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+"""Which numbers in this config did anyone actually choose?
+
+EngineDesign configs carry ~900 fields. Most arrive by inheriting configs/default.yaml,
+which is a GENERIC template -- its feed system is 3/8" NPT at K0 2.0 with 0.305 m runs,
+which is nobody's vehicle in particular. Those values are perfectly valid config, so
+nothing in the loader, the optimizer or the UI ever flags them, and a design point can
+miss by 20%+ on O/F with every gate reporting a clean reason that points somewhere else.
+
+This prints, for a given config, every physically consequential field that is
+BIT-IDENTICAL to default.yaml -- i.e. inherited, not chosen.
+
+ python3 scripts/config_provenance_audit.py configs/mine.yaml [--all]
+
+Note the obvious limit: identical-to-default is not the same as wrong. Prandtl 0.7 is
+fine for anyone. The point is that nothing distinguishes the fields where that is true
+from the fields where it is not, so the list is where to start looking, not a defect list.
+"""
+from __future__ import annotations
+import argparse, json, sys
+from pathlib import Path
+
+import yaml
+
+ROOT = Path(__file__).resolve().parent.parent
+
+# Sections whose values change the answer rather than the presentation.
+CONSEQUENTIAL = (
+ "feed_system", "injector", "combustion.efficiency", "spray", "stability",
+ "ablative_cooling", "film_cooling", "regen_cooling", "chamber_geometry",
+)
+# Fields that have bitten a real design point. Printed first, loudly.
+KNOWN_TRAPS = {
+ "feed_system.oxidizer.line_size": "sets LOX bore; dP ~ 1/A^2, so a name is worth ~2x",
+ "feed_system.fuel.line_size": "sets fuel bore; dP ~ 1/A^2",
+ "feed_system.oxidizer.K0": "lumped loss coeff; NOT length-scaled -- must cover the whole run",
+ "feed_system.fuel.K0": "lumped loss coeff; NOT length-scaled",
+ "feed_system.oxidizer.length": "chug inertance ONLY -- does not touch dP",
+ "feed_system.fuel.length": "chug inertance ONLY -- does not touch dP",
+ "spray.evaporation.x_star_limit": "caps vaporisation length; gates L* feasibility",
+ "ablative_cooling.initial_thickness": "sets chamber bore for a given OD",
+}
+
+
+def flatten(d, pre=""):
+ out = {}
+ if isinstance(d, dict):
+ for k, v in d.items():
+ out.update(flatten(v, f"{pre}.{k}" if pre else k))
+ elif isinstance(d, list):
+ out[pre] = json.dumps(d)[:80]
+ else:
+ out[pre] = d
+ return out
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("config", help="config YAML to audit")
+ ap.add_argument("--baseline", default=str(ROOT / "configs/default.yaml"))
+ ap.add_argument("--all", action="store_true", help="every inherited field, not just consequential ones")
+ a = ap.parse_args()
+
+ cfg = flatten(yaml.safe_load(open(a.config)))
+ base = flatten(yaml.safe_load(open(a.baseline)))
+
+ inherited = [k for k in sorted(cfg)
+ if k in base and str(cfg[k]) == str(base[k]) and cfg[k] is not None]
+ if not a.all:
+ inherited = [k for k in inherited if any(k.startswith(s) for s in CONSEQUENTIAL)]
+
+ traps = [k for k in inherited if k in KNOWN_TRAPS]
+ rest = [k for k in inherited if k not in KNOWN_TRAPS]
+
+ print(f"config : {a.config}")
+ print(f"baseline : {a.baseline}")
+ print(f"\n{len(inherited)} consequential fields are bit-identical to the baseline "
+ f"(inherited, not chosen).\n")
+
+ if traps:
+ print(" These have each cost a design point before:")
+ for k in traps:
+ print(f" {k:44s} = {str(cfg[k]):<14s} {KNOWN_TRAPS[k]}")
+ print()
+ if rest:
+ print(" Also inherited:")
+ for k in rest:
+ print(f" {k:44s} = {cfg[k]}")
+ if not inherited:
+ print(" (none -- every consequential field differs from the baseline)")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/EngineDesign/scripts/design_audit.py b/EngineDesign/scripts/design_audit.py
new file mode 100644
index 000000000..b3d5b476a
--- /dev/null
+++ b/EngineDesign/scripts/design_audit.py
@@ -0,0 +1,73 @@
+"""Audit a design against EVERY limit its own config declares, with NO gate slack forgiven.
+
+ python3 scripts/design_audit.py configs/ethalox_8kN_SHIP.yaml [more.yaml ...]
+
+Layer 1's own gates carry tolerances -- the spray-tilt gate forgives
+``layer1_resultant_tilt_gate_tol_deg`` (shipped default 1.0 deg) and the O/F gate forgives
+15 %. Those exist so a search sitting on a boundary is not thrown away, and they are
+reasonable there. They are NOT reasonable in a sign-off: measured, three candidates reported
+ALL GATES PASS while exceeding their own declared tilt allowance, because 1.0 deg of slack is
+most of the margin the allowance exists to create. This re-checks every limit at face value.
+
+Exits non-zero if any design fails, so it can gate a commit.
+"""
+import sys, copy, math, yaml
+sys.path.insert(0, str(__import__('pathlib').Path(__file__).resolve().parents[1]))
+from engine.pipeline.io import load_config
+from engine.core.runner import PintleEngineRunner
+from engine.optimizer.layers.layer1_static_optimization import (
+ _impinging_resultant_tilt_deg, _resultant_tilt_breakeven_deg,
+ _resolve_tilt_allowance_deg, _impinging_face_infeasibility_terms)
+
+def audit(f):
+ c = load_config(f); Y = yaml.safe_load(open(f))
+ g = Y['injector']['geometry']; cg = Y['chamber_geometry']; rq = Y['design_requirements']
+ run = PintleEngineRunner(copy.deepcopy(c))
+ r = run.evaluate(c.lox_tank.initial_pressure_psi*6894.757,
+ c.fuel_tank.initial_pressure_psi*6894.757, silent=True)
+ O, F = g['oxidizer'], g['fuel']; n = int(O['n_elements'])
+ bore = cg['chamber_diameter']; Lch = cg['length_cylindrical']+cg['length_contraction']
+ tilt = _impinging_resultant_tilt_deg(r['mdot_O'], r['mdot_F'], 1140.0, 789.0, n,
+ O['d_jet'], F['d_jet'], O['impingement_angle'], F['impingement_angle'])
+ be = _resultant_tilt_breakeven_deg(n_elements=n, spacing_O_m=O['spacing'],
+ spacing_F_m=F['spacing'], angle_O_deg=O['impingement_angle'],
+ angle_F_deg=F['impingement_angle'], D_chamber_inner_m=bore, L_chamber_m=Lch)
+ allow = _resolve_tilt_allowance_deg(from_reach=True, constant_deg=0.0,
+ breakeven_deg=be, margin=float(rq.get('layer1_resultant_tilt_reach_margin') or 1.5))
+ face = _impinging_face_infeasibility_terms(n_elements=float(n),
+ spacing_O_m=O['spacing'], spacing_F_m=F['spacing'], d_jet_O_m=O['d_jet'],
+ d_jet_F_m=F['d_jet'], D_chamber_inner_m=bore,
+ angle_O_deg=O['impingement_angle'], angle_F_deg=F['impingement_angle'],
+ center_clear_dia_m=rq.get('layer1_injector_center_clear_dia_m') or 0.0,
+ min_web_m=rq.get('layer1_injector_min_web_m') or 0.0,
+ wall_clearance_m=rq.get('layer1_injector_wall_clearance_m') or 0.0,
+ spray_radius_frac=rq.get('layer1_injector_spray_radius_frac') or 0.0,
+ spray_radius_tol=rq.get('layer1_injector_spray_radius_tol') or 0.08)
+ PO = c.lox_tank.initial_pressure_psi*6894.757; PF = c.fuel_tank.initial_pressure_psi*6894.757
+ dpo, dpf = (PO-r['Pc'])/r['Pc'], (PF-r['Pc'])/r['Pc']
+ checks = [
+ ("thrust 8000 +/-2%", abs(r['F']-8000)/8000 <= 0.02, f"{r['F']:.1f} N"),
+ ("O/F 1.65 +/-5%", abs(r['MR']-1.65)/1.65 <= 0.05, f"{r['MR']:.4f}"),
+ ("dP/Pc O in band", 0.20 <= dpo <= 0.40, f"{dpo:.3f}"),
+ ("dP/Pc F in band", 0.20 <= dpf <= 0.40, f"{dpf:.3f}"),
+ ("n <= 30", n <= 30, f"{n}"),
+ ("included <= 90", O['impingement_angle']+F['impingement_angle'] <= 90.0,
+ f"{O['impingement_angle']+F['impingement_angle']:.0f} deg"),
+ ("jet >= 40 deg", min(O['impingement_angle'],F['impingement_angle']) >= 40.0,
+ f"{O['impingement_angle']:.0f}/{F['impingement_angle']:.0f}"),
+ ("incidence >= 40 deg",90-max(O['impingement_angle'],F['impingement_angle']) >= 40.0,
+ f"{90-max(O['impingement_angle'],F['impingement_angle']):.0f} deg"),
+ ("face limits all met", face <= 1e-12, f"{face:.2e}"),
+ ("tilt <= allowed (NO slack)", tilt <= allow, f"{tilt:+.3f} vs {allow:.3f}"),
+ ]
+ bad = [c for c in checks if not c[1]]
+ print(f"{f.split('/')[-1]:34s} n={n:2d} Isp {r['Isp']:.2f} O/F {r['MR']:.4f} "
+ f"{'CLEAN' if not bad else 'FAILS: ' + ', '.join(c[0] for c in bad)}")
+ for name, ok, val in checks:
+ if not ok: print(f" {name:30s} {val}")
+ return not bad
+
+
+if __name__ == '__main__':
+ ok = all([audit(f) for f in sys.argv[1:]])
+ sys.exit(0 if ok else 1)
diff --git a/EngineDesign/scripts/design_robustness.py b/EngineDesign/scripts/design_robustness.py
new file mode 100644
index 000000000..7be2c23b3
--- /dev/null
+++ b/EngineDesign/scripts/design_robustness.py
@@ -0,0 +1,96 @@
+"""Does a design survive the things the model is NOT sure about?
+
+ python3 scripts/design_robustness.py configs/.yaml
+
+A converged run tells you the design is self-consistent. It tells you nothing about how
+much of the answer rests on a number nobody measured. This sweeps the three that carry
+real uncertainty and re-solves the design AS WRITTEN at each point.
+
+Read the "Cd got" column. ``inlet_geometry`` resolves Cd inside
+``cd_inf_from_orifice_diameter`` and overrides ``Cd_inf``, so a sweep that only sets
+``Cd_inf`` silently does nothing -- measured, 0.72 / 0.76 / 0.80 all returned exactly the
+same thrust until this script cleared ``inlet_geometry`` too. Any sweep of this model must
+report the value it actually achieved, not the one it asked for.
+
+Three knobs carry real uncertainty, and none of them is settled by a converged run:
+ eta_c* -- 0.95 here vs a 0.87 published comparable for this propellant/class
+ Cd -- 0.80 is an inlet-geometry correlation, not a flow test
+ Pc -- the throat grows as the graphite recesses
+Each is swept INDEPENDENTLY through its plausible range and the design re-solved as written.
+"""
+import sys, copy, math, json
+sys.path.insert(0, str(__import__('pathlib').Path(__file__).resolve().parents[1]))
+import yaml
+from engine.pipeline.io import load_config
+from engine.core.runner import PintleEngineRunner
+
+def solve(cfg):
+ run = PintleEngineRunner(copy.deepcopy(cfg))
+ res = run.evaluate(cfg.lox_tank.initial_pressure_psi*6894.757,
+ cfg.fuel_tank.initial_pressure_psi*6894.757, silent=True)
+ return float(res.get('Pc', float('nan'))), res
+
+def main():
+ path = sys.argv[1]
+ base = load_config(path)
+ pc0, r0 = solve(base)
+ F0 = r0.get('F') or 0.0
+ print(f"BASE Pc {pc0/6894.757:7.2f} psia F {F0:8.1f} N O/F {r0['MR']:.4f} "
+ f"eta_c* {r0['eta_cstar']:.4f} Isp {r0.get('Isp',float('nan')):.2f}")
+ print()
+ print("--- Cd swept (flow test not yet done; 0.80 is a correlation) ---")
+ print(f"{'Cd set':>7s}{'Cd got':>8s}{'Pc psia':>10s}{'thrust N':>11s}{'d(F)':>9s}{'O/F':>9s}{'dP/Pc O':>9s}{'dP/Pc F':>9s}")
+ for cd in (0.72, 0.76, 0.80, 0.84, 0.88):
+ c = copy.deepcopy(base)
+ PO = c.lox_tank.initial_pressure_psi*6894.757
+ PF = c.fuel_tank.initial_pressure_psi*6894.757
+ for side in ('fuel','oxidizer'):
+ d = c.discharge[side]
+ # inlet_geometry resolves Cd inside cd_inf_from_orifice_diameter and WINS over
+ # Cd_inf, so it has to be cleared or the sweep silently does nothing. (Measured:
+ # 0.72/0.76/0.80 all returned the same thrust until this line existed.)
+ d.inlet_geometry = None
+ d.inlet_radius_ratio = None
+ d.use_geometry_cd = False
+ d.Cd_inf = cd; d.Cd_min = cd; d.a_Re = 0.0; d.cd_inf_max = cd; d.cd_inf_min_geom = cd
+ try:
+ pc, r = solve(c)
+ dpo = (PO - pc)/pc if pc else float('nan'); dpf = (PF - pc)/pc if pc else float('nan')
+ got = r.get('Cd_O', float('nan'))
+ flag = "" if abs(got-cd) < 5e-3 else " <-- CLAMPED, sweep not honoured"
+ print(f"{cd:7.2f}{got:8.3f}{pc/6894.757:10.2f}{r.get('F',0):11.1f}"
+ f"{100*((r.get('F',0)-F0)/F0):+8.1f}%{r['MR']:9.4f}{dpo:9.3f}{dpf:9.3f}{flag}")
+ except Exception as e:
+ print(f"{cd:6.2f} FAILED: {type(e).__name__}: {str(e)[:60]}")
+ print()
+ print("--- eta_c* haircut (0.95 modelled vs 0.87 published comparable) ---")
+ print(f"{'scale':>7s}{'eta_c*':>9s}{'Pc psia':>10s}{'thrust N':>11s}{'d(F)':>9s}{'Isp':>9s}")
+ for k in (1.00, 0.97, 0.94, 0.92):
+ c = copy.deepcopy(base)
+ e = c.combustion.efficiency
+ e.Em_peak = float(e.Em_peak) * k
+ try:
+ pc, r = solve(c)
+ print(f"{k:7.2f}{r['eta_cstar']:9.4f}{pc/6894.757:10.2f}{r.get('F',0):11.1f}"
+ f"{100*((r.get('F',0)-F0)/F0):+8.1f}%{r.get('Isp',float('nan')):9.2f}")
+ except Exception as ex:
+ print(f"{k:7.2f} FAILED: {type(ex).__name__}: {str(ex)[:60]}")
+ print()
+ print("--- throat growth from graphite recession (mid-burn) ---")
+ print(f"{'dA/A':>7s}{'D_t mm':>9s}{'Pc psia':>10s}{'thrust N':>11s}{'d(F)':>9s}")
+ A0 = base.chamber_geometry.A_throat
+ for g in (0.0, 0.02, 0.05, 0.09):
+ c = copy.deepcopy(base)
+ # The throat erodes; the exit plane does not move. eps therefore FALLS.
+ c.chamber_geometry.A_throat = A0*(1+g)
+ c.chamber_geometry.expansion_ratio = c.chamber_geometry.A_exit/(A0*(1+g))
+ c.combustion.cea.expansion_ratio = c.chamber_geometry.expansion_ratio
+ try:
+ pc, r = solve(c)
+ print(f"{g:6.0%}{math.sqrt(4*A0*(1+g)/math.pi)*1000:9.2f}{pc/6894.757:10.2f}"
+ f"{r.get('F',0):11.1f}{100*((r.get('F',0)-F0)/F0):+8.1f}%")
+ except Exception as ex:
+ print(f"{g:6.0%} FAILED: {type(ex).__name__}: {str(ex)[:60]}")
+
+if __name__ == '__main__':
+ main()
diff --git a/EngineDesign/scripts/feed_line_K.py b/EngineDesign/scripts/feed_line_K.py
new file mode 100755
index 000000000..93fc8b3e2
--- /dev/null
+++ b/EngineDesign/scripts/feed_line_K.py
@@ -0,0 +1,165 @@
+#!/usr/bin/env python3
+"""Compute EngineDesign's lumped feed ``K0`` from an actual component list.
+
+EngineDesign's feed model is a single lumped coefficient per side:
+
+ dp = K0 * 0.5 * rho * v^2, v = mdot / (rho * A_hydraulic)
+
+``docs/integration/line-loss-plan.md`` rates that "estimated K" -- rung 4 of 5, a
+guess. Until the P&ID path lands, this script is rung 3: itemise the run, take each
+K from a correlation rather than memory, and refer every one to a single reference
+bore so the sum is a valid K0 for that A_hydraulic.
+
+Reference-area conversion. dp is one number, so for two bores
+ K_ref = K_local * (A_ref / A_local)^2
+because v ~ 1/A. Losses at a WIDE section shrink when expressed against the high
+velocity of a NARROW reference bore. Getting this backwards is the classic error.
+
+Friction factors come from ``fluids`` (Colebrook); fitting K's are Crane TP-410 /
+Idelchik forms, each labelled at its call site. Run:
+
+ python3 scripts/feed_line_K.py
+"""
+from __future__ import annotations
+
+import math
+from fluids.friction import friction_factor
+
+IN = 0.0254
+PSI = 6894.757
+ROUGHNESS_M = 1.5e-6 # drawn stainless tube, Crane TP-410 Table A-23
+
+def area(d: float) -> float:
+ return math.pi / 4.0 * d * d
+
+def tube_id(od_in: float, wall_in: float = 0.035) -> float:
+ return (od_in - 2.0 * wall_in) * IN
+
+class Run:
+ """One feed run, itemised. All K's accumulate against ``d_ref``."""
+
+ def __init__(self, name: str, d_ref: float, mdot: float, rho: float, mu: float):
+ self.name, self.d_ref, self.mdot, self.rho, self.mu = name, d_ref, mdot, rho, mu
+ self.A_ref = area(d_ref)
+ self.v_ref = mdot / (rho * self.A_ref)
+ self.rows: list[tuple[str, float, str]] = []
+
+ def _refer(self, K_local: float, d_local: float) -> float:
+ return K_local * (self.A_ref / area(d_local)) ** 2
+
+ def add(self, label: str, K_local: float, d_local: float, note: str) -> None:
+ self.rows.append((label, self._refer(K_local, d_local), note))
+
+ def entrance(self, d: float) -> None:
+ # Sharp-edged entrance from a vessel, Crane TP-410 A-29.
+ self.add("tank exit -> line (sharp entrance)", 0.5, d, "Crane A-29, K=0.5")
+
+ def ball_valve_full_bore(self, d: float, f_T: float) -> None:
+ # Crane TP-410: full-bore ball valve, fully open, K = 3 f_T.
+ self.add(f"full-bore ball valve {d/IN:.3f}\" bore", 3.0 * f_T, d,
+ f"Crane TP-410, K=3*f_T, f_T={f_T}")
+
+ def contraction(self, d_big: float, d_small: float) -> None:
+ # Sudden contraction, referenced to the SMALL (downstream) velocity.
+ beta2 = (d_small / d_big) ** 2
+ self.add(f"contraction {d_big/IN:.3f}\" -> {d_small/IN:.3f}\"",
+ 0.5 * (1.0 - beta2), d_small, "K=0.5(1-beta^2), at small-bore v")
+
+ def expansion(self, d_small: float, d_big: float) -> None:
+ # Sudden expansion, referenced to the SMALL (upstream) velocity.
+ beta2 = (d_small / d_big) ** 2
+ self.add(f"expansion {d_small/IN:.3f}\" -> {d_big/IN:.3f}\"",
+ (1.0 - beta2) ** 2, d_small, "K=(1-beta^2)^2, at small-bore v")
+
+ def straight(self, d: float, L: float) -> None:
+ v = self.mdot / (self.rho * area(d))
+ Re = self.rho * v * d / self.mu
+ f = friction_factor(Re=Re, eD=ROUGHNESS_M / d)
+ self.add(f"straight tube {d/IN:.3f}\" ID x {L/IN:.1f}\"", f * L / d, d,
+ f"Darcy f*L/D, Re={Re:.3e}, f={f:.4f}")
+
+ def report(self) -> float:
+ K0 = sum(k for _, k, _ in self.rows)
+ q = 0.5 * self.rho * self.v_ref ** 2
+ print(f"\n{self.name}")
+ print(f" reference bore {self.d_ref*1000:.3f} mm mdot {self.mdot:.3f} kg/s"
+ f" v_ref {self.v_ref:.1f} m/s q {q/PSI:.1f} psi")
+ print(f" {'component':44s} {'K@ref':>8s} source")
+ print(" " + "-" * 92)
+ for label, k, note in self.rows:
+ print(f" {label:44s} {k:8.4f} {note}")
+ print(" " + "-" * 92)
+ print(f" {'K0 (use this for A_hydraulic at the reference bore)':44s} {K0:8.4f}")
+ print(f" => dp_line = {K0 * q / PSI:.1f} psi")
+ return K0
+
+
+def main() -> None:
+ # Design point the runs must carry.
+ F, Isp, OF, Pc_psi = 8000.0, 237.0, 1.65, 430.0
+ mdot = F / (Isp * 9.80665)
+ mdot_O, mdot_F = mdot * OF / (1 + OF), mdot / (1 + OF)
+
+ d_half_npt = 0.5 * IN # 1/2" NPT through-bore and full-bore ball valve bore
+ d_half_tube = tube_id(0.500) # 1/2" Swagelok tube, 0.035" wall -> 0.430" ID
+ d_38_tube = tube_id(0.375) # 3/8" Swagelok tube, 0.035" wall -> 0.305" ID
+
+ print("=" * 96)
+ print("CalSTAR ethalox flight feed runs -- K0 from the as-built component list (operator, 2026-09-13)")
+ print(f"design point: F {F:.0f} N, O/F {OF}, Pc {Pc_psi:.0f} psia"
+ f" -> mdot_O {mdot_O:.3f}, mdot_F {mdot_F:.3f} kg/s")
+ print("=" * 96)
+
+ # --- LOX: 1/2" NPT -> 1/2" full-flow ball valve -> 1/2" NPT-to-Swage -> 4" of 1/2"
+ # tube -> 1/2" Swage-to-NPT -> injector. No bends.
+ lox = Run("OXIDISER (reference: 1/2\" tube ID)", d_half_tube, mdot_O, 1140.0, 1.8e-4)
+ lox.entrance(d_half_npt)
+ lox.ball_valve_full_bore(d_half_npt, f_T=0.027)
+ lox.contraction(d_half_npt, d_half_tube)
+ lox.straight(d_half_tube, 4.0 * IN)
+ lox.expansion(d_half_tube, d_half_npt)
+ K_O = lox.report()
+
+ # --- FUEL: 1/2" NPT full-flow ball valve -> 1/2"-to-3/8" Swage -> ~3 ft of 3/8"
+ # tube (straight) -> 3/8" Swage-to-1/2" NPT -> injector.
+ fu = Run("FUEL (reference: 3/8\" tube ID)", d_38_tube, mdot_F, 789.0, 1.2e-3)
+ fu.entrance(d_half_npt)
+ fu.ball_valve_full_bore(d_half_npt, f_T=0.027)
+ fu.contraction(d_half_npt, d_38_tube)
+ fu.straight(d_38_tube, 36.0 * IN)
+ fu.expansion(d_38_tube, d_half_npt)
+ K_F = fu.report()
+
+ # --- Does the design point close on a 600 psi tank?
+ print("\n" + "=" * 96)
+ print("PRESSURE BUDGET at a 600 psi tank")
+ print("=" * 96)
+ for tag, run, K in (("LOX", lox, K_O), ("FUEL", fu, K_F)):
+ dp_line = K * 0.5 * run.rho * run.v_ref ** 2 / PSI
+ for frac in (0.20, 0.25):
+ need = Pc_psi + dp_line + frac * Pc_psi
+ mark = "OK" if need <= 600 else f"SHORT {need-600:.0f} psi"
+ print(f" {tag:4s} dP/Pc {frac:.2f}: {Pc_psi:.0f} Pc + {dp_line:5.1f} line"
+ f" + {frac*Pc_psi:5.1f} inj = {need:5.1f} psi {mark}")
+
+ # --- What a 1/2" fuel run would buy, since the 3/8" one is the binding side.
+ print("\n" + "=" * 96)
+ print("IF THE FUEL RUN WERE 1/2\" TUBE INSTEAD (same layout, 3 ft straight)")
+ print("=" * 96)
+ alt = Run("FUEL alt (reference: 1/2\" tube ID)", d_half_tube, mdot_F, 789.0, 1.2e-3)
+ alt.entrance(d_half_npt)
+ alt.ball_valve_full_bore(d_half_npt, f_T=0.027)
+ alt.contraction(d_half_npt, d_half_tube)
+ alt.straight(d_half_tube, 36.0 * IN)
+ alt.expansion(d_half_tube, d_half_npt)
+ K_alt = alt.report()
+ dp_alt = K_alt * 0.5 * alt.rho * alt.v_ref ** 2 / PSI
+ for frac in (0.20, 0.25, 0.30):
+ need = Pc_psi + dp_alt + frac * Pc_psi
+ mark = "OK" if need <= 600 else f"SHORT {need-600:.0f} psi"
+ print(f" FUEL dP/Pc {frac:.2f}: {Pc_psi:.0f} + {dp_alt:5.1f} + {frac*Pc_psi:5.1f}"
+ f" = {need:5.1f} psi {mark}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/EngineDesign/scripts/layer1_run.py b/EngineDesign/scripts/layer1_run.py
new file mode 100644
index 000000000..0f52f140f
--- /dev/null
+++ b/EngineDesign/scripts/layer1_run.py
@@ -0,0 +1,62 @@
+"""Run Layer 1 on a config and write both the summary and the optimised config.
+
+ python3 scripts/layer1_run.py --config configs/ethalox_8kN_SHIP.yaml --out /tmp/run.json
+
+Writes (summary JSON) and (the design itself -- that file is
+the artifact, the summary is not). Pin layer1_random_seed in the config for a reproducible
+result; Layer 1 has two recurring basins and an unseeded run can land in either.
+"""
+import sys, copy, json, argparse
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+from engine.pipeline.io import load_config
+from engine.core.runner import PintleEngineRunner
+from engine.optimizer.layers.layer1_static_optimization import run_layer1_optimization
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument('--config', required=True)
+ ap.add_argument('--out', default='', help='summary JSON path; default alongside the config')
+ ap.add_argument('--label', default='')
+ a = ap.parse_args()
+ if not a.out:
+ a.out = str(Path(a.config).with_suffix('.layer1.json'))
+ cfg = load_config(a.config)
+ req = cfg.design_requirements.model_dump()
+ runner = PintleEngineRunner(copy.deepcopy(cfg))
+ pcfg = {"mode": "optimizer_controlled",
+ "max_lox_pressure_psi": float(req["max_lox_tank_pressure_psi"]),
+ "max_fuel_pressure_psi": float(req["max_fuel_tank_pressure_psi"])}
+ opt_cfg, results = run_layer1_optimization(
+ config_obj=copy.deepcopy(cfg), runner=runner, requirements=req,
+ target_burn_time=float(req.get("target_burn_time", 6.0)),
+ tolerances={"thrust": 0.10, "apogee": 0.15}, pressure_config=pcfg)
+ perf = results.get("performance") or {}
+ ci = results.get("convergence_info") or {}
+ out = {
+ "label": a.label, "config": a.config,
+ "failure_reasons": list(perf.get("failure_reasons") or []),
+ "gates": {k: v for k, v in perf.items() if k.endswith("_check_passed") or k.endswith("_gate_passed")},
+ "MR": perf.get("MR"), "F": perf.get("F"), "Pc_psi": (perf.get("Pc") or 0)/6894.757,
+ "Isp": perf.get("Isp"), "Lstar": perf.get("layer1_geometry_Lstar_config_m"),
+ "CR": perf.get("layer1_geometry_Ac_over_At"), "eta_cstar": perf.get("eta_cstar"),
+ "R": perf.get("momentum_ratio_R"), "n_F": perf.get("momentum_ratio_n_elements_F"),
+ "n_O": perf.get("momentum_ratio_n_elements_O"),
+ "imp_angle": perf.get("impingement_angle_deg_effective"),
+ "smd_um": perf.get("effective_smd_microns"),
+ "D_over_Dt": (ci.get("best_objective_breakdown") or {}).get("chamber_D_over_Dt"),
+ "best_objective": ci.get("best_objective"),
+ "breakdown": ci.get("best_objective_breakdown"),
+ }
+ # emit the optimised config too -- this is the artifact, not the summary.
+ # No try/except: if this cannot be written I want to know why, loudly.
+ import yaml as _yaml
+ from backend.routers.config import config_to_dict
+ _yaml.safe_dump(config_to_dict(opt_cfg),
+ open(a.out.replace('.json', '_config.yaml'), 'w'),
+ sort_keys=False, default_flow_style=False)
+ json.dump(out, open(a.out, 'w'), indent=1, default=str)
+ print("RESULT " + json.dumps({k: v for k, v in out.items() if k != "breakdown"}, default=str))
+
+if __name__ == '__main__':
+ main()
diff --git a/EngineDesign/tests/test_injector_face_real_estate.py b/EngineDesign/tests/test_injector_face_real_estate.py
new file mode 100644
index 000000000..8b246bf00
--- /dev/null
+++ b/EngineDesign/tests/test_injector_face_real_estate.py
@@ -0,0 +1,463 @@
+"""The injector face has finite real estate, and the optimizer must respect it.
+
+Until these constraints existed, WHERE the doublet ring pair sat radially was an exactly
+flat direction in the Layer-1 objective: ``_layer1_derive_fuel_spacing`` solves the fuel
+pitch so the standoff hits its target, and it does so by fixing the ring GAP, so ``dr`` is
+independent of ``s_O`` and the pair slides radially at zero cost. ``s_O`` fell onto its own
+lower bound (0.003 m).
+
+Measured on configs/ethalox_8kN_FINAL.yaml before the fix:
+
+ D_pitch_O 26.90 mm (28 holes at a 3.018 mm pitch -- a 1.44 mm web)
+ D_pitch_F 88.01 mm
+ impingement 41.79 mm circle, inside a 127.00 mm bore and NARROWER THAN THE
+ 49.57 mm THROAT -- 10.8 % of the chamber area was fed directly
+ centre 24.83 mm clear, against 27.15 mm needed for a 3/8-18 NPT igniter boss
+
+None of that violated a single constraint, because none of those constraints existed.
+"""
+import math
+import numpy as np
+import pytest
+
+from engine.optimizer.layers.layer1_static_optimization import (
+ _impinging_ring_geometry_squared,
+ _impinging_hard_geometry_blocks_eval,
+)
+
+# The shipped FINAL design, in SI.
+FINAL = dict(
+ n_elements=28.0,
+ spacing_O_m=0.0030182972100858507,
+ spacing_F_m=0.009875029755263455,
+ d_jet_O_m=0.0015821116699998301,
+ d_jet_F_m=0.001375132476863662,
+ D_chamber_inner_m=0.127,
+ angle_O_deg=40.0,
+ angle_F_deg=69.0,
+)
+IGNITER_BOSS_M = 0.0280 # 3/8-18 NPT: 17.15 mm thread crest + ~5 mm wall each side
+
+
+def _dp(n, s):
+ return n * s / math.pi
+
+
+# ---------------------------------------------------------------------------------------
+# Centre clearance
+# ---------------------------------------------------------------------------------------
+
+def test_shipped_design_has_no_room_for_the_igniter():
+ """Documents the defect: the FINAL face cannot take a 3/8 NPT boss."""
+ half_major = 0.5 * FINAL["d_jet_O_m"] / math.cos(math.radians(FINAL["angle_O_deg"]))
+ inner_edge = 0.5 * _dp(FINAL["n_elements"], FINAL["spacing_O_m"]) - half_major
+ assert 2 * inner_edge < IGNITER_BOSS_M, "fixture stale: FINAL now clears the boss"
+
+
+def test_centre_clearance_penalises_the_shipped_design():
+ free = _impinging_ring_geometry_squared(**FINAL, center_clear_dia_m=0.0)
+ held = _impinging_ring_geometry_squared(**FINAL, center_clear_dia_m=IGNITER_BOSS_M)
+ assert held > free, "reserving the centre circle must cost the shipped layout something"
+
+
+def test_centre_clearance_is_free_once_the_ring_moves_out():
+ """Same design, LOX ring opened to a 60 mm pitch circle: the term must go to zero."""
+ moved = dict(FINAL)
+ moved["spacing_O_m"] = math.pi * 0.060 / FINAL["n_elements"]
+ moved["spacing_F_m"] = moved["spacing_O_m"] + (
+ FINAL["spacing_F_m"] - FINAL["spacing_O_m"]) # same ring gap => same standoff
+ base = _impinging_ring_geometry_squared(**moved, center_clear_dia_m=0.0)
+ held = _impinging_ring_geometry_squared(**moved, center_clear_dia_m=IGNITER_BOSS_M)
+ assert held == pytest.approx(base), "a ring well clear of the boss must pay nothing"
+
+
+def test_centre_clearance_hard_blocks_the_shipped_design():
+ kw = dict(
+ d_jet_O=FINAL["d_jet_O_m"], d_jet_F=FINAL["d_jet_F_m"],
+ sp_O=FINAL["spacing_O_m"], sp_F=FINAL["spacing_F_m"],
+ D_chamber_inner=FINAL["D_chamber_inner_m"],
+ D_throat_check=0.0496, A_chamber_check=0.012668, A_throat_check=0.00193,
+ n_elements=FINAL["n_elements"],
+ angle_O_deg=FINAL["angle_O_deg"], angle_F_deg=FINAL["angle_F_deg"],
+ )
+ assert _impinging_hard_geometry_blocks_eval(**kw) is False, "FINAL was accepted before"
+ assert _impinging_hard_geometry_blocks_eval(**kw, center_clear_dia_m=IGNITER_BOSS_M) is True
+
+
+# ---------------------------------------------------------------------------------------
+# The elliptical face trace
+# ---------------------------------------------------------------------------------------
+
+def test_radial_extent_uses_the_ellipse_not_the_drill_diameter():
+ """A hole inclined theta from the axis prints as an ellipse of major axis d/cos(theta).
+
+ At 69 deg that is 2.79x the drill diameter. Using d would under-state the radial reach
+ of every orifice, which is exactly the number the clearance terms are made of.
+ """
+ d, bore = 0.001375, 0.127
+ n, sp = 28.0, math.pi * 0.030 / 28.0 # 30 mm pitch circle, fuel INBOARD
+ kw = dict(n_elements=n, spacing_O_m=math.pi * 0.090 / n, spacing_F_m=sp,
+ d_jet_O_m=0.0015821, d_jet_F_m=d, D_chamber_inner_m=bore,
+ angle_O_deg=40.0, ring_order_fuel_outboard=False)
+ # Clear circle sized to sit exactly between the round diameter and the ellipse.
+ edge_round = 0.5 * 0.030 - 0.5 * d
+ edge_ellipse = 0.5 * 0.030 - 0.5 * d / math.cos(math.radians(69.0))
+ clear = 2 * 0.5 * (edge_round + edge_ellipse)
+ assert edge_ellipse < 0.5 * clear < edge_round
+ # Isolate the clearance term: theta_F also moves tan_sum, hence L_imp, hence the standoff
+ # term, which is ~1e5 larger. Difference the same angle against its own zero-clearance run.
+ def _clearance_only(theta_F_deg):
+ with_c = _impinging_ring_geometry_squared(
+ **kw, angle_F_deg=theta_F_deg, center_clear_dia_m=clear)
+ without = _impinging_ring_geometry_squared(
+ **kw, angle_F_deg=theta_F_deg, center_clear_dia_m=0.0)
+ return with_c - without
+
+ steep, shallow = _clearance_only(69.0), _clearance_only(5.0)
+ assert shallow == pytest.approx(0.0, abs=1e-15), (
+ "a nearly-axial hole prints ~round, so its edge clears the circle and it pays nothing"
+ )
+ assert steep > 0.0, (
+ "a 69 deg hole reaches 2.79x further radially than its drill diameter; the clearance "
+ "term must see that"
+ )
+
+
+# ---------------------------------------------------------------------------------------
+# Web
+# ---------------------------------------------------------------------------------------
+
+def test_web_floor_catches_a_land_thinner_than_asked():
+ """spacing >= d_jet alone permits a web of exactly zero."""
+ web = FINAL["spacing_O_m"] - FINAL["d_jet_O_m"]
+ assert web == pytest.approx(0.001436, abs=1e-5), "fixture stale"
+ assert _impinging_ring_geometry_squared(**FINAL, min_web_m=0.001) == pytest.approx(
+ _impinging_ring_geometry_squared(**FINAL, min_web_m=0.0)), "1.0 mm floor is met"
+ assert _impinging_ring_geometry_squared(**FINAL, min_web_m=0.002) > \
+ _impinging_ring_geometry_squared(**FINAL, min_web_m=0.0), "2.0 mm floor is not"
+
+
+def test_web_floor_hard_blocks():
+ kw = dict(
+ d_jet_O=FINAL["d_jet_O_m"], d_jet_F=FINAL["d_jet_F_m"],
+ sp_O=FINAL["spacing_O_m"], sp_F=FINAL["spacing_F_m"],
+ D_chamber_inner=FINAL["D_chamber_inner_m"],
+ D_throat_check=0.0496, A_chamber_check=0.012668, A_throat_check=0.00193,
+ n_elements=FINAL["n_elements"],
+ angle_O_deg=FINAL["angle_O_deg"], angle_F_deg=FINAL["angle_F_deg"],
+ )
+ assert _impinging_hard_geometry_blocks_eval(**kw, min_web_m=0.001) is False
+ assert _impinging_hard_geometry_blocks_eval(**kw, min_web_m=0.002) is True
+
+
+# ---------------------------------------------------------------------------------------
+# Wall clearance
+# ---------------------------------------------------------------------------------------
+
+def test_wall_clearance_costs_a_ring_crowding_the_bore():
+ crowd = dict(FINAL)
+ crowd["spacing_F_m"] = math.pi * 0.120 / FINAL["n_elements"] # 120 mm ring in a 127 bore
+ assert _impinging_ring_geometry_squared(**crowd, wall_clearance_m=0.008) > \
+ _impinging_ring_geometry_squared(**crowd, wall_clearance_m=0.0)
+ # 88 mm ring in a 127 bore has 17.6 mm of land -- 8 mm must be free
+ assert _impinging_ring_geometry_squared(**FINAL, wall_clearance_m=0.008) == pytest.approx(
+ _impinging_ring_geometry_squared(**FINAL, wall_clearance_m=0.0))
+
+
+# ---------------------------------------------------------------------------------------
+# Defaults must not move
+# ---------------------------------------------------------------------------------------
+
+def test_unset_keys_change_nothing():
+ """CLAUDE.md: new physics is opt-in and defaults to the previous behaviour, exactly."""
+ base = _impinging_ring_geometry_squared(**FINAL)
+ assert _impinging_ring_geometry_squared(
+ **FINAL, center_clear_dia_m=0.0, min_web_m=0.0, wall_clearance_m=0.0
+ ) == pytest.approx(base)
+ kw = dict(
+ d_jet_O=FINAL["d_jet_O_m"], d_jet_F=FINAL["d_jet_F_m"],
+ sp_O=FINAL["spacing_O_m"], sp_F=FINAL["spacing_F_m"],
+ D_chamber_inner=FINAL["D_chamber_inner_m"],
+ D_throat_check=0.0496, A_chamber_check=0.012668, A_throat_check=0.00193,
+ n_elements=FINAL["n_elements"],
+ )
+ assert _impinging_hard_geometry_blocks_eval(**kw) is False
+
+
+# ---------------------------------------------------------------------------------------
+# Machining: face incidence
+# ---------------------------------------------------------------------------------------
+
+def test_face_incidence_blocks_a_jet_the_drill_cannot_start():
+ """theta is from the AXIS, so the drill meets the face at (90 - theta).
+
+ The shipped FINAL put the fuel jet at 69 deg -- 21 deg of incidence. A twist drill
+ entering a flat that shallow walks off the spot.
+ """
+ kw = dict(
+ d_jet_O=FINAL["d_jet_O_m"], d_jet_F=FINAL["d_jet_F_m"],
+ sp_O=FINAL["spacing_O_m"], sp_F=FINAL["spacing_F_m"],
+ D_chamber_inner=FINAL["D_chamber_inner_m"],
+ D_throat_check=0.0496, A_chamber_check=0.012668, A_throat_check=0.00193,
+ n_elements=FINAL["n_elements"],
+ angle_O_deg=FINAL["angle_O_deg"], angle_F_deg=FINAL["angle_F_deg"],
+ )
+ assert _impinging_hard_geometry_blocks_eval(**kw) is False, "inert when unset"
+ assert _impinging_hard_geometry_blocks_eval(**kw, min_face_incidence_deg=20.0) is False, \
+ "21 deg of incidence clears a 20 deg floor"
+ assert _impinging_hard_geometry_blocks_eval(**kw, min_face_incidence_deg=40.0) is True, \
+ "21 deg of incidence must not clear a 40 deg floor"
+
+
+def test_face_incidence_passes_the_replacement_design():
+ """40 / 49 deg -> 50 / 41 deg of incidence, both above a 40 deg floor."""
+ assert _impinging_hard_geometry_blocks_eval(
+ d_jet_O=0.0016380, d_jet_F=0.0014330,
+ sp_O=0.0090639037305711, sp_F=0.0119080988215582,
+ D_chamber_inner=0.127, D_throat_check=0.0488,
+ A_chamber_check=0.012668, A_throat_check=0.00187,
+ n_elements=27.0, angle_O_deg=40.0, angle_F_deg=49.0,
+ center_clear_dia_m=0.0381, min_web_m=0.002, wall_clearance_m=0.008,
+ min_face_incidence_deg=40.0,
+ ) is False
+
+
+# ---------------------------------------------------------------------------------------
+# The tilt allowance must follow the geometry, not sit still while it moves
+# ---------------------------------------------------------------------------------------
+
+from engine.optimizer.layers.layer1_static_optimization import ( # noqa: E402
+ _resultant_tilt_breakeven_deg,
+ _resolve_tilt_allowance_deg,
+)
+
+SHIP = dict(n_elements=27.0, spacing_O_m=0.0090639037305711,
+ spacing_F_m=0.0119080988215582, angle_O_deg=40.0, angle_F_deg=49.0,
+ D_chamber_inner_m=0.127, L_chamber_m=0.15340)
+
+
+def test_breakeven_is_where_the_fan_arrives_at_the_throat_plane():
+ be = _resultant_tilt_breakeven_deg(**SHIP)
+ r_imp = 0.5 * 0.08821 # from the emitted design
+ assert math.degrees(math.atan2(0.0635 - r_imp, SHIP["L_chamber_m"])) == pytest.approx(be, abs=0.05)
+ # and a fan at exactly that angle lands exactly one chamber length downstream
+ assert (0.0635 - r_imp) / math.tan(math.radians(be)) == pytest.approx(SHIP["L_chamber_m"], rel=2e-3)
+
+
+def test_derived_allowance_tracks_the_impingement_radius():
+ """A ring pair further out has less room, so it must be allowed less tilt."""
+ near = _resultant_tilt_breakeven_deg(**SHIP)
+ out = dict(SHIP)
+ out["spacing_O_m"] *= 1.25 # push both rings outward
+ out["spacing_F_m"] *= 1.25
+ far = _resultant_tilt_breakeven_deg(**out)
+ assert far < near, "a ring closer to the liner must earn a smaller allowance"
+ a_near = _resolve_tilt_allowance_deg(from_reach=True, constant_deg=6.0,
+ breakeven_deg=near, margin=1.5)
+ a_far = _resolve_tilt_allowance_deg(from_reach=True, constant_deg=6.0,
+ breakeven_deg=far, margin=1.5)
+ assert a_far < a_near < near, "the margin must cut the allowance below break-even"
+
+
+def test_margin_means_chamber_lengths():
+ """margin = 1.5 => the fan reaches the liner at 1.5 chamber lengths, not 1.0."""
+ be = _resultant_tilt_breakeven_deg(**SHIP)
+ allowed = _resolve_tilt_allowance_deg(from_reach=True, constant_deg=0.0,
+ breakeven_deg=be, margin=1.5)
+ r_imp = 0.5 * 0.08821
+ reach = (0.0635 - r_imp) / math.tan(math.radians(allowed))
+ assert reach == pytest.approx(1.5 * SHIP["L_chamber_m"], rel=5e-3)
+
+
+def test_derived_mode_is_opt_in_and_falls_back_safely():
+ """CLAUDE.md: defaults to the previous behaviour, exactly."""
+ assert _resolve_tilt_allowance_deg(
+ from_reach=False, constant_deg=6.0, breakeven_deg=7.21, margin=1.5) == 6.0
+ # degenerate geometry must not silently forbid every candidate
+ for bad in (float("nan"), 0.0, -3.0):
+ assert _resolve_tilt_allowance_deg(
+ from_reach=True, constant_deg=6.0, breakeven_deg=bad, margin=1.5) == 6.0
+
+
+# ---------------------------------------------------------------------------------------
+# Where the propellant actually lands
+# ---------------------------------------------------------------------------------------
+
+EQUAL_AREA = 1.0 / math.sqrt(2.0) # splits the chamber cross-section in half
+
+
+def _r_imp(kw):
+ n = kw["n_elements"]
+ dpo, dpf = n * kw["spacing_O_m"] / math.pi, n * kw["spacing_F_m"] / math.pi
+ tan_sum = math.tan(math.radians(kw["angle_O_deg"])) + math.tan(math.radians(kw["angle_F_deg"]))
+ L = 0.5 * abs(dpo - dpf) / tan_sum
+ th_in = kw["angle_O_deg"] if dpo <= dpf else kw["angle_F_deg"]
+ return 0.5 * min(dpo, dpf) + L * math.tan(math.radians(th_in))
+
+
+def test_the_shipped_bug_is_a_third_of_the_way_out():
+ """FINAL put every element on a circle at 0.33 of the bore radius."""
+ assert _r_imp(FINAL) / (0.5 * FINAL["D_chamber_inner_m"]) == pytest.approx(0.329, abs=0.005)
+
+
+def test_spray_radius_term_is_inert_unless_asked():
+ base = _impinging_ring_geometry_squared(**FINAL)
+ assert _impinging_ring_geometry_squared(**FINAL, spray_radius_frac=0.0) == pytest.approx(base)
+
+
+def test_spray_radius_penalises_a_core_jet():
+ """0.33 of the radius is 10.8 % of the area -- it must cost something against 0.707."""
+ free = _impinging_ring_geometry_squared(**FINAL)
+ held = _impinging_ring_geometry_squared(**FINAL, spray_radius_frac=EQUAL_AREA)
+ assert held > free
+
+
+def test_spray_radius_is_free_inside_the_band():
+ """A ring pair at the equal-area radius must pay exactly nothing."""
+ kw = dict(FINAL)
+ # slide BOTH rings out together: same gap, same standoff, same L/d -- only the radius moves
+ target_r = EQUAL_AREA * 0.5 * FINAL["D_chamber_inner_m"]
+ shift = target_r - _r_imp(FINAL)
+ d_spacing = 2.0 * shift * math.pi / FINAL["n_elements"]
+ kw["spacing_O_m"] = FINAL["spacing_O_m"] + d_spacing
+ kw["spacing_F_m"] = FINAL["spacing_F_m"] + d_spacing
+ assert _r_imp(kw) / (0.5 * kw["D_chamber_inner_m"]) == pytest.approx(EQUAL_AREA, abs=1e-6)
+ base = _impinging_ring_geometry_squared(**kw)
+ held = _impinging_ring_geometry_squared(**kw, spray_radius_frac=EQUAL_AREA)
+ assert held == pytest.approx(base), "on target must be free"
+
+
+def test_spray_radius_band_has_width():
+ """Just outside the band costs; just inside does not."""
+ def at(frac_target, tol):
+ return _impinging_ring_geometry_squared(
+ **FINAL, spray_radius_frac=frac_target, spray_radius_tol=tol)
+ base = _impinging_ring_geometry_squared(**FINAL)
+ actual = _r_imp(FINAL) / (0.5 * FINAL["D_chamber_inner_m"]) # 0.329
+ assert at(actual + 0.05, 0.08) == pytest.approx(base), "inside the band is free"
+ assert at(actual + 0.20, 0.08) > base, "outside the band is not"
+
+
+def test_spray_radius_is_hard_not_merely_priced():
+ """The soft term cost 1.5 points against an objective of ~2690, so every seed bought a
+ spray circle outside its own declared band. Hard, like ring fit."""
+ kw = dict(
+ d_jet_O=FINAL["d_jet_O_m"], d_jet_F=FINAL["d_jet_F_m"],
+ sp_O=FINAL["spacing_O_m"], sp_F=FINAL["spacing_F_m"],
+ D_chamber_inner=FINAL["D_chamber_inner_m"],
+ D_throat_check=0.0496, A_chamber_check=0.012668, A_throat_check=0.00193,
+ n_elements=FINAL["n_elements"],
+ angle_O_deg=FINAL["angle_O_deg"], angle_F_deg=FINAL["angle_F_deg"],
+ )
+ assert _impinging_hard_geometry_blocks_eval(**kw) is False, "inert when unset"
+ # FINAL sits at 0.329 of the bore radius; the equal-area target is 0.707
+ assert _impinging_hard_geometry_blocks_eval(
+ **kw, spray_radius_frac=EQUAL_AREA, spray_radius_tol=0.08) is True
+ # a band wide enough to contain it must let it through
+ assert _impinging_hard_geometry_blocks_eval(
+ **kw, spray_radius_frac=EQUAL_AREA, spray_radius_tol=0.40) is False
+
+
+def test_both_optimizer_paths_enforce_the_same_face_limits():
+ """A limit only one path applies is not a limit.
+
+ The serial loop's _impinging_hard_geometry_blocks_eval never ran in the parallel CMA
+ workers, where essentially every candidate is scored. Two of three seeds converged
+ outside their own declared spray-radius band and reported ALL GATES PASS.
+ """
+ import inspect
+ from engine.optimizer.layers import layer1_static_optimization as L1
+ src = inspect.getsource(L1)
+ worker = src[src.index("def _compute_objective_value"):]
+ worker = worker[:worker.index("\ndef ", 10)]
+ assert "_impinging_face_infeasibility_terms(" in worker, (
+ "_compute_objective_value does not apply the face limits; the parallel workers "
+ "would score a violating candidate as feasible"
+ )
+ for key in ("layer1_injector_spray_radius_frac", "layer1_injector_center_clear_dia_m",
+ "layer1_injector_wall_clearance_m", "layer1_injector_min_web_m"):
+ assert key in worker, f"worker path never reads {key}"
+
+
+def test_face_infeasibility_is_graded_not_binary():
+ """A binary block puts the whole violating region on one flat 1e6 plateau.
+
+ Measured: with `infeasibility_score += 1.0`, 2 of 3 seeds never found the feasible set
+ at all (converged O/F 2.09 and 25.6 against a 1.65 target). The term must carry a
+ gradient pointing back toward the band.
+ """
+ from engine.optimizer.layers.layer1_static_optimization import (
+ _impinging_face_infeasibility_terms,
+ )
+ base = dict(
+ n_elements=FINAL["n_elements"], spacing_O_m=FINAL["spacing_O_m"],
+ spacing_F_m=FINAL["spacing_F_m"], d_jet_O_m=FINAL["d_jet_O_m"],
+ d_jet_F_m=FINAL["d_jet_F_m"], D_chamber_inner_m=FINAL["D_chamber_inner_m"],
+ angle_O_deg=FINAL["angle_O_deg"], angle_F_deg=FINAL["angle_F_deg"],
+ )
+ assert _impinging_face_infeasibility_terms(**base) == 0.0, "inert when nothing is declared"
+
+ # Walk the ring pair outward toward the target. Declare ONLY the spray band, so the
+ # monotonicity claim is about that term and is not confounded by the wall-clearance term
+ # taking over once the outer ring runs out of chamber (which it correctly does).
+ scores = []
+ for mult in (1.0, 1.4, 1.8, 2.2):
+ kw = dict(base)
+ kw["spacing_O_m"] = FINAL["spacing_O_m"] * mult
+ kw["spacing_F_m"] = FINAL["spacing_F_m"] + (kw["spacing_O_m"] - FINAL["spacing_O_m"])
+ scores.append(_impinging_face_infeasibility_terms(
+ **kw, spray_radius_frac=EQUAL_AREA, spray_radius_tol=0.08))
+ assert scores[0] > 0.0, "the shipped bug must register as infeasible"
+ for a, b in zip(scores, scores[1:]):
+ assert b < a, f"no gradient: {scores} -- CMA cannot descend a flat plateau"
+
+
+def test_face_terms_compete_rather_than_cancel():
+ """Pushing the rings out to hit the spray target must not smuggle them past the wall.
+
+ At a fixed ring GAP, sliding the pair out far enough to reach the equal-area radius puts
+ the fuel ring at 136 mm on a 127 mm bore. The spray term is then satisfied and the wall
+ term is not, and the total must rise -- the sum is a constraint set, not a score to game.
+ """
+ from engine.optimizer.layers.layer1_static_optimization import (
+ _impinging_face_infeasibility_terms,
+ )
+ kw = dict(
+ n_elements=FINAL["n_elements"], d_jet_O_m=FINAL["d_jet_O_m"],
+ d_jet_F_m=FINAL["d_jet_F_m"], D_chamber_inner_m=FINAL["D_chamber_inner_m"],
+ angle_O_deg=FINAL["angle_O_deg"], angle_F_deg=FINAL["angle_F_deg"],
+ )
+ kw["spacing_O_m"] = FINAL["spacing_O_m"] * 2.8
+ kw["spacing_F_m"] = FINAL["spacing_F_m"] + (kw["spacing_O_m"] - FINAL["spacing_O_m"])
+ assert _r_imp(dict(kw, spacing_O_m=kw["spacing_O_m"], spacing_F_m=kw["spacing_F_m"])) \
+ / (0.5 * kw["D_chamber_inner_m"]) == pytest.approx(EQUAL_AREA, abs=0.01)
+ spray_only = _impinging_face_infeasibility_terms(
+ **kw, spray_radius_frac=EQUAL_AREA, spray_radius_tol=0.08)
+ with_wall = _impinging_face_infeasibility_terms(
+ **kw, spray_radius_frac=EQUAL_AREA, spray_radius_tol=0.08, wall_clearance_m=0.008)
+ assert spray_only == pytest.approx(0.0, abs=1e-12), "on the spray target"
+ # fuel ring at 136.43 mm + a 1.92 mm elliptical half-trace = 70.13 mm of radius, 8 mm of
+ # land wanted, against a 63.50 mm bore radius: (70.13 + 8 - 63.50)/127 = 0.1152, squared.
+ assert with_wall == pytest.approx(0.01328, rel=1e-3), (
+ "the fuel ring is 14.6 mm outside where the wall land allows, and that must be what "
+ "the total reports once the spray term is satisfied")
+ assert with_wall > spray_only
+
+
+def test_face_infeasibility_zero_for_the_shipped_design():
+ from engine.optimizer.layers.layer1_static_optimization import (
+ _impinging_face_infeasibility_terms,
+ )
+ import yaml as _yaml
+ c = _yaml.safe_load(open("configs/ethalox_8kN_SHIP.yaml"))
+ g = c["injector"]["geometry"]
+ assert _impinging_face_infeasibility_terms(
+ n_elements=float(g["oxidizer"]["n_elements"]),
+ spacing_O_m=g["oxidizer"]["spacing"], spacing_F_m=g["fuel"]["spacing"],
+ d_jet_O_m=g["oxidizer"]["d_jet"], d_jet_F_m=g["fuel"]["d_jet"],
+ D_chamber_inner_m=c["chamber_geometry"]["chamber_diameter"],
+ angle_O_deg=g["oxidizer"]["impingement_angle"],
+ angle_F_deg=g["fuel"]["impingement_angle"],
+ center_clear_dia_m=0.0381, min_web_m=0.002, wall_clearance_m=0.008,
+ spray_radius_frac=0.7071, spray_radius_tol=0.08,
+ ) == 0.0, "the shipped design must violate none of its own declared face limits"
diff --git a/EngineDesign/tests/test_layer1_geometry_wiring.py b/EngineDesign/tests/test_layer1_geometry_wiring.py
new file mode 100644
index 000000000..f65b8c8d3
--- /dev/null
+++ b/EngineDesign/tests/test_layer1_geometry_wiring.py
@@ -0,0 +1,172 @@
+"""Layer-1 geometry DOFs: configurable theta, hard geometric constraints, priced diameter.
+
+Each test breaks the thing it guards and asserts the guard fires.
+"""
+import math
+import numpy as np
+import pytest
+
+from engine.optimizer.layers.layer1_static_optimization import (
+ _layer1_contraction_theta,
+ _layer1_geometry_infeasibility,
+ _layer1_chamber_mass_kg,
+ DEFAULT_CONTRACTION_HALF_ANGLE_DEG,
+)
+from engine.core.chamber_geometry import (
+ chamber_length_calc, contraction_length_horizontal_calc,
+)
+
+A_T = 0.0018729167346808366
+PC = 2.9648e6
+
+
+class TestContractionAngle:
+ def test_defaults_to_45_when_unset(self):
+ for req in ({}, None, {"layer1_contraction_half_angle_deg": None}):
+ assert math.degrees(_layer1_contraction_theta(req)) == pytest.approx(
+ DEFAULT_CONTRACTION_HALF_ANGLE_DEG)
+
+ def test_config_value_is_honoured(self):
+ for deg in (25.0, 30.0, 37.5, 45.0):
+ got = math.degrees(_layer1_contraction_theta(
+ {"layer1_contraction_half_angle_deg": deg}))
+ assert got == pytest.approx(deg)
+
+ def test_garbage_falls_back_rather_than_raising(self):
+ for bad in ("banana", float("nan"), float("inf")):
+ got = math.degrees(_layer1_contraction_theta(
+ {"layer1_contraction_half_angle_deg": bad}))
+ assert got == pytest.approx(DEFAULT_CONTRACTION_HALF_ANGLE_DEG)
+
+ def test_angle_actually_changes_the_geometry(self):
+ """Not just plumbing -- a different angle must move the chamber."""
+ A_c = math.pi / 4 * 0.127 ** 2
+ R_t = math.sqrt(A_T / math.pi)
+ out = {}
+ for deg in (25.0, 45.0):
+ th = _layer1_contraction_theta({"layer1_contraction_half_angle_deg": deg})
+ out[deg] = (chamber_length_calc(1.0 * A_T, A_T, A_c / A_T, th),
+ contraction_length_horizontal_calc(A_c, R_t, th))
+ assert out[25.0] != out[45.0]
+ # shallower cone is longer and takes more of the volume
+ assert out[25.0][1] > out[45.0][1]
+ assert out[25.0][0] < out[45.0][0]
+
+
+class TestGeometryInfeasibility:
+ A_C = math.pi / 4 * 0.127 ** 2
+
+ def test_noop_when_unconfigured(self):
+ assert _layer1_geometry_infeasibility(
+ {}, L_cylindrical=0.09, D_chamber_inner=0.127,
+ A_chamber=self.A_C, n_elements=26) == 0.0
+
+ def test_min_lcyl_over_d_fires_only_when_violated(self):
+ ok = _layer1_geometry_infeasibility(
+ {"layer1_min_Lcyl_over_D": 0.6}, L_cylindrical=0.09,
+ D_chamber_inner=0.127, A_chamber=self.A_C, n_elements=26)
+ bad = _layer1_geometry_infeasibility(
+ {"layer1_min_Lcyl_over_D": 0.9}, L_cylindrical=0.09,
+ D_chamber_inner=0.127, A_chamber=self.A_C, n_elements=26)
+ assert ok == 0.0
+ assert bad > 0.0
+
+ def test_element_pitch_fires_only_when_violated(self):
+ ok = _layer1_geometry_infeasibility(
+ {"layer1_max_element_pitch_m": 0.030}, L_cylindrical=0.09,
+ D_chamber_inner=0.127, A_chamber=self.A_C, n_elements=26)
+ bad = _layer1_geometry_infeasibility(
+ {"layer1_max_element_pitch_m": 0.018}, L_cylindrical=0.09,
+ D_chamber_inner=0.127, A_chamber=self.A_C, n_elements=26)
+ assert ok == 0.0
+ assert bad > 0.0
+
+ def test_pitch_worsens_monotonically_with_face_area(self):
+ """Spreading a fixed element count over a bigger face must cost more."""
+ prev = -1.0
+ for D in (0.127, 0.1397, 0.1524, 0.1651):
+ v = _layer1_geometry_infeasibility(
+ {"layer1_max_element_pitch_m": 0.020}, L_cylindrical=0.09,
+ D_chamber_inner=D, A_chamber=math.pi / 4 * D ** 2, n_elements=26)
+ assert v > prev
+ prev = v
+
+
+class TestClosureMass:
+ def test_closure_is_opt_in(self):
+ A_c = math.pi / 4 * 0.127 ** 2
+ assert (_layer1_chamber_mass_kg(A_c, 0.15, 0.0381, 2000.0)
+ == pytest.approx(_layer1_chamber_mass_kg(A_c, 0.15, 0.0381, 2000.0, Pc_pa=0.0)))
+
+ def test_closure_prices_diameter_at_fixed_lstar(self):
+ """Barrel-only keeps rewarding a fatter chamber; the closure must turn it over.
+
+ This is the documented reason a mass penalty was abandoned in Layer 1:
+ mass/volume ~ 4t/D, so charging barrel mass alone buys a short fat chamber.
+ """
+ th = math.radians(45.0)
+ R_t = math.sqrt(A_T / math.pi)
+ bores = (0.1143, 0.127, 0.1397, 0.1524, 0.1651)
+ barrel, full = [], []
+ for D in bores:
+ A_c = math.pi / 4 * D ** 2
+ L = (chamber_length_calc(1.0 * A_T, A_T, A_c / A_T, th)
+ + contraction_length_horizontal_calc(A_c, R_t, th))
+ barrel.append(_layer1_chamber_mass_kg(A_c, L, 0.0381, 2000.0))
+ full.append(_layer1_chamber_mass_kg(A_c, L, 0.0381, 2000.0, Pc_pa=PC))
+ # barrel-only is still falling at the fattest bore -> unbounded preference
+ assert barrel[-1] == min(barrel)
+ # with the closure the optimum is interior
+ assert full.index(min(full)) < len(bores) - 1
+
+
+class TestConstantsDictPlumbing:
+ """constants_dict is curated -- a key absent from it never reaches the worker objective.
+
+ This is how the first wiring attempt failed silently: theta was honoured by
+ _layer1_apply_chamber_geometry_to_config (which reads the config) but defaulted to
+ 45 deg inside the objective, so the optimiser scored a different chamber than it built,
+ and layer1_min_Lcyl_over_D was never enforced at all -- Layer 1 returned a design at
+ L_cyl/D = 0.337 against a configured floor of 0.55 and called it valid.
+ """
+
+ KEYS = ("layer1_contraction_half_angle_deg",
+ "layer1_min_Lcyl_over_D",
+ "layer1_max_element_pitch_m")
+
+ def test_geometry_keys_are_forwarded_to_constants_dict(self):
+ import inspect
+ from engine.optimizer.layers import layer1_static_optimization as L1
+ src = inspect.getsource(L1)
+ start = src.index("constants_dict = {")
+ end = src.index("}", src.index("'target_thrust'", start))
+ block = src[start:end]
+ for k in self.KEYS:
+ assert f"'{k}'" in block, (
+ f"{k} is missing from constants_dict: the worker objective will "
+ f"silently use its default while the applied geometry uses the config value")
+
+ def test_helpers_read_through_a_plain_dict(self):
+ """constants_dict is a plain dict; the config object is a pydantic model.
+
+ Both must work -- a bare .get() broke the model path, a bare getattr breaks the dict.
+ """
+ from engine.optimizer.layers.layer1_static_optimization import (
+ _layer1_contraction_theta, _layer1_geometry_infeasibility)
+ import math
+
+ class _Model: # stands in for DesignRequirementsConfig
+ layer1_contraction_half_angle_deg = 30.0
+ layer1_min_Lcyl_over_D = 0.55
+ layer1_max_element_pitch_m = 0.024
+
+ as_dict = {"layer1_contraction_half_angle_deg": 30.0,
+ "layer1_min_Lcyl_over_D": 0.55,
+ "layer1_max_element_pitch_m": 0.024}
+ A_c = math.pi / 4 * 0.127 ** 2
+ for src in (as_dict, _Model()):
+ assert math.degrees(_layer1_contraction_theta(src)) == pytest.approx(30.0)
+ # L_cyl/D = 0.337, below the 0.55 floor -> must score infeasible from BOTH shapes
+ assert _layer1_geometry_infeasibility(
+ src, L_cylindrical=0.0428, D_chamber_inner=0.127,
+ A_chamber=A_c, n_elements=27) > 0.0
diff --git a/EngineDesign/tests/test_requirements_schema_completeness.py b/EngineDesign/tests/test_requirements_schema_completeness.py
new file mode 100644
index 000000000..277d0625a
--- /dev/null
+++ b/EngineDesign/tests/test_requirements_schema_completeness.py
@@ -0,0 +1,81 @@
+"""Every requirement key Layer 1 reads must be DECLARED in DesignRequirementsConfig.
+
+A pydantic model silently DROPS unknown keys. So a key the optimizer reads but the schema
+does not declare cannot be set at all: `PUT /api/config` returns 200 and discards it, a YAML
+carrying it loads without it, and the value the optimizer uses is always its hardcoded
+fallback. Four such keys even had labelled controls in the Configuration editor, so the UI
+offered knobs that did nothing.
+
+Sixteen keys were in that state when this test was written (2026-09-14), including
+`max_chamber_length_m`, the throat-area search bounds, and the whole injector ring-geometry
+group. This test fails if anyone adds a seventeenth.
+
+The reverse direction is deliberately NOT checked: a declared field with no reader is
+harmless (it may be read by Layer 2/3, the frontend, or a future consumer).
+"""
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+import pytest
+
+from engine.pipeline.config_schemas import DesignRequirementsConfig
+
+LAYER1 = Path(__file__).resolve().parent.parent / "engine/optimizer/layers/layer1_static_optimization.py"
+
+# Keys read from `requirements` that are NOT design requirements and are supplied by the
+# caller at runtime instead. Keep this list short and justified.
+RUNTIME_ONLY: set[str] = set()
+
+READ_PATTERNS = (
+ r'_requirement_float\(\s*requirements,\s*["\']([A-Za-z0-9_]+)["\']',
+ r'_requirement_bool\(\s*requirements,\s*["\']([A-Za-z0-9_]+)["\']',
+ r'_requirement_int\(\s*requirements,\s*["\']([A-Za-z0-9_]+)["\']',
+ r'requirements\.get\(\s*["\']([A-Za-z0-9_]+)["\']',
+ r'_req_lookup\(\s*requirements,\s*["\']([A-Za-z0-9_]+)["\']',
+)
+
+
+def _keys_layer1_reads() -> set[str]:
+ src = LAYER1.read_text(encoding="utf-8")
+ keys: set[str] = set()
+ for pat in READ_PATTERNS:
+ keys |= set(re.findall(pat, src))
+ return keys - RUNTIME_ONLY
+
+
+def test_every_key_layer1_reads_is_declared():
+ declared = set(DesignRequirementsConfig.model_fields)
+ missing = sorted(_keys_layer1_reads() - declared)
+ assert not missing, (
+ "Layer 1 reads these requirement keys but DesignRequirementsConfig does not declare "
+ "them, so pydantic silently drops them and they can never be set:\n "
+ + "\n ".join(missing)
+ + "\n\nAdd each as `Optional[...] = Field(default=None, ...)` whose description states "
+ "the optimizer's fallback, and make sure any raw `requirements.get(k, default)` "
+ "reader is None-safe -- once declared the key arrives present-with-value-None, so "
+ "`.get(k, default)` stops firing and `int(None)` / `float(None)` raises."
+ )
+
+
+def test_the_scan_actually_finds_keys():
+ """Guard the guard: if the regexes stop matching, the test above passes vacuously."""
+ found = _keys_layer1_reads()
+ assert len(found) > 80, f"only found {len(found)} requirement reads - the scan is broken"
+ for anchor in ("optimal_of_ratio", "min_Lstar", "W_MOM"):
+ assert anchor in found, f"scan missed a known requirement key: {anchor}"
+
+
+@pytest.mark.parametrize("key", [
+ "layer1_enforce_ring_geometry",
+ "layer1_impingement_Ld_min",
+ "layer1_impingement_Ld_max",
+ "max_chamber_length_m",
+])
+def test_previously_undeclared_keys_now_round_trip(key):
+ """These four were readable-but-unsettable and are the reason this test exists."""
+ assert key in DesignRequirementsConfig.model_fields
+ val = True if key == "layer1_enforce_ring_geometry" else 6.0
+ cfg = DesignRequirementsConfig(**{key: val})
+ assert getattr(cfg, key) == val, f"{key} did not survive construction"
From a5345fd593e89d8dde9389989e7818f6b23b9281 Mon Sep 17 00:00:00 2001
From: Carlsaurus
Date: Mon, 14 Sep 2026 18:30:07 -0700
Subject: [PATCH 05/24] Chug stability on a double time lag, picked by
measurement
The chug verdict was riding on a d2-law vaporization lag. Against the test data
the Leonardi 2017 double-time-lag form is 6.3x closer, so that is the default now
and the d2 law stays available as time_lag_model: d2_law. Convection is off --
turning it on made it worse, not better, on the same data.
Adds a root-locus view so the margin is something you can look at rather than a
single number, and the glossary explains which lag produced the answer.
docs/stability/chug-double-time-lag.md has the comparison.
---
.../docs/stability/chug-double-time-lag.md | 192 ++++++++
.../stability/combustion_stability_physics.md | 17 +-
.../engine/pipeline/stability/analysis.py | 237 +++++++--
.../engine/pipeline/stability/chug.py | 111 ++++-
.../engine/pipeline/stability/report.py | 91 +++-
.../engine/pipeline/stability/timelag.py | 451 ++++++++++++++++++
.../components/stability/ChugRootLocus.tsx | 326 +++++++++++++
.../components/stability/ChugStabilityMap.tsx | 113 +++--
.../stability/StabilityGlossary.tsx | 50 +-
.../components/stability/StabilityPanel.tsx | 51 +-
.../src/components/stability/types.ts | 63 ++-
.../scripts/chug_timelag_benchmark.py | 392 +++++++++++++++
.../tests/test_api_nonfinite_serialization.py | 57 +++
13 files changed, 2049 insertions(+), 102 deletions(-)
create mode 100644 EngineDesign/docs/stability/chug-double-time-lag.md
create mode 100644 EngineDesign/engine/pipeline/stability/timelag.py
create mode 100644 EngineDesign/frontend/src/components/stability/ChugRootLocus.tsx
create mode 100644 EngineDesign/scripts/chug_timelag_benchmark.py
create mode 100644 EngineDesign/tests/test_api_nonfinite_serialization.py
diff --git a/EngineDesign/docs/stability/chug-double-time-lag.md b/EngineDesign/docs/stability/chug-double-time-lag.md
new file mode 100644
index 000000000..78364d88b
--- /dev/null
+++ b/EngineDesign/docs/stability/chug-double-time-lag.md
@@ -0,0 +1,192 @@
+# The chug conversion lag: double time lag, and how the model was chosen
+
+**Status:** implemented and shipping as the default.
+**Code:** `engine/pipeline/stability/timelag.py`, wired in `engine/pipeline/stability/analysis.py`.
+**Benchmark:** `scripts/chug_timelag_benchmark.py` — run it before changing any number in this
+document.
+
+---
+
+## 1. Why this exists
+
+The chug loop (`chug.py`) needs exactly one number per propellant stream: the lag between an
+injection-rate perturbation and the heat release it eventually produces. Everything else in the loop
+— feed inertance, injector conductance, chamber gain — is geometry and can be measured. The lag is
+where the *propellant* and the *injector* enter the stability problem, which makes it the one place
+that must not quietly assume either.
+
+Before this change it assumed both. `build_stability_inputs` ran
+
+```python
+core.lags_from_smd(D32, k_g=..., rho_l=..., cp_g=..., T_inf=Tc, T_boil=..., h_fg=..., chi=1.0)
+```
+
+on **both** streams unconditionally. That is the quiescent d²-law droplet lifetime, and it has three
+problems that are invisible in the output:
+
+1. **It has no notion of phase.** Run it on a gaseous propellant — GH2, GOX, gaseous methane — and it
+ returns a droplet lifetime for something that has no droplets. The number is not wrong by a
+ factor; it is a category error, and nothing in the payload said so.
+2. **It is only the vaporization term.** Atomization and mixing are missing entirely, so the lag is
+ systematically short and the chug margin systematically optimistic — the dangerous direction.
+3. **It rides on `k_g`, the hot-gas thermal conductivity**, a property nobody on this program
+ measures. Across a plausible band (0.35–0.70 W/m·K for H₂-rich products) the predicted lag moves
+ by a factor of two.
+
+## 2. The model
+
+From Leonardi, Nasuti, Di Matteo & Steelant, *"A methodology to study the possible occurrence of
+chugging in liquid rocket engines during transient start-up"*, **Acta Astronautica 139 (2017)
+344–356** — hereafter **L17** — the conversion lag decomposes (L17 eq. 5) as
+
+$$\tau_{tot} = \tau_{atom} + \tau_{vap} + \tau_{mix}$$
+
+**Phase decides which terms exist at all.** L17 §3.2: the gaseous propellant carries only
+$\tau_{mix}$, because a gas neither atomizes nor vaporizes. This is the structural fix, and it is
+applied under *both* lag models — it is a correctness repair, not new physics.
+
+### Atomization — L17 eq. 6–7
+
+$$\tau_{atom} = 6\times10^{-4}\left(\frac{\rho_g}{\rho_l}\right)^{-0.32} We_g^{0.03}\, Re_l^{0.55}\,\frac{D_l}{u_l}$$
+
+with $Re_l = u_l D_l \rho_l/\mu_l$ and $We_g = 2\rho_g (u_g-u_l)^2 D_l/\sigma_l$. $D_l$ is the liquid
+post inner diameter and $u_l$ the injection velocity; both come from whichever injector the config
+names (see §4).
+
+### Vaporization — L17 eq. 9
+
+$$\tau_{vap} = \frac{D_0^2}{k},\qquad k = 10^{-6}\left[\frac{1.01}{1+MR} + 1.16\times10^{-3}(T_\infty - T_{cr})^{0.93}\right]^{0.86}$$
+
+$T_{cr}$ is the **liquid critical temperature** — a propellant property, so it lives on `FluidConfig`
+(`critical_temperature`), sourced from the config, then CoolProp, then a handbook table, with every
+fallback recorded.
+
+$D_0$ is the initial drop size, taken from the injector's own spray solve (Ingebo for impinging,
+Lefebvre for coaxial, the sheet model for pintle).
+
+> **L17 eq. 10 is deliberately not implemented.** It is a $D_0$ correlation "specifically developed
+> for coaxial injectors and liquid oxygen" — adopting it would hardcode one injector and one
+> oxidizer into a multi-injector, multi-propellant tool, which is the coupling this work removes. It
+> is also not dimensionally closed as printed (the exponents 2.25 and −2.65 do not cancel), so its
+> units cannot be reconstructed from L17 alone.
+
+### Mixing — calibrated ratio
+
+L17 reads $\tau_{mix}$ off Szuch's empirical curve of mixing time versus $L_{50}$, the length to
+vaporize 50 % of the liquid (NASA TN-D-7026, L17 fig. 1). That curve is a figure, not a table, and is
+not reproduced here — digitizing it by eye would be inventing a correlation.
+
+What L17 *does* state numerically is its own calibration point: $\tau_{vap} = 4.4$ ms and
+$\tau_{mix} = 2.2$ ms for the validation engine (L17 §3.1), a ratio of **0.5**. Since $L_{50}$ is
+itself proportional to $\tau_{vap}$ at fixed droplet speed (`MASS_HALF_LIFE_FRACTION` = $1-2^{-2/3}$
+= 0.370, a closed form under the d² law), a constant of proportionality is the faithful reduction of
+that curve to one number.
+
+So `stability.mixing_lag_fraction` defaults to 0.5 and carries L17's provenance. It is **shared by
+every stream** and scales off the **slowest liquid** stream — with two liquids, mixing cannot
+complete until both are vapour. To replace it with a digitized curve, replace
+`timelag.resolve_mixing_lag`; do not tune the 0.5.
+
+## 3. Which model is more accurate — the measurement
+
+The anchor is L17's validation engine, itself a re-analysis of the NASA GH2/LOX chug rig of L17
+ref. [25]: a **measured** chug frequency (66 Hz), a **measured** stability boundary
+($\Delta p_{ox}/p_c \approx 0.35$ at $\Delta p_{fu}/p_c = 0.5$), and one propellant injected as a gas.
+
+`scripts/chug_timelag_benchmark.py` scores each model end-to-end through `chug.py` on both measured
+quantities. Score = |frequency error| + |boundary error|, in percent; lower is better.
+
+| lags from | τ_O | τ_F | f pred | boundary | score |
+|---|---|---|---|---|---|
+| L17's own hand-calibrated lags *(reference)* | 6.60 ms | 2.20 ms | 58.6 Hz | 0.28 | **30** |
+| **`leonardi_dtl`, `convection=none`** | 7.48 ms | 2.49 ms | 53.0 Hz | 0.30 | **35** |
+| `leonardi_dtl`, `convection=leonardi_eq8` | 3.25 ms | 1.08 ms | 104.1 Hz | 0.20 | 102 |
+| `leonardi_dtl`, `convection=ranz_marshall` | 1.65 ms | 0.55 ms | 183.3 Hz | 0.12 | 243 |
+| `d2_law` + mixing, k_g = 0.35 | 3.86 ms | 1.29 ms | 90.5 Hz | 0.22 | 75 |
+| `d2_law` + mixing, k_g = 0.70 | 1.93 ms | 0.64 ms | 160.6 Hz | 0.14 | 205 |
+| `d2_law`, no mixing — **STAR before this change** | 1.80 ms | 0.00 ms | 170.2 Hz | 0.12 | **222** |
+
+**`leonardi_dtl` with no convection correction wins by 6.3× over what STAR shipped**, and lands
+within a few points of L17's own hand-tuned lags. It is therefore the default everywhere —
+Layer-1's per-evaluation gate as well as the rich report — because the accuracy gap is not close
+enough to justify running two different physics in the same program.
+
+Two further points from the benchmark:
+
+* **The single largest gain is including τ_mix at all.** Today's model has no mixing term, and the
+ gaseous-fuel stream had *no lag whatsoever*.
+* **`d2_law`'s score spans 75 → 205 across the k_g band alone.** `leonardi_dtl` has no k_g dependence:
+ the hot-gas conductivity drops out of the lag entirely.
+
+### Why the convection correction ships OFF
+
+L17 eq. 8 divides the quiescent lifetime by $1+1.5\alpha$, $\alpha = 1-3\times10^{-3} p_c\,[\text{bar}]$.
+Applying it makes the model worse, three ways:
+
+1. **Against the anchor.** Eq. 9 in its quiescent form gives 4.98 ms at L17's own reference point
+ (D₀ = 83 µm, MR 5, 44.8 bar, 2038 K) against the experiment-derived 4.4 ms — **+13 %**. With
+ eq. 8 it becomes 2.17 ms, **−51 %**.
+2. **End-to-end**, it triples the combined error (35 → 102 in the table above).
+3. **Against the textbook.** It depends on chamber pressure *alone* — no slip velocity, no drop size
+ — and it *falls* as $p_c$ rises, while Ranz–Marshall does not. Benchmark C prints both.
+
+The coherent reading is that eq. 9's $k$ was already calibrated against real chamber data (L17 routes
+its 4.4 ms through Priem–Heidmann's $L_{50}/v_{inj}$), so it *contains* the convective enhancement and
+eq. 8 double-counts it. That also reconciles L17's two otherwise-inconsistent statements about the
+same working point — the paper reports D₀ = 83 µm *and* τ_vap = 4.4 ms, which eq. 8–9 as printed
+cannot both satisfy (118 µm would be needed).
+
+Both corrections stay available through `CONVECTION_MODELS` so the run report can say which one
+produced the answer.
+
+### What the benchmark does **not** establish
+
+Both models underpredict the total lag against this one rig, and one rig is one rig. The GH2/LOX
+anchor is the only published chug case found with a measured boundary *and* a gas-phase propellant;
+no STAR-propellant (LOX/ethanol, LOX/CH₄, LOX/RP-1) chug measurement was available to score against.
+Treat the absolute lag as good to roughly a factor of 1.5 and the *relative* ranking of designs as
+the trustworthy output.
+
+## 4. What stopped being hardcoded
+
+| was | now |
+|---|---|
+| d²-law run on both streams regardless of phase | `injection_phase` per fluid (explicit or inferred from T vs critical point); a gas carries only τ_mix |
+| `tau_sens = chi * tau_conv_O` — the oxidizer assumed rate-limiting | slowest **liquid** stream, whichever side it is |
+| `D32_O or 80e-6`, `D32_F or 60e-6` | SMD from the injector's own spray model; a missing one is recorded via `assume()` |
+| `rho_O = inp.get("rho_O", 1140.0)` (LOX behind every oxidizer) | config-sourced; missing value recorded |
+| `Cd = ... else 0.6` | solved Cd; missing value recorded |
+| η sweep fixed at 0.08–0.45 for every engine | window anchored to the design point (`_eta_window`) |
+| `T_crit` absent — no model needed it | `FluidConfig.critical_temperature`, config → CoolProp → handbook, every fallback recorded |
+| frontend legend hardcoded "O (LOX)" / "F (fuel)" | actual fluid names and phases from the payload |
+| jet diameter unavailable to the lag model | `_jet_geometry` resolves it for impinging / coaxial / pintle, and returns NaN (recorded) rather than a stand-in when the injector type has no equivalent dimension |
+
+## 5. The root locus
+
+`chug.chug_root_locus` tracks the dominant eigenvalue of $1 + L(s) = 0$ through the s-plane as
+$\eta_{inj}$ sweeps, by continuation from the softest injector upward. `report.py` emits it as
+`chug.root_locus`, plus `chug.eta_critical` — where the branch crosses the imaginary axis, which is
+the injector stiffness the engine has to beat.
+
+The damping ratio was corrected to the standard $\zeta = -\sigma/|s|$ (it was $-\sigma/\omega$, which
+is $\zeta/\sqrt{1-\zeta^2}$: indistinguishable below $\zeta \approx 0.1$, 15 % off at $\zeta = 0.5$,
+and enough to put a plotted pole off its own constant-ζ ray).
+
+**Caveat on the locus:** the sweep moves both streams to the same $\eta_{inj}$, while the design-point
+eigenvalue is solved at each stream's own $\eta$ — so the design marker sits *near* the branch, not
+exactly on it, whenever the two injector stiffnesses differ. The UI says so.
+
+## 6. Running the benchmark
+
+```bash
+python3 scripts/chug_timelag_benchmark.py
+```
+
+Five benchmarks, all against external anchors: (A) `chug.py` vs the measured frequency and boundary
+using L17's own lags, (B) each lag model vs the experiment-derived τ_vap, (C) L17 eq. 8 vs
+Ranz–Marshall, (D) blast radius on STAR-class engines, (E) the decider — each model end-to-end
+against both measured quantities. Exit code is non-zero if any criterion regresses.
+
+Unit tests live in `tests/test_stability_timelag.py`. **Note that `tests/test_stability_*.py` is
+gitignored repo-wide** (`.gitignore:93`, "local-only tests"), so neither these nor the pre-existing
+stability tests run in CI.
diff --git a/EngineDesign/docs/stability/combustion_stability_physics.md b/EngineDesign/docs/stability/combustion_stability_physics.md
index ea6cc18d9..a817cc9dd 100644
--- a/EngineDesign/docs/stability/combustion_stability_physics.md
+++ b/EngineDesign/docs/stability/combustion_stability_physics.md
@@ -279,6 +279,17 @@ than a single false-precision "stable/unstable" verdict.
## 5. The sensitive time lag for an impinging LOX/CH₄ spray
+> **SUPERSEDED for the chug transport lag — see [`chug-double-time-lag.md`](chug-double-time-lag.md).**
+> This section's closing simplification, $\tau_{tot}\approx\tau_{vap}$, is the assumption the code
+> shipped, and measuring it against the GH2/LOX chug rig of Leonardi et al. (2017) showed it costs a
+> factor of ~6 in combined frequency and boundary error. The chug loop now uses the double-time-lag
+> decomposition $\tau_{atom}+\tau_{vap}+\tau_{mix}$ with a phase-aware rule (a gaseous propellant
+> carries only $\tau_{mix}$), and $\tau_{vap}$ comes from the L17 eq. 9 evaporation constant rather
+> than the $d^2$-law below. The $d^2$-law remains selectable as `stability.time_lag_model: d2_law`,
+> and §5.1–5.2 still describe it accurately. The **acoustic** sensitive lag $\tau_{sens}$ is
+> unchanged in form, but is now taken off whichever *liquid* stream is rate-limiting rather than off
+> the oxidizer by position.
+
This is the bridge between the spray/atomization model (already in the code: Ingebo SMD) and
stability. For a **liquid bipropellant with both propellants injected as liquid jets**, the rate-
limiting step of the conversion time is almost always **droplet vaporization** (Priem & Heidmann,
@@ -288,8 +299,10 @@ $$
\tau_{tot} \;=\; \tau_{atomize} + \tau_{vap} + \tau_{mix} + \tau_{chem},
$$
-with $\tau_{chem}\ll$ the others for LOX/CH₄ at chamber conditions, and $\tau_{atomize},\tau_{mix}$
-small for a well-impinged doublet. Thus $\tau_{tot}\approx\tau_{vap}$.
+with $\tau_{chem}\ll$ the others for LOX/CH₄ at chamber conditions. This document previously
+concluded that $\tau_{atomize},\tau_{mix}$ are small for a well-impinged doublet and therefore that
+$\tau_{tot}\approx\tau_{vap}$; the benchmark above contradicts that for $\tau_{mix}$, which is
+comparable to $\tau_{vap}$, not small against it.
### 5.1 Vaporization time from the $d^2$-law
diff --git a/EngineDesign/engine/pipeline/stability/analysis.py b/EngineDesign/engine/pipeline/stability/analysis.py
index 92a336594..12777eb49 100644
--- a/EngineDesign/engine/pipeline/stability/analysis.py
+++ b/EngineDesign/engine/pipeline/stability/analysis.py
@@ -17,6 +17,7 @@
from __future__ import annotations
import os
+from functools import lru_cache
from typing import Dict, Tuple, Optional, List, Any
import numpy as np
from engine.pipeline.config_schemas import PintleEngineConfig, StabilityConfig
@@ -264,34 +265,75 @@ def _feed_attr(config, key, attr, default):
# Handbook thermodynamic fallbacks BY FLUID, used only when the config omits a property. Every use
# is recorded in the assumptions registry. Previously the fuel fallbacks were methane's (h_fg 510 kJ/kg,
# T_boil 111.6 K) regardless of which fuel the config named, and the oxidizer's were LOX's.
-# density kg/m^3 latent heat J/kg boiling point K (1 atm)
+# density kg/m^3 latent heat J/kg boiling point K critical T K
_FLUID_THERMO_FALLBACKS = {
- "lox": (1140.0, 213000.0, 90.2),
- "methane": ( 422.6, 510000.0, 111.65),
- "ethanol": ( 789.0, 838000.0, 351.4),
- "rp1": ( 810.0, 246000.0, 489.0),
- "ipa": ( 786.0, 665000.0, 355.6),
- "nitrousoxide": (1220.0, 376000.0, 184.7),
+ "lox": (1140.0, 213000.0, 90.2, 154.58),
+ "methane": ( 422.6, 510000.0, 111.65, 190.56),
+ "ethanol": ( 789.0, 838000.0, 351.4, 514.0),
+ "rp1": ( 810.0, 246000.0, 489.0, 678.0), # n-dodecane surrogate
+ "ipa": ( 786.0, 665000.0, 355.6, 508.3),
+ "nitrousoxide": (1220.0, 376000.0, 184.7, 309.52),
+ "hydrogen": ( 70.8, 446000.0, 20.3, 33.15),
+ "nitrogen": ( 806.0, 199000.0, 77.36, 126.19),
}
-_THERMO_INDEX = {"density": (0, "kg/m^3"), "latent_heat": (1, "J/kg"), "boiling_point": (2, "K")}
-_GENERIC_THERMO = {"fuel": (800.0, 300000.0, 450.0), "oxidizer": (1140.0, 213000.0, 90.2)}
+_THERMO_INDEX = {"density": (0, "kg/m^3"), "latent_heat": (1, "J/kg"),
+ "boiling_point": (2, "K"), "critical_temperature": (3, "K")}
+_GENERIC_THERMO = {"fuel": (800.0, 300000.0, 450.0, 600.0),
+ "oxidizer": (1140.0, 213000.0, 90.2, 154.58)}
+
+#: Fluid name -> CoolProp fluid, for properties the config did not supply. Only names whose
+#: thermodynamics CoolProp actually covers; RP-1 is a cut, not a compound, so it is left to the
+#: n-dodecane surrogate in the handbook table above rather than asked of CoolProp under a name
+#: CoolProp would silently resolve to something else.
+_COOLPROP_NAMES = {
+ "lox": "Oxygen", "methane": "Methane", "ethanol": "Ethanol", "ipa": "n-Propanol",
+ "nitrousoxide": "NitrousOxide", "hydrogen": "Hydrogen", "nitrogen": "Nitrogen",
+}
+
+
+@lru_cache(maxsize=32)
+def _coolprop_critical_temperature(canon: str) -> Optional[float]:
+ """Critical temperature [K] from CoolProp for a canonical fluid name, or None.
+
+ Cached: the fast stability tier runs on every optimizer candidate, and a PropsSI call per
+ candidate would be a real cost for a value that cannot change within a process.
+ """
+ name = _COOLPROP_NAMES.get(canon)
+ if not name:
+ return None
+ try:
+ from CoolProp.CoolProp import PropsSI
+ v = float(PropsSI("Tcrit", name))
+ return v if np.isfinite(v) and v > 0 else None
+ except Exception:
+ return None
+
+
+def _fluid_name(config, key: str) -> str:
+ try:
+ f = config.fluids[key] if isinstance(config.fluids, dict) else getattr(config.fluids, key)
+ return str(getattr(f, "name", "") or "")
+ except Exception:
+ return ""
def _fluid_thermo(config, key: str, attr: str) -> float:
- """``fluids[key].attr`` from the config; else the handbook value for that named fluid; else a
- generic value. Both fallbacks are recorded, and the generic one says the fluid was unrecognised."""
+ """``fluids[key].attr`` from the config; else CoolProp for the named fluid; else the handbook
+ table; else a generic value. Every fallback is recorded, and the generic one says outright that
+ the fluid was unrecognised so it reads as "fix the config", not as a property."""
v = _fluid_attr(getattr(config, "fluids", None), key, attr, None)
if v is not None:
return v
from engine.pipeline.assumptions import assume
from engine.pipeline.io import _canon_fluid
idx, unit = _THERMO_INDEX[attr]
- try:
- f = config.fluids[key] if isinstance(config.fluids, dict) else getattr(config.fluids, key)
- name = getattr(f, "name", "") or ""
- except Exception:
- name = ""
+ name = _fluid_name(config, key)
canon = _canon_fluid(name)
+ if attr == "critical_temperature":
+ cp = _coolprop_critical_temperature(canon)
+ if cp is not None:
+ return assume(f"stability.fluids.{key}.{attr}", cp, unit=unit,
+ reason=f"fluids.{key}.critical_temperature missing; CoolProp value for {name}")
if canon in _FLUID_THERMO_FALLBACKS:
return assume(f"stability.fluids.{key}.{attr}", _FLUID_THERMO_FALLBACKS[canon][idx], unit=unit,
reason=f"fluids.{key}.{attr} missing from config; handbook value for {name}")
@@ -299,6 +341,33 @@ def _fluid_thermo(config, key: str, attr: str) -> float:
unit=unit, reason=f"fluids.{key}.{attr} missing and fluid {name!r} is not in the handbook table -- set it in the config")
+def _injection_phase(config, key: str) -> str:
+ """``"liquid"`` or ``"gas"`` at the injector face.
+
+ Explicit config wins. Otherwise: supercritical at the tank temperature is gas-like, and a fluid
+ whose vapour pressure at its own bulk temperature exceeds the chamber pressure arrives as vapour.
+ Both inferences are recorded — getting this wrong changes which time lags exist at all, so it
+ must never be a silent guess.
+ """
+ from engine.pipeline.assumptions import assume
+ explicit = None
+ try:
+ f = config.fluids[key] if isinstance(config.fluids, dict) else getattr(config.fluids, key)
+ explicit = getattr(f, "injection_phase", None)
+ except Exception:
+ f = None
+ if explicit in ("liquid", "gas"):
+ return str(explicit)
+ name = _fluid_name(config, key)
+ T = _fluid_attr(getattr(config, "fluids", None), key, "temperature", None)
+ T_crit = _fluid_thermo(config, key, "critical_temperature")
+ if T is not None and np.isfinite(T_crit) and T >= T_crit:
+ return assume(f"stability.fluids.{key}.injection_phase", "gas", unit="-",
+ reason=f"{name or key} is stored at {T:.0f} K, at or above its critical "
+ f"temperature {T_crit:.0f} K -- inferred to arrive as a gas")
+ return "liquid"
+
+
def _feed_geometry(config, side: str) -> Tuple[float, float]:
"""(length [m], flow area [m^2]) of one feed line for the chug inertance L/A.
@@ -343,6 +412,48 @@ def _chamber_dims(config, cg) -> Tuple[float, float]:
return L, D
+def _jet_geometry(config, diagnostics: Dict[str, Any], side: str) -> Tuple[float, float]:
+ """(jet/post inner diameter [m], injection velocity [m/s]) for one stream.
+
+ These are Leonardi eq. 6-7's ``D_l`` and ``u_l``. Both come from whichever injector the config
+ actually names -- the solved closure diagnostics first (every injector model publishes ``u_O``/
+ ``u_F``), then the injector's own geometry block. Returns NaN rather than a stand-in when the
+ injector type carries no equivalent dimension; the lag model then drops the atomization term and
+ records it, instead of inventing a jet.
+
+ Impinging -> the jet diameter. Coaxial -> the core port (oxidizer) and the annulus hydraulic
+ diameter (fuel), which is what L17 calls the liquid post. Pintle -> the tip orifice (oxidizer)
+ and the annular gap's hydraulic diameter, 2*h_gap (fuel).
+ """
+ key = "O" if side == "oxidizer" else "F"
+ u = diagnostics.get(f"u_{key}")
+ u = float(u) if (u is not None and np.isfinite(float(u)) and float(u) > 0.0) else float("nan")
+
+ d = diagnostics.get(f"d_jet_{key}")
+ if d is not None and np.isfinite(float(d)) and float(d) > 0.0:
+ return float(d), u
+
+ inj = getattr(config, "injector", None)
+ geom = getattr(inj, "geometry", None)
+ itype = str(getattr(inj, "type", "") or "")
+ try:
+ if itype == "impinging":
+ elem = geom.oxidizer if side == "oxidizer" else geom.fuel
+ return float(elem.d_jet), u
+ if itype == "coaxial":
+ if side == "oxidizer":
+ return float(geom.core.d_port), u
+ # Annulus hydraulic diameter = 2 * gap (outer minus inner diameter).
+ return float(2.0 * geom.annulus.gap_thickness), u
+ if itype == "pintle":
+ if side == "oxidizer":
+ return float(geom.lox.d_orifice), u
+ return float(2.0 * geom.fuel.h_gap), u
+ except Exception:
+ pass
+ return float("nan"), u
+
+
def _stability_config(config) -> StabilityConfig:
sc = getattr(config, "stability", None)
return sc if isinstance(sc, StabilityConfig) else StabilityConfig()
@@ -359,7 +470,7 @@ def build_stability_inputs(config, Pc: float, MR: float, mdot_total: float, csta
config (``fluids`` for the propellants, ``feed_system`` for the plumbing, ``stability`` for the
model calibration), and finally recorded assumptions -- never a silent constant.
"""
- from engine.pipeline.stability import core, chug, acoustic
+ from engine.pipeline.stability import core, chug, acoustic, timelag
from engine.pipeline.assumptions import assume
sc = _stability_config(config)
@@ -394,8 +505,21 @@ def build_stability_inputs(config, Pc: float, MR: float, mdot_total: float, csta
dpiF = float(diagnostics.get("delta_p_injector_F") or 0.30 * Pc)
dpfO = float(diagnostics.get("delta_p_feed_O") or 0.10 * Pc)
dpfF = float(diagnostics.get("delta_p_feed_F") or 0.10 * Pc)
- D32_O = float(diagnostics.get("D32_O") or 80e-6)
- D32_F = float(diagnostics.get("D32_F") or 60e-6)
+ # SMD comes from whichever spray model the config's injector selected (Ingebo for impinging,
+ # Lefebvre for coaxial, the sheet model for pintle) -- this layer must never pick one. When the
+ # closure did not produce one, the substitution is recorded rather than silently applied; it was
+ # a bare 80/60 um, i.e. a LOX/methane impinging spray asserted for every engine.
+ D32_O = diagnostics.get("D32_O")
+ if D32_O is None or not np.isfinite(float(D32_O)) or float(D32_O) <= 0.0:
+ D32_O = assume("stability.D32_oxidizer", 80e-6, unit="m",
+ reason="closure produced no oxidizer SMD; order-of-magnitude liquid-oxidizer "
+ "spray. The chug lag scales as SMD^2, so this is a large lever")
+ D32_O = float(D32_O)
+ D32_F = diagnostics.get("D32_F")
+ if D32_F is None or not np.isfinite(float(D32_F)) or float(D32_F) <= 0.0:
+ D32_F = assume("stability.D32_fuel", 60e-6, unit="m",
+ reason="closure produced no fuel SMD; order-of-magnitude liquid-fuel spray")
+ D32_F = float(D32_F)
ov = overrides or {}
if ov.get("smd_um") is not None:
D32_O = float(ov["smd_um"]) * 1e-6
@@ -416,16 +540,56 @@ def build_stability_inputs(config, Pc: float, MR: float, mdot_total: float, csta
if K_bulk_O is None:
K_bulk_O = assume("stability.fluids.oxidizer.bulk_modulus_pa", 1.5e9, unit="Pa",
reason="fluids.oxidizer.bulk_modulus_pa missing (set via propellant preset); measure via water-hammer test T5")
- tau_conv_O, _, K_v_O = core.lags_from_smd(D32_O, k_g=k_g, rho_l=rho_O, cp_g=cp_g, T_inf=Tc,
- T_boil=tbO, h_fg=hfg_O, chi=1.0)
- tau_conv_F, _, K_v_F = core.lags_from_smd(D32_F, k_g=k_g, rho_l=rho_F, cp_g=cp_g, T_inf=Tc,
- T_boil=tbF, h_fg=hfg_F, chi=1.0)
- if not np.isfinite(tau_conv_O):
+ tcrO = _fluid_thermo(config, "oxidizer", "critical_temperature")
+ tcrF = _fluid_thermo(config, "fuel", "critical_temperature")
+ muO = _fluid_attr(config.fluids, "oxidizer", "viscosity", float("nan"))
+ muF = _fluid_attr(config.fluids, "fuel", "viscosity", float("nan"))
+ sigO = _fluid_attr(config.fluids, "oxidizer", "surface_tension", float("nan"))
+ sigF = _fluid_attr(config.fluids, "fuel", "surface_tension", float("nan"))
+ phaseO = _injection_phase(config, "oxidizer")
+ phaseF = _injection_phase(config, "fuel")
+ d_jet_O, u_inj_O = _jet_geometry(config, diagnostics, "oxidizer")
+ d_jet_F, u_inj_F = _jet_geometry(config, diagnostics, "fuel")
+
+ # Mean axial gas velocity in the chamber -- the ``u_g`` of the atomization Weber number.
+ u_gas = float(mdot_total / (rho_g * A_c)) if (rho_g > 0 and A_c > 0) else float("nan")
+
+ lag_streams = {
+ "O": timelag.StreamThermo(
+ name=_fluid_name(config, "oxidizer") or "oxidizer", phase=phaseO,
+ rho_l=rho_O, mu_l=muO, sigma_l=sigO, T_boil=tbO, T_crit=tcrO, h_fg=hfg_O,
+ D0=D32_O, u_inj=u_inj_O, d_orifice=d_jet_O),
+ "F": timelag.StreamThermo(
+ name=_fluid_name(config, "fuel") or "fuel", phase=phaseF,
+ rho_l=rho_F, mu_l=muF, sigma_l=sigF, T_boil=tbF, T_crit=tcrF, h_fg=hfg_F,
+ D0=D32_F, u_inj=u_inj_F, d_orifice=d_jet_F),
+ }
+ lag_chamber = timelag.ChamberThermo(Pc=Pc, Tc=Tc, MR=MR, rho_g=rho_g, u_g=u_gas,
+ k_g=k_g, cp_g=cp_g)
+ # `or` rather than a dict default: an override dict that carries the key with a None value
+ # (a caller passing model_dump() unfiltered) must fall back to the config, not stringify None
+ # into a model name the registry will reject.
+ lag_model = str(ov.get("time_lag_model") or sc.time_lag_model)
+ convection = str(ov.get("convection_model") or sc.convection_model)
+ # The d^2-law is the historical model; it never carried a mixing lag, so selecting it must not
+ # introduce one. Gate on the model having something to do, not merely on the field being set.
+ mix_fraction = float(sc.mixing_lag_fraction) if lag_model == "leonardi_dtl" else 0.0
+ if ov.get("mixing_lag_fraction") is not None:
+ mix_fraction = float(ov["mixing_lag_fraction"]) # 0.0 is meaningful here, so test for None
+ lags = timelag.compute_lags(
+ lag_streams, lag_chamber, model=lag_model, mix_fraction=mix_fraction,
+ convection=convection,
+ on_fallback=lambda name, value, unit, reason: assume(name, value, unit=unit, reason=reason),
+ )
+ tau_conv_O = float(lags["O"].tau_total)
+ tau_conv_F = float(lags["F"].tau_total)
+ K_v_O, K_v_F = float(lags["O"].K_v), float(lags["F"].K_v)
+ if not np.isfinite(tau_conv_O) or tau_conv_O <= 0.0:
tau_conv_O = assume("stability.tau_conv_O", 2.0e-3, unit="s",
- reason="d^2-law oxidizer lag non-finite (check T_boil < Tc and h_fg)")
- if not np.isfinite(tau_conv_F):
+ reason=f"{lag_model} oxidizer lag non-finite (check T_boil < Tc, h_fg, SMD)")
+ if not np.isfinite(tau_conv_F) or tau_conv_F <= 0.0:
tau_conv_F = assume("stability.tau_conv_F", 1.5e-3, unit="s",
- reason="d^2-law fuel lag non-finite (check T_boil < Tc and h_fg)")
+ reason=f"{lag_model} fuel lag non-finite (check T_boil < Tc, h_fg, SMD)")
L_feed_O, A_feed_O = _feed_geometry(config, "oxidizer")
L_feed_F, A_feed_F = _feed_geometry(config, "fuel")
@@ -442,7 +606,16 @@ def build_stability_inputs(config, Pc: float, MR: float, mdot_total: float, csta
chamber = chug.ChugChamber(cstar=cstar, A_t=A_t, Lstar=Lstar, gamma=gamma)
chi_ac = float(ov.get("chi_acoustic", sc.chi_acoustic))
n_int = float(ov.get("n_interaction", sc.n_interaction))
- tau_sens = chi_ac * tau_conv_O # LOX-side rate-limiting; sensitive lag << transport lag [Phys §5]
+ # Sensitive lag for the acoustic n-tau driving. The rate-limiting stream is whichever LIQUID
+ # stream converts slowest -- not "the oxidizer" (this read tau_conv_O unconditionally, which is
+ # only right when the oxidizer happens to be both liquid and slower; on a gas/liquid pair such as
+ # GOX/ethanol it priced the acoustic driving off a stream that has no droplets at all).
+ # Uses the POST-fallback lags: a liquid stream whose lag was non-finite and got substituted is
+ # still a liquid stream and still competes to be the rate-limiting one.
+ _liquid_taus = [tau for k, tau in (("O", tau_conv_O), ("F", tau_conv_F))
+ if not lag_streams[k].is_gas and np.isfinite(tau) and tau > 0]
+ tau_rate_limiting = max(_liquid_taus) if _liquid_taus else max(tau_conv_O, tau_conv_F)
+ tau_sens = chi_ac * tau_rate_limiting # [Phys §5]
# Nozzle-entrance Mach sets the convective (nozzle) damping. Config value if given, else the
# subsonic isentropic solution for the actual contraction ratio (a fixed 0.2 corresponds to a
@@ -464,6 +637,14 @@ def build_stability_inputs(config, Pc: float, MR: float, mdot_total: float, csta
"D_ch": D_ch, "L_ch": L_ch, "Lstar": Lstar, "contraction_ratio": contraction_ratio,
"mach_nozzle_entrance": float(M_ne),
"tau_conv_O": tau_conv_O, "tau_conv_F": tau_conv_F, "tau_sens": tau_sens,
+ "tau_rate_limiting": float(tau_rate_limiting),
+ "lag_model": lag_model, "convection_model": convection, "mixing_lag_fraction": mix_fraction,
+ "lag_breakdown": {k: v.as_dict() for k, v in lags.items()},
+ "phase_O": phaseO, "phase_F": phaseF,
+ "fluid_name_O": _fluid_name(config, "oxidizer") or "oxidizer",
+ "fluid_name_F": _fluid_name(config, "fuel") or "fuel",
+ "injector_type": str(getattr(getattr(config, "injector", None), "type", "") or "unknown"),
+ "d_jet_O": d_jet_O, "d_jet_F": d_jet_F, "u_inj_O": u_inj_O, "u_inj_F": u_inj_F,
"chi_acoustic": chi_ac, "n_interaction": n_int,
"eta_inj_O": eta_O, "eta_inj_F": eta_F,
"D32_O": D32_O, "D32_F": D32_F, "K_v_O": K_v_O, "K_v_F": K_v_F,
diff --git a/EngineDesign/engine/pipeline/stability/chug.py b/EngineDesign/engine/pipeline/stability/chug.py
index d114aac57..370897e49 100644
--- a/EngineDesign/engine/pipeline/stability/chug.py
+++ b/EngineDesign/engine/pipeline/stability/chug.py
@@ -286,11 +286,118 @@ def _dominant_driver(streams: List[ChugStream], s: complex) -> str:
return max(drivers, key=drivers.get)
+def damping_ratio(sigma: float, omega: float) -> float:
+ """Damping ratio of a complex pole ``s = sigma + j*omega``: ``zeta = -sigma / |s|``.
+
+ This is the standard definition — the cosine of the pole's angle from the negative real axis —
+ and it is what a constant-zeta ray on a root locus means. (An earlier version of this module
+ reported ``-sigma/omega``, which is ``zeta/sqrt(1-zeta^2)``: indistinguishable below zeta ~ 0.1
+ and 15 % off by zeta = 0.5, so a pole plotted against its own reported zeta did not sit on the
+ ray. Fixed here so the diagram and the number agree.)
+ """
+ mag = float(np.hypot(sigma, omega))
+ if not np.isfinite(mag) or mag <= 0:
+ return float("nan")
+ return float(-sigma / mag)
+
+
+def _solve_root_near(streams: List[ChugStream], chamber: ChugChamber, s0: complex,
+ *, with_regulator: bool = True) -> Tuple[float, float, float]:
+ """Single-seed Newton solve for the root of F(s)=0 nearest ``s0``. (alpha, omega, |F|).
+
+ The continuation step of the root locus: each point seeds from its predecessor, so one fsolve
+ per point is enough. ``_solve_dominant_root`` fans out over nine seeds because it has no
+ predecessor to start from; doing that per locus point would cost ~40x for no extra accuracy.
+ """
+ from scipy.optimize import fsolve
+
+ def residual(x):
+ F = chug_characteristic(complex(x[0], x[1]), streams, chamber,
+ with_regulator=with_regulator)
+ return [F.real, F.imag]
+
+ try:
+ sol, _, ier, _ = fsolve(residual, [s0.real, s0.imag], full_output=True)
+ except Exception:
+ return float("nan"), float("nan"), float("inf")
+ if ier != 1:
+ return float("nan"), float("nan"), float("inf")
+ resF = abs(complex(*residual(sol)))
+ if resF > 1e-6 or sol[1] <= 0:
+ return float("nan"), float("nan"), float("inf")
+ return float(sol[0]), float(sol[1]), float(resF)
+
+
+def chug_root_locus(streams: List[ChugStream], chamber: ChugChamber,
+ *, eta_values: Optional[np.ndarray] = None,
+ with_regulator: bool = True) -> List[Dict[str, float]]:
+ """Track the dominant chug pole through the s-plane as injector stiffness sweeps.
+
+ This is a root locus in the textbook sense: ``eta_inj = dP_inj/Pc`` is the swept gain, and each
+ returned point is the eigenvalue ``s = sigma + j*omega`` of the closed-loop characteristic
+ equation ``1 + L(s) = 0`` at that gain. The imaginary axis is the stability boundary — the
+ branch crosses it where the loop goes neutrally stable, and the crossing frequency is the chug
+ frequency the engine would ring at.
+
+ Solved by continuation from the softest injector upward, each point seeded on its predecessor,
+ which is the standard way to follow a branch rather than re-discover it. Honest caveat: on every
+ case tried so far (lags 0.8-9 ms, feed runs 0.08-1.5 m, eta from 0.02 to 1.2) a single fixed seed
+ found the same branch, so the continuation is insurance against branch-hopping rather than a
+ demonstrated fix for it. ``test_locus_is_continuous_in_frequency`` checks the OUTPUT is a branch;
+ it does not, and cannot currently, distinguish the two seeding strategies.
+
+ Returns points in ascending ``eta`` with keys ``eta``, ``real``, ``imag``, ``f_hz``, ``zeta``.
+ Points where the branch could not be followed are dropped, so the caller gets a clean polyline.
+ """
+ import copy
+
+ if eta_values is None:
+ eta_values = np.linspace(0.05, 0.60, 28)
+ etas = np.asarray(sorted(float(e) for e in eta_values if np.isfinite(e) and e > 0))
+ if etas.size == 0:
+ return []
+
+ def scaled(eta: float) -> List[ChugStream]:
+ out = []
+ for st in streams:
+ st2 = copy.copy(st)
+ st2.eta_inj = float(eta)
+ out.append(st2)
+ return out
+
+ # Seed the branch from the fast tier's phase crossover at the softest injector, where the loop
+ # is most strongly coupled and the dominant root is least ambiguous.
+ seed_streams = scaled(etas[0])
+ fast = chug_margin_fast(seed_streams, chamber, with_regulator=with_regulator)
+ w0 = 2 * np.pi * fast["f_chug_hz"] if np.isfinite(fast["f_chug_hz"]) else 2 * np.pi * 100.0
+ s_prev = complex(0.0, w0)
+
+ pts: List[Dict[str, float]] = []
+ for eta in etas:
+ st = scaled(eta)
+ a, w, res = _solve_root_near(st, chamber, s_prev, with_regulator=with_regulator)
+ if not (np.isfinite(a) and np.isfinite(w) and w > 0):
+ # Lost the branch: re-acquire from the frequency scan rather than abandoning the sweep.
+ f2 = chug_margin_fast(st, chamber, with_regulator=with_regulator)
+ if not np.isfinite(f2["f_chug_hz"]):
+ continue
+ a, w, res = _solve_root_near(st, chamber, complex(0.0, 2 * np.pi * f2["f_chug_hz"]),
+ with_regulator=with_regulator)
+ if not (np.isfinite(a) and np.isfinite(w) and w > 0):
+ continue
+ s_prev = complex(a, w)
+ pts.append({
+ "eta": float(eta), "real": float(a), "imag": float(w),
+ "f_hz": float(w / (2 * np.pi)), "zeta": damping_ratio(a, w),
+ })
+ return pts
+
+
def chug_growth_rate(streams: List[ChugStream], chamber: ChugChamber,
*, with_regulator: bool = True) -> Dict[str, float]:
"""Rich chug analysis: dominant growth rate alpha and frequency from root-find of (3.3).
- Returns dict: ``alpha`` [1/s], ``f_chug_hz``, ``zeta`` (= -alpha/omega), ``margin`` (= 1+zeta),
+ Returns dict: ``alpha`` [1/s], ``f_chug_hz``, ``zeta`` (= -alpha/|s|), ``margin`` (= 1+zeta),
``stable``, ``alpha_no_reg`` (Z_reg=0 comparison), ``driver``, ``residual``.
"""
fast = chug_margin_fast(streams, chamber, with_regulator=with_regulator)
@@ -304,7 +411,7 @@ def chug_growth_rate(streams: List[ChugStream], chamber: ChugChamber,
"residual": resF,
}
if np.isfinite(alpha) and np.isfinite(omega) and omega > 0:
- zeta = -alpha / omega
+ zeta = damping_ratio(alpha, omega)
out["zeta"] = float(zeta)
out["margin"] = float(1.0 + zeta)
out["stable"] = bool(alpha < 0.0)
diff --git a/EngineDesign/engine/pipeline/stability/report.py b/EngineDesign/engine/pipeline/stability/report.py
index 0a008d572..eaa3bfae8 100644
--- a/EngineDesign/engine/pipeline/stability/report.py
+++ b/EngineDesign/engine/pipeline/stability/report.py
@@ -10,7 +10,7 @@
from __future__ import annotations
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, List, Optional, Tuple
import numpy as np
from engine.pipeline.stability import chug, acoustic, analysis
@@ -22,7 +22,21 @@
# Visualization data builders
# ---------------------------------------------------------------------------
-def _chug_boundary_curve(streams, chamber, n_pts: int = 16) -> List[List[float]]:
+def _eta_window(streams, *, lo_frac: float = 0.35, hi_frac: float = 2.2,
+ floor: float = 0.02, ceil: float = 0.90) -> Tuple[float, float]:
+ """(eta_lo, eta_hi) sweep window bracketing THIS design's injector stiffness.
+
+ The window used to be a fixed 0.08..0.45 for every engine, which puts a design at eta = 0.55
+ off the right edge of its own chart and a design at eta = 0.05 off the left. Anchoring it to
+ the design point keeps the operating dot on the plot whatever the injector does."""
+ etas = [float(s.eta_inj) for s in streams if np.isfinite(s.eta_inj) and s.eta_inj > 0]
+ eta0 = float(np.mean(etas)) if etas else 0.25
+ return (float(max(floor, min(eta0 * lo_frac, 0.15))),
+ float(min(ceil, max(eta0 * hi_frac, 0.45))))
+
+
+def _chug_boundary_curve(streams, chamber, n_pts: int = 16,
+ eta_window: Optional[Tuple[float, float]] = None) -> List[List[float]]:
"""Viz #1: the chug stability boundary in (eta_inj, tau/theta_c). For each eta_inj, bisect on a
lag-scale factor to find where the fast gain margin crosses 1 (marginal). Uses the FAST margin
(cheap; ~n_pts*~12 calls)."""
@@ -31,8 +45,9 @@ def _chug_boundary_curve(streams, chamber, n_pts: int = 16) -> List[List[float]]
if not np.isfinite(theta_c) or theta_c <= 0:
return []
tau0 = float(np.mean([s.tau_conv for s in streams]))
+ lo_eta, hi_eta = eta_window if eta_window else _eta_window(streams)
curve: List[List[float]] = []
- for eta in np.linspace(0.08, 0.45, n_pts):
+ for eta in np.linspace(lo_eta, hi_eta, n_pts):
# scale all streams to this eta; bisect lag factor k in [0.1, 8] for GM(k)=1
def gm_at(kfac: float) -> float:
sc = []
@@ -62,7 +77,15 @@ def _vaporization_profile(inp: Dict[str, Any], Pc: float, n_pts: int = 40) -> Di
D32 = inp["D32_O"]
K_v = inp["K_v_O"]
L_ch = inp["L_ch"]
- rho_O = float(inp.get("rho_O", 1140.0)) # config-sourced via build_stability_inputs (P2c)
+ # Config-sourced via build_stability_inputs (P2c). The old `inp.get("rho_O", 1140.0)` put LOX's
+ # density behind every oxidizer as an invisible default; build_stability_inputs always supplies
+ # it now, and a missing one is recorded rather than substituted.
+ rho_O = inp.get("rho_O")
+ if rho_O is None or not np.isfinite(float(rho_O)) or float(rho_O) <= 0.0:
+ from engine.pipeline.assumptions import assume
+ rho_O = assume("stability.viz.rho_oxidizer", 1140.0, unit="kg/m^3",
+ reason="oxidizer density missing when drawing the vaporization profile")
+ rho_O = float(rho_O)
eta = inp["eta_inj_O"]
# Representative droplet axial speed: the solved oxidizer injection velocity when the closure
# provides it, else Bernoulli with the solved Cd (a fixed Cd of 0.6 used to sit here).
@@ -71,7 +94,12 @@ def _vaporization_profile(inp: Dict[str, Any], Pc: float, n_pts: int = 40) -> Di
v_drop = float(u_O)
else:
Cd = inp.get("Cd_O")
- Cd = float(Cd) if (Cd is not None and np.isfinite(float(Cd)) and float(Cd) > 0.0) else 0.6
+ if Cd is None or not np.isfinite(float(Cd)) or float(Cd) <= 0.0:
+ from engine.pipeline.assumptions import assume
+ Cd = assume("stability.viz.Cd_oxidizer", 0.6, unit="-",
+ reason="solved oxidizer discharge coefficient unavailable for the droplet "
+ "velocity; sharp-edged-orifice value")
+ Cd = float(Cd)
v_drop = Cd * float(np.sqrt(max(2.0 * eta * Pc / rho_O, 1.0)))
tau_vap = inp["tau_conv_O"]
L_vap = v_drop * tau_vap if np.isfinite(tau_vap) else float("nan")
@@ -109,6 +137,23 @@ def _chug_pole(chug_rich: Dict[str, Any]) -> Dict[str, float]:
return {"real": float(alpha), "imag": float(2 * np.pi * f_hz)}
+def _locus_crossing(locus: List[Dict[str, float]]) -> Dict[str, float]:
+ """Where the locus branch crosses the imaginary axis: the neutral-stability gain and frequency.
+
+ Linear interpolation in ``eta`` on the sign change of ``Re(s)``. This is the number a designer
+ reads off a root locus — "stiffen past here and the pole is in the left half-plane" — so it is
+ computed once on the backend rather than eyeballed off the chart."""
+ out = {"eta": float("nan"), "f_hz": float("nan")}
+ for a, b in zip(locus, locus[1:]):
+ if a["real"] == 0.0 or a["real"] * b["real"] < 0.0:
+ da = b["real"] - a["real"]
+ t = (0.0 - a["real"]) / da if da != 0 else 0.0
+ out["eta"] = float(a["eta"] + t * (b["eta"] - a["eta"]))
+ out["f_hz"] = float(a["f_hz"] + t * (b["f_hz"] - a["f_hz"]))
+ break
+ return out
+
+
def _radar(chug_margin: float, ac: Dict[str, Any], vap: Dict[str, Any],
gate_threshold: float, alpha_offset: float) -> Dict[str, Any]:
"""Viz #7: one-glance health radar."""
@@ -267,15 +312,30 @@ def build_rich_report(config, Pc: float, MR: float, mdot_total: float, cstar: fl
chug_rich = chug.chug_growth_rate(streams, chamber)
chug_margin = analysis._chug_gate_margin(
chug.chug_margin_fast(streams, chamber).get("gain_margin", float("nan")))
- boundary = _chug_boundary_curve(streams, chamber)
+ eta_window = _eta_window(streams)
+ boundary = _chug_boundary_curve(streams, chamber, eta_window=eta_window)
theta_c = chamber.theta_c()
design_streams = []
+ lag_break = inp.get("lag_breakdown") or {}
for label, eta, tau in (
("O", inp["eta_inj_O"], inp["tau_conv_O"]),
("F", inp["eta_inj_F"], inp["tau_conv_F"]),
):
tt = float(tau / theta_c) if (np.isfinite(theta_c) and theta_c > 0) else float("nan")
- design_streams.append({"stream": label, "eta_inj": float(eta), "tau_theta_c": tt})
+ lb = lag_break.get(label, {})
+ design_streams.append({
+ "stream": label,
+ "fluid": inp.get(f"fluid_name_{label}", label),
+ "phase": inp.get(f"phase_{label}", "liquid"),
+ "eta_inj": float(eta), "tau_theta_c": tt, "tau_s": float(tau),
+ "tau_atom_s": lb.get("tau_atom_s"), "tau_vap_s": lb.get("tau_vap_s"),
+ "tau_mix_s": lb.get("tau_mix_s"),
+ })
+
+ # Root locus: the dominant eigenvalue tracked through the s-plane as injector stiffness sweeps.
+ locus = chug.chug_root_locus(
+ streams, chamber, eta_values=np.linspace(eta_window[0], eta_window[1], 26))
+ eta_critical = _locus_crossing(locus)
# --- acoustic (full mode set with damping budgets) ---
ac = acoustic.analyze_acoustic_modes(inp["D_ch"], inp["L_ch"], gas,
@@ -317,6 +377,13 @@ def build_rich_report(config, Pc: float, MR: float, mdot_total: float, cstar: fl
"boundary_curve": boundary,
"pole": _chug_pole(chug_rich),
"design_streams": design_streams,
+ "root_locus": locus,
+ "locus_param": "eta_inj",
+ "eta_window": [float(eta_window[0]), float(eta_window[1])],
+ "eta_critical": eta_critical,
+ "lag_model": inp.get("lag_model"),
+ "convection_model": inp.get("convection_model"),
+ "lag_breakdown": lag_break,
},
"acoustic": {"margin": acoustic_margin, "modes": acoustic_modes,
"any_unstable": ac["any_unstable"], "limiting_mode": ac["limiting_mode"]},
@@ -332,6 +399,16 @@ def build_rich_report(config, Pc: float, MR: float, mdot_total: float, cstar: fl
"contraction_ratio": float(inp["contraction_ratio"]),
"feed_length_O_m": float(inp["feed_length_O"]), "feed_length_F_m": float(inp["feed_length_F"]),
"acoustic_gate_alpha_offset": float(inp["acoustic_gate_alpha_offset"]),
+ # Which named models produced this answer, and what the propellants/injector actually
+ # are -- so a report can never be read as if it described a different engine.
+ "time_lag_model": inp.get("lag_model"),
+ "convection_model": inp.get("convection_model"),
+ "mixing_lag_fraction": inp.get("mixing_lag_fraction"),
+ "injector_type": inp.get("injector_type"),
+ "fluid_O": inp.get("fluid_name_O"), "fluid_F": inp.get("fluid_name_F"),
+ "phase_O": inp.get("phase_O"), "phase_F": inp.get("phase_F"),
+ "tau_conv_O_s": float(inp["tau_conv_O"]), "tau_conv_F_s": float(inp["tau_conv_F"]),
+ "lag_breakdown": lag_break,
# Every recorded silent-default substitution this process has made (P2c registry).
# Empty list = config fully specified the physics. The hardcoded-Cd bug class, surfaced.
"fallbacks_used": _fallbacks_used(),
diff --git a/EngineDesign/engine/pipeline/stability/timelag.py b/EngineDesign/engine/pipeline/stability/timelag.py
new file mode 100644
index 000000000..b33b08aa9
--- /dev/null
+++ b/EngineDesign/engine/pipeline/stability/timelag.py
@@ -0,0 +1,451 @@
+"""Conversion time lags for the chug loop — a registry of NAMED models.
+
+The chug characteristic equation (``chug.py``) needs one number per propellant stream: the lag
+between an injection-rate perturbation and the heat release it produces. That number is where the
+propellant and the injector enter the stability problem, so it is the one place that must not
+hardcode either.
+
+Two models, both selectable through ``StabilityConfig.time_lag_model``:
+
+``d2_law``
+ The historical STAR model: ``tau = D32**2 / K_v`` with the Godsave/Spalding evaporation
+ constant. This is the **quiescent** droplet lifetime — no convection, no atomization, no
+ mixing — and it is reproduced here bit-for-bit so old results stay reproducible.
+
+``leonardi_dtl``
+ The double-time-lag decomposition of Leonardi, Nasuti, Di Matteo & Steelant, *"A methodology
+ to study the possible occurrence of chugging in liquid rocket engines during transient
+ start-up"*, Acta Astronautica 139 (2017) 344-356 [hereafter **L17**]:
+
+ tau_tot = tau_atom + tau_vap + tau_mix (L17 eq. 5)
+
+ with a convection-corrected vaporization lag (eq. 8-9) and an atomization lag (eq. 6-7). The
+ decisive structural point is **phase**: L17 §3.2 gives the gaseous propellant *only* the mixing
+ lag, because a gas neither atomizes nor vaporizes. STAR's previous code ran the d^2-law on both
+ streams unconditionally, which silently invents a droplet lifetime for a gas.
+
+What is deliberately NOT implemented
+------------------------------------
+L17 eq. 10 (the initial droplet diameter ``D0``) is a correlation "specifically developed for
+coaxial injectors and liquid oxygen". EngineDesign already solves a Sauter mean diameter with the
+injector-appropriate model — Ingebo for impinging, Lefebvre for coaxial, the pintle sheet model for
+pintle — so ``D0`` is taken from that solve. Adopting eq. 10 would hardcode *one* injector and *one*
+oxidizer into a multi-injector, multi-propellant tool, which is precisely the coupling this module
+exists to remove. (It is also not dimensionally closed as printed: the exponents 2.25 and -2.65 do
+not cancel, so its units depend on ref. [24] and cannot be reconstructed from L17 alone.)
+
+The convection correction (L17 eq. 8) is OFF by default — and why
+-----------------------------------------------------------------
+L17 eq. 8 divides the quiescent droplet lifetime by ``1 + 1.5*alpha``, ``alpha = 1 - 3e-3*pc[bar]``.
+Applying it makes the model *worse* against the paper's own experiment, so it ships behind a named
+switch set to ``"none"``. The evidence (``scripts/chug_timelag_benchmark.py``, benchmarks B and E,
+on the GH2/LOX rig of L17 ref. [25]):
+
+ * Eq. 9's evaporation constant in the QUIESCENT form, ``tau_vap = D0**2 / k``, gives 4.98 ms at
+ L17's own reference point (D0 = 83 um, MR 5, 44.8 bar, 2038 K) against the experiment-derived
+ 4.4 ms — **+13 %**. Apply eq. 8 and it becomes 2.17 ms, **-51 %**.
+ * End-to-end through ``chug.py``, quiescent eq. 9 predicts 53 Hz and a stability boundary at
+ Delta_p_ox/pc = 0.30 against a measured 66 Hz and 0.35. With eq. 8 it is 104 Hz and 0.20.
+ * Eq. 8 also disagrees with Ranz-Marshall in *trend*, not just magnitude: it depends on chamber
+ pressure alone — no slip velocity, no drop size — and it FALLS as Pc rises, while the physical
+ correction does not.
+
+The coherent reading is that eq. 9's ``k`` was already calibrated against real chamber data (L17
+routes its 4.4 ms through Priem-Heidmann's L50/v_inj), so it *contains* the convective enhancement
+and eq. 8 double-counts it. That also reconciles L17's two otherwise-inconsistent statements about
+the same working point. Both corrections remain available through ``CONVECTION_MODELS`` so the run
+report can say which one produced the answer.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Callable, Dict, Optional, Tuple
+
+import numpy as np
+
+from engine.pipeline.stability import core
+
+__all__ = [
+ "StreamThermo",
+ "ChamberThermo",
+ "LagBreakdown",
+ "atomization_lag",
+ "vaporization_lag_leonardi",
+ "evaporation_constant_leonardi",
+ "convection_correction_leonardi",
+ "ranz_marshall_correction",
+ "CONVECTION_MODELS",
+ "DEFAULT_CONVECTION_MODEL",
+ "TIME_LAG_MODELS",
+ "compute_lags",
+ "resolve_mixing_lag",
+ "MASS_HALF_LIFE_FRACTION",
+]
+
+
+#: Fraction of the d^2-law droplet lifetime at which HALF the droplet MASS has vaporized.
+#: Closed form, not a fit: under d^2 = d0^2 (1 - t/tau_vap) the remaining mass fraction is
+#: (1 - t/tau_vap)^{3/2}, so m/m0 = 1/2 at t/tau_vap = 1 - (1/2)^{2/3} = 0.3700.
+#: This is what turns a vaporization lifetime into L17's L50 (the length to vaporize 50% of the
+#: liquid), which is the abscissa of the Szuch mixing-lag curve (L17 fig. 1).
+MASS_HALF_LIFE_FRACTION: float = float(1.0 - 0.5 ** (2.0 / 3.0))
+
+
+# ---------------------------------------------------------------------------
+# Parameter containers
+# ---------------------------------------------------------------------------
+
+@dataclass(frozen=True)
+class StreamThermo:
+ """One propellant stream at the injector face. All SI.
+
+ ``phase`` is ``"liquid"`` or ``"gas"`` **at injection conditions** and decides which lags apply
+ at all — it is not a cosmetic label. ``D0`` is the initial drop size from the injector's own
+ spray model (SMD), ``d_orifice`` is L17's liquid post inner diameter ``D_l``, and ``u_inj`` is
+ its injection velocity ``u_l``.
+ """
+ name: str
+ phase: str
+ rho_l: float = float("nan")
+ mu_l: float = float("nan")
+ sigma_l: float = float("nan")
+ T_boil: float = float("nan")
+ T_crit: float = float("nan")
+ h_fg: float = float("nan")
+ D0: float = float("nan")
+ u_inj: float = float("nan")
+ d_orifice: float = float("nan")
+
+ @property
+ def is_gas(self) -> bool:
+ return str(self.phase).lower().startswith("g")
+
+
+@dataclass(frozen=True)
+class ChamberThermo:
+ """Chamber gas state the lags are evaluated against. All SI except where noted."""
+ Pc: float
+ Tc: float
+ MR: float
+ rho_g: float
+ u_g: float
+ k_g: float
+ cp_g: float
+
+ @property
+ def Pc_bar(self) -> float:
+ return float(self.Pc / 1.0e5)
+
+
+@dataclass(frozen=True)
+class LagBreakdown:
+ """Per-stream lag decomposition. ``tau_total`` is what the chug loop consumes as ``tau_conv``."""
+ stream: str
+ model: str
+ phase: str
+ convection: str
+ tau_atom: float
+ tau_vap: float
+ tau_mix: float
+ tau_total: float
+ K_v: float = float("nan")
+ notes: Tuple[str, ...] = field(default_factory=tuple)
+
+ def as_dict(self) -> Dict[str, object]:
+ return {
+ "stream": self.stream, "model": self.model, "phase": self.phase,
+ "convection": self.convection,
+ "tau_atom_s": float(self.tau_atom), "tau_vap_s": float(self.tau_vap),
+ "tau_mix_s": float(self.tau_mix), "tau_total_s": float(self.tau_total),
+ "K_v": float(self.K_v), "notes": list(self.notes),
+ }
+
+
+# ---------------------------------------------------------------------------
+# L17 correlations
+# ---------------------------------------------------------------------------
+
+def atomization_lag(st: StreamThermo, ch: ChamberThermo) -> float:
+ """Atomization lag [s]. **L17 eq. 6-7** (after Boronine, Vollmer & Frey, ref. [23]):
+
+ tau_atom = 6e-4 * (rho_g/rho_l)^-0.32 * We_g^0.03 * Re_l^0.55 * D_l/u_l
+ Re_l = u_l D_l rho_l / mu_l
+ We_g = 2 rho_g (u_g - u_l)^2 D_l / sigma_l
+
+ Returns 0.0 for a gas stream (nothing to atomize) and NaN when an input is missing, so a caller
+ that cannot supply injector geometry gets a recorded fallback rather than a fabricated lag.
+ """
+ if st.is_gas:
+ return 0.0
+ D_l, u_l = float(st.d_orifice), float(st.u_inj)
+ if not (np.isfinite(D_l) and D_l > 0 and np.isfinite(u_l) and u_l > 0):
+ return float("nan")
+ if not (np.isfinite(st.rho_l) and st.rho_l > 0 and np.isfinite(ch.rho_g) and ch.rho_g > 0):
+ return float("nan")
+ if not (np.isfinite(st.mu_l) and st.mu_l > 0 and np.isfinite(st.sigma_l) and st.sigma_l > 0):
+ return float("nan")
+ Re_l = u_l * D_l * st.rho_l / st.mu_l
+ We_g = 2.0 * ch.rho_g * (float(ch.u_g) - u_l) ** 2 * D_l / st.sigma_l
+ # We_g -> 0 when the gas and the jet move together; We^0.03 is a very weak power, but 0**0.03
+ # is 0 and would zero the lag outright, so the degenerate case falls back to the We-free form.
+ we_term = float(We_g ** 0.03) if We_g > 0 else 1.0
+ return float(6.0e-4 * (ch.rho_g / st.rho_l) ** (-0.32) * we_term * Re_l ** 0.55 * (D_l / u_l))
+
+
+def evaporation_constant_leonardi(MR: float, T_inf: float, T_crit: float) -> float:
+ """L17 eq. 9 evaporation constant ``k`` [m^2/s]:
+
+ k = 1e-6 * [ 1.01/(1 + MR) + 1.16e-3 (T_inf - T_cr)^0.93 ]^0.86
+
+ ``T_inf`` is the combustion-gas temperature [K] and ``T_cr`` the LIQUID critical temperature
+ [K] — a propellant property, which is why it is a per-stream input here and not a constant.
+ """
+ if not (np.isfinite(MR) and MR > -1.0):
+ return float("nan")
+ if not (np.isfinite(T_inf) and np.isfinite(T_crit)):
+ return float("nan")
+ dT = float(T_inf) - float(T_crit)
+ if dT <= 0.0:
+ # Chamber colder than the liquid's critical point: the correlation's (T_inf - T_cr)^0.93
+ # is undefined. Report NaN rather than clipping — a caller must record the substitution.
+ return float("nan")
+ inner = 1.01 / (1.0 + float(MR)) + 1.16e-3 * dT ** 0.93
+ if inner <= 0.0:
+ return float("nan")
+ return float(1.0e-6 * inner ** 0.86)
+
+
+def convection_correction_leonardi(Pc_bar: float) -> float:
+ """L17 eq. 8 convective speed-up factor ``1 + 1.5*alpha`` with ``alpha = 1 - 3e-3*pc[bar]``.
+
+ ``tau_vap = tau_vap(Re=0) / (1 + 1.5*alpha)`` — a droplet in a crossflow lives shorter than a
+ quiescent one. Note the factor FALLS with chamber pressure (alpha -> 0 near 333 bar), i.e. the
+ correlation says the convective enhancement washes out at high Pc. Floored at 1.0 so the
+ correction can never *lengthen* the lag past the quiescent value.
+ """
+ if not np.isfinite(Pc_bar):
+ return float("nan")
+ alpha = 1.0 - 3.0e-3 * float(Pc_bar)
+ return float(max(1.0, 1.0 + 1.5 * alpha))
+
+
+def ranz_marshall_correction(Re_d: float, Pr: float = 0.7) -> float:
+ """Textbook convective correction ``Nu/2 = 1 + 0.3 Re_d^0.5 Pr^(1/3)`` (Ranz-Marshall).
+
+ Not used by any model — it is the independent yardstick the benchmark scores L17 eq. 8 against,
+ so a disagreement shows up as a number instead of an assumption. ``Re_d`` is the droplet
+ Reynolds number based on the slip velocity and the drop diameter.
+ """
+ if not (np.isfinite(Re_d) and Re_d >= 0 and np.isfinite(Pr) and Pr > 0):
+ return float("nan")
+ return float(1.0 + 0.3 * np.sqrt(Re_d) * Pr ** (1.0 / 3.0))
+
+
+def _slip_reynolds(st: StreamThermo, ch: ChamberThermo, mu_g: float = 7.0e-5) -> float:
+ """Droplet Reynolds number on the gas-droplet slip velocity. Used only by ``ranz_marshall``."""
+ u_l = float(st.u_inj) if np.isfinite(st.u_inj) else 0.0
+ slip = abs(float(ch.u_g) - u_l)
+ if not (np.isfinite(slip) and slip > 0 and np.isfinite(st.D0) and st.D0 > 0):
+ return float("nan")
+ return float(ch.rho_g * slip * st.D0 / mu_g)
+
+
+#: Named convective speed-up factors for the droplet lifetime: ``tau = tau_quiescent / factor``.
+#: ``"none"`` is the default — see the module docstring for the benchmark that decided it.
+CONVECTION_MODELS: Dict[str, Callable[[StreamThermo, ChamberThermo], float]] = {
+ "none": lambda st, ch: 1.0,
+ "leonardi_eq8": lambda st, ch: convection_correction_leonardi(ch.Pc_bar),
+ "ranz_marshall": lambda st, ch: ranz_marshall_correction(_slip_reynolds(st, ch)),
+}
+
+#: Ships off. L17 eq. 9's evaporation constant already carries the convective enhancement
+#: (benchmark B: quiescent +13 % vs the experiment-derived anchor, eq. 8-corrected -51 %).
+DEFAULT_CONVECTION_MODEL = "none"
+
+
+def vaporization_lag_leonardi(st: StreamThermo, ch: ChamberThermo,
+ *, convection: str = DEFAULT_CONVECTION_MODEL) -> float:
+ """Vaporization lag [s] from **L17 eq. 9**'s evaporation constant: ``tau = D0**2 / k / factor``.
+
+ ``convection`` names an entry of ``CONVECTION_MODELS``; the default ``"none"`` leaves the
+ quiescent form, which is the variant that matches the experiment (module docstring). 0.0 for a
+ gas stream.
+ """
+ if st.is_gas:
+ return 0.0
+ D0 = float(st.D0)
+ if not (np.isfinite(D0) and D0 > 0):
+ return float("nan")
+ k = evaporation_constant_leonardi(ch.MR, ch.Tc, st.T_crit)
+ if not (np.isfinite(k) and k > 0):
+ return float("nan")
+ fn = CONVECTION_MODELS.get(str(convection))
+ if fn is None:
+ raise ValueError(
+ f"unknown convection model {convection!r}; known: {sorted(CONVECTION_MODELS)}"
+ )
+ corr = fn(st, ch)
+ if not (np.isfinite(corr) and corr > 0):
+ # A correction that cannot be evaluated must not silently become 1.0 — that would be a
+ # different model wearing this one's name. Report NaN and let the caller record it.
+ return float("nan")
+ return float((D0 * D0 / k) / corr)
+
+
+def vaporization_lag_d2(st: StreamThermo, ch: ChamberThermo) -> Tuple[float, float]:
+ """Quiescent d^2-law lag [s] and its evaporation constant K_v — STAR's historical model.
+
+ Exactly ``core.lags_from_smd(..., chi=1.0)``: same call, same arguments, same order, so the
+ ``d2_law`` branch is bit-for-bit what the code did before this module existed.
+ """
+ if st.is_gas:
+ return 0.0, float("nan")
+ tau_vap, _, K_v = core.lags_from_smd(
+ st.D0, k_g=ch.k_g, rho_l=st.rho_l, cp_g=ch.cp_g, T_inf=ch.Tc,
+ T_boil=st.T_boil, h_fg=st.h_fg, chi=1.0,
+ )
+ return float(tau_vap), float(K_v)
+
+
+# ---------------------------------------------------------------------------
+# Mixing lag (shared by every stream — it is a chamber property, not a stream property)
+# ---------------------------------------------------------------------------
+
+def resolve_mixing_lag(tau_vap_liquid: float, mix_fraction: float) -> float:
+ """Mixing lag [s] shared by all streams, as a fraction of the rate-limiting vaporization lag.
+
+ **Why a fraction and not L17 fig. 1.** The paper reads tau_mix off Szuch's empirical curve of
+ mixing time versus L50 (the length to vaporize 50% of the liquid, NASA TN-D-7026). That curve is
+ a figure, not a table, and is not reproduced here; digitizing it by eye would be inventing a
+ correlation. What L17 *does* state numerically is its own calibration point — tau_vap = 4.4 ms
+ and tau_mix = 2.2 ms for the validation engine (L17 §3.1), i.e. a ratio of 0.5 — and L50 is
+ itself proportional to tau_vap at fixed droplet speed (``MASS_HALF_LIFE_FRACTION``), so a
+ constant of proportionality is the faithful reduction of the curve to one number.
+
+ The default 0.5 therefore carries the paper's provenance, and the caller records it through the
+ assumptions registry. Swap in a digitized curve by replacing this function, not by tuning 0.5.
+ """
+ if not (np.isfinite(tau_vap_liquid) and tau_vap_liquid >= 0):
+ return float("nan")
+ if not (np.isfinite(mix_fraction) and mix_fraction >= 0):
+ return float("nan")
+ return float(mix_fraction * tau_vap_liquid)
+
+
+# ---------------------------------------------------------------------------
+# Model registry
+# ---------------------------------------------------------------------------
+
+_ModelResult = Tuple[float, float, float, float, Tuple[str, ...]]
+
+
+def _model_d2_law(st: StreamThermo, ch: ChamberThermo, convection: str) -> _ModelResult:
+ """-> (tau_atom, tau_vap, K_v, tau_mix_basis, notes). tau_mix is applied by ``compute_lags``.
+
+ ``convection`` is accepted and ignored: the d^2-law is the historical model and is reproduced
+ unchanged, corrections included (there were none).
+ """
+ tau_vap, K_v = vaporization_lag_d2(st, ch)
+ notes: Tuple[str, ...] = ()
+ if st.is_gas:
+ notes = ("gas at injection: no atomization or vaporization lag",)
+ return 0.0, tau_vap, K_v, tau_vap, notes
+
+
+def _model_leonardi(st: StreamThermo, ch: ChamberThermo, convection: str) -> _ModelResult:
+ notes_l = []
+ if st.is_gas:
+ # Belt and braces: both primitives below already return 0.0 for a gas, so deleting this
+ # early return changes no number today (a mutation test confirms it). It stays because it
+ # is where L17 §3.2's rule is legible -- but do not remove it on the grounds that it is
+ # redundant without re-checking that BOTH primitives still guard the gas case themselves.
+ notes_l.append("gas at injection: no atomization or vaporization lag (L17 §3.2)")
+ return 0.0, 0.0, float("nan"), 0.0, tuple(notes_l)
+ tau_atom = atomization_lag(st, ch)
+ if not np.isfinite(tau_atom):
+ notes_l.append("atomization lag unavailable (injector jet diameter/velocity missing)")
+ tau_vap = vaporization_lag_leonardi(st, ch, convection=convection)
+ K_v = float("nan")
+ if not np.isfinite(tau_vap):
+ notes_l.append(
+ "L17 eq. 9 unavailable (needs the liquid critical temperature and Tc > T_crit); "
+ "fell back to the d^2-law for this stream"
+ )
+ tau_vap, K_v = vaporization_lag_d2(st, ch)
+ elif convection != "none":
+ notes_l.append(f"convective speed-up applied: {convection}")
+ return tau_atom, tau_vap, K_v, tau_vap, tuple(notes_l)
+
+
+#: name -> callable. Keep the keys stable: they are written into config files and report payloads.
+TIME_LAG_MODELS: Dict[str, Callable[[StreamThermo, ChamberThermo, str], _ModelResult]] = {
+ "d2_law": _model_d2_law,
+ "leonardi_dtl": _model_leonardi,
+}
+
+
+def compute_lags(
+ streams: Dict[str, StreamThermo],
+ ch: ChamberThermo,
+ *,
+ model: str = "d2_law",
+ mix_fraction: float = 0.0,
+ convection: str = DEFAULT_CONVECTION_MODEL,
+ on_fallback: Optional[Callable[[str, float, str, str], float]] = None,
+) -> Dict[str, LagBreakdown]:
+ """Lag breakdown for every stream under one named model.
+
+ ``mix_fraction`` scales the shared mixing lag off the SLOWEST liquid stream's vaporization lag
+ (the rate-limiting one — L17 assigns the same tau_mix to both propellants). Pass 0.0 to disable
+ it, which is what ``d2_law`` does by default so it reproduces the historical numbers exactly.
+
+ ``on_fallback(name, value, unit, reason) -> value`` is the assumptions hook; the integration
+ layer passes ``assumptions.assume`` so nothing is substituted silently. Defaults to identity for
+ pure-numeric use (tests, the benchmark script).
+ """
+ fb = on_fallback if on_fallback is not None else (lambda name, value, unit, reason: value)
+ fn = TIME_LAG_MODELS.get(str(model))
+ if fn is None:
+ raise ValueError(
+ f"unknown time_lag_model {model!r}; known models: {sorted(TIME_LAG_MODELS)}"
+ )
+
+ if str(convection) not in CONVECTION_MODELS:
+ raise ValueError(
+ f"unknown convection model {convection!r}; known: {sorted(CONVECTION_MODELS)}"
+ )
+ raw = {key: fn(st, ch, str(convection)) for key, st in streams.items()}
+
+ # Mixing lag: one number for the whole chamber, scaled off the slowest LIQUID stream.
+ liquid_taus = [
+ r[3] for key, r in raw.items()
+ if not streams[key].is_gas and np.isfinite(r[3]) and r[3] > 0
+ ]
+ if mix_fraction > 0.0 and liquid_taus:
+ tau_mix = resolve_mixing_lag(max(liquid_taus), mix_fraction)
+ else:
+ tau_mix = 0.0
+
+ out: Dict[str, LagBreakdown] = {}
+ for key, st in streams.items():
+ tau_atom, tau_vap, K_v, _, notes = raw[key]
+ ta = tau_atom if np.isfinite(tau_atom) else fb(
+ f"stability.tau_atom_{key}", 0.0, "s",
+ f"{model}: atomization lag for the {key} stream needs the injector jet diameter and "
+ f"injection velocity; neither reached the stability layer",
+ )
+ tv = tau_vap
+ if not np.isfinite(tv):
+ tv = fb(
+ f"stability.tau_vap_{key}", 2.0e-3, "s",
+ f"{model}: vaporization lag non-finite for {st.name or key} "
+ f"(check T_boil < Tc, h_fg > 0, and a positive SMD)",
+ )
+ tm = tau_mix if np.isfinite(tau_mix) else 0.0
+ out[key] = LagBreakdown(
+ stream=key, model=str(model), phase=str(st.phase), convection=str(convection),
+ tau_atom=float(ta), tau_vap=float(tv), tau_mix=float(tm),
+ tau_total=float(ta + tv + tm), K_v=float(K_v), notes=notes,
+ )
+ return out
diff --git a/EngineDesign/frontend/src/components/stability/ChugRootLocus.tsx b/EngineDesign/frontend/src/components/stability/ChugRootLocus.tsx
new file mode 100644
index 000000000..f6d2b6441
--- /dev/null
+++ b/EngineDesign/frontend/src/components/stability/ChugRootLocus.tsx
@@ -0,0 +1,326 @@
+import type { StabilityRichPayload } from './types';
+import { VizCard, MUTED, STABLE, UNSTABLE, DESIGN } from './shared';
+
+/**
+ * Root locus of the chug loop in the s-plane.
+ *
+ * The characteristic equation is 1 + L(s) = 0 with L the open-loop transfer of the
+ * feed -> injector -> chamber -> combustion path. Each point on the branch is an
+ * eigenvalue s = sigma + j*omega of that closed loop at one value of the swept gain,
+ * eta_inj = dP_inj/Pc. The imaginary axis is the stability boundary: sigma < 0 (left
+ * half-plane) means any chug oscillation decays.
+ *
+ * This replaces a chart that plotted a marginal boundary in (eta, tau/theta_c) and
+ * mentioned the pole only as a line of text underneath — which showed neither the
+ * eigenvalues nor the axis they have to stay left of.
+ */
+
+const ZETA_RAYS = [0.1, 0.3, 0.5];
+
+interface Props {
+ data: StabilityRichPayload;
+}
+
+export function ChugRootLocus({ data }: Props) {
+ const locus = data.chug.root_locus ?? [];
+ const poleSigma = data.chug.pole?.real ?? data.chug.alpha ?? NaN;
+ const poleOmega = data.chug.pole?.imag ?? (data.chug.freq_hz ?? 0) * 2 * Math.PI;
+ const havePole = Number.isFinite(poleSigma) && Number.isFinite(poleOmega);
+ const critical = data.chug.eta_critical;
+
+ if (locus.length < 2 && !havePole) {
+ return (
+
+
+ The chug root-find did not converge for this evaluation, so there is no locus to draw.
+
+
+ );
+ }
+
+ const sigmas = locus.map((p) => p.real).concat(havePole ? [poleSigma] : []);
+ const omegas = locus.map((p) => p.imag).concat(havePole ? [poleOmega] : []);
+
+ // Keep sigma = 0 inside the frame always — the whole point of the chart is which side of it
+ // the eigenvalues sit on, so the boundary must never be cropped out.
+ const sMin = Math.min(...sigmas, 0);
+ const sMax = Math.max(...sigmas, 0);
+ const sPad = Math.max((sMax - sMin) * 0.18, Math.abs(sMax - sMin) < 1e-9 ? 1 : 0);
+ const xMin = sMin - sPad;
+ const xMax = sMax + sPad;
+ const yMax = Math.max(...omegas, 1) * 1.18;
+ const yMin = 0;
+
+ const W = 320;
+ const H = 260;
+ const pad = { l: 52, r: 30, t: 22, b: 52 };
+ const plotW = W - pad.l - pad.r;
+ const plotH = H - pad.t - pad.b;
+
+ const toX = (s: number) => pad.l + ((s - xMin) / (xMax - xMin)) * plotW;
+ const toY = (w: number) => pad.t + plotH - ((w - yMin) / (yMax - yMin)) * plotH;
+
+ const x0 = toX(0); // the stability boundary
+
+ const branch = locus.map((p) => `${toX(p.real)},${toY(p.imag)}`).join(' ');
+
+ // Ticks on round numbers (1/2/5 x 10^k), not on evenly-divided data extents — a growth-rate
+ // axis reading "-42, -19, 4, 50" is unreadable and hides where zero is.
+ const niceTicks = (lo: number, hi: number, target = 5): number[] => {
+ const span = hi - lo;
+ if (!Number.isFinite(span) || span <= 0) return [lo];
+ const raw = span / target;
+ const mag = Math.pow(10, Math.floor(Math.log10(raw)));
+ const norm = raw / mag;
+ const step = (norm >= 5 ? 5 : norm >= 2 ? 2 : 1) * mag;
+ const first = Math.ceil(lo / step) * step;
+ const out: number[] = [];
+ for (let v = first; v <= hi + step * 1e-9; v += step) out.push(Math.abs(v) < step * 1e-9 ? 0 : v);
+ return out;
+ };
+ const xTicks = niceTicks(xMin, xMax);
+ const yTicks = niceTicks(yMin, yMax);
+ const decimals = (ticks: number[]) => {
+ const step = ticks.length > 1 ? Math.abs(ticks[1] - ticks[0]) : 1;
+ return step >= 1 ? 0 : step >= 0.1 ? 1 : 2;
+ };
+ const xDec = decimals(xTicks);
+ const yDec = decimals(yTicks);
+
+ // Constant-zeta rays: zeta = -sigma/|s|, so the ray leaves the origin at angle
+ // atan2(omega, sigma) with sigma = -zeta*r, omega = sqrt(1-zeta^2)*r. Each ray is clipped
+ // where it leaves the frame, and labelled there, so the labels never pile up in a corner.
+ const rays = ZETA_RAYS.map((z) => {
+ const dirX = -z;
+ const dirY = Math.sqrt(1 - z * z);
+ // scale until the ray exits through the left edge or the top edge, whichever comes first
+ const tLeft = dirX < 0 ? xMin / dirX : Infinity;
+ const tTop = dirY > 0 ? yMax / dirY : Infinity;
+ const t = Math.min(tLeft, tTop);
+ const exitsTop = tTop <= tLeft;
+ return { z, x: dirX * t, y: dirY * t, exitsTop };
+ }).filter((r) => Number.isFinite(r.x) && Number.isFinite(r.y));
+
+ const arrowAt = locus.length > 3 ? locus[Math.floor(locus.length * 0.62)] : null;
+ const arrowPrev = locus.length > 3 ? locus[Math.floor(locus.length * 0.62) - 1] : null;
+
+ const fmt = (v: number, d = 1) => (Number.isFinite(v) ? v.toFixed(d) : '—');
+ const poleStable = poleSigma < 0;
+ const poleZeta = data.chug.zeta;
+ const eFoldMs = Number.isFinite(poleSigma) && Math.abs(poleSigma) > 1e-6
+ ? 1000 / Math.abs(poleSigma)
+ : NaN;
+
+ return (
+
+
+
+
+
+ locus (η_inj sweep)
+
+
+ ✕ design point
+
+
+ σ = 0 boundary
+
+
+ Every point on the blue branch is a root of
+ the chug characteristic equation 1 + L(s) = 0 at one injector stiffness; the arrow points
+ toward stiffer injectors. The ✕ is this design. σ is the growth rate — negative means a chug
+ oscillation dies out, positive means it builds. ω is how fast it oscillates while it does.
+ {critical && Number.isFinite(critical.eta) ? (
+ <>
+ {' '}The branch crosses into the left half-plane at{' '}
+ η_inj = {fmt(critical.eta, 3)},
+ so that is the injector ΔP/Pc this engine has to beat.
+ >
+ ) : null}
+
+
+ The sweep moves both propellant streams to the same η_inj, while the ✕ is solved at each
+ stream's own η — so the ✕ sits near the branch rather than exactly on it whenever the two
+ injector stiffnesses differ.
+
- )}
-
- {/* per-stream coordinates that put the dots on the map */}
+
+ {/* Per-stream coordinates, and the lag decomposition that produced the y coordinate.
+ τ is a sum (Leonardi 2017 eq. 5), so showing only the total hides which term to attack. */}
+ τ terms in ms.{' '}
+ {lagModel === 'leonardi_dtl'
+ ? 'Double time lag τ = τ_at + τ_vap + τ_mix (Leonardi et al., Acta Astronautica 139, 2017). A gas-phase propellant carries only τ_mix.'
+ : lagModel === 'd2_law'
+ ? 'Quiescent d²-law droplet lifetime — no atomization or mixing term.'
+ : ''}
+
Each dot is a propellant stream at its injector stiffness (x) and combustion lag (y). Dots
@@ -200,13 +214,6 @@ export function ChugStabilityMap({ data, etaInjOverride }: Props) {
(η_inj → moves right) or atomize finer{' '}
(smaller SMD shortens the lag → moves down).
- {poleFinite && (
-
- The pole is the actual root of the chug loop: σ<0 means any chug oscillation decays
- {decayMs != null ? `, shrinking ~3× every ${decayMs} ms` : ''}. ζ is its damping ratio
- (higher = better damped).
-
- )}
);
}
diff --git a/EngineDesign/frontend/src/components/stability/StabilityGlossary.tsx b/EngineDesign/frontend/src/components/stability/StabilityGlossary.tsx
index 3a6163b06..78ac8d244 100644
--- a/EngineDesign/frontend/src/components/stability/StabilityGlossary.tsx
+++ b/EngineDesign/frontend/src/components/stability/StabilityGlossary.tsx
@@ -17,15 +17,28 @@ const GROUPS: { title: string; entries: Entry[] }[] = [
'how fast an oscillation grows (α>0, unstable) or decays (α<0, stable). Larger |α| = faster.',
},
{
- sym: 'σ, ω',
- name: 'chug pole',
+ sym: 's = σ + jω',
+ name: 'chug eigenvalue (pole)',
meaning:
- 'σ = growth rate [1/s] (same sign rule as α); ω = oscillation rate [rad/s], with ω = 2π·f.',
+ 'the root of the chug characteristic equation 1 + L(s) = 0. σ = growth rate [1/s] (same sign rule as α); ω = oscillation rate [rad/s], with f = ω/2π.',
+ },
+ {
+ sym: 'root locus',
+ name: 's-plane branch',
+ meaning:
+ 'the path that eigenvalue traces as one design parameter is swept — here injector stiffness η_inj. The vertical σ = 0 line is the stability boundary: left of it the oscillation decays.',
},
{
sym: 'ζ',
name: 'damping ratio',
- meaning: 'how damped the chug pole is. ζ>0 decays; larger = more damped.',
+ meaning:
+ 'ζ = −σ/|s|, the cosine of the pole angle from the negative real axis. ζ>0 decays; larger = more damped. The dashed rays on the root locus are lines of constant ζ.',
+ },
+ {
+ sym: 'η_crit',
+ name: 'neutral-stability stiffness',
+ meaning:
+ 'the η_inj where the locus crosses σ = 0. Stiffen past it and the chug pole moves into the stable half-plane.',
},
{
sym: 'margin',
@@ -35,6 +48,35 @@ const GROUPS: { title: string; entries: Entry[] }[] = [
},
],
},
+ {
+ title: 'The conversion lag (why the pole sits where it does)',
+ entries: [
+ {
+ sym: 'τ_at',
+ name: 'atomization lag',
+ meaning:
+ 'time for the liquid jet to break into drops. Scales with jet diameter and Reynolds number (Leonardi 2017 eq. 6).',
+ },
+ {
+ sym: 'τ_vap',
+ name: 'vaporization lag',
+ meaning:
+ 'time for those drops to become vapour. Scales with SMD² — atomization is a quadratic lever on stability.',
+ },
+ {
+ sym: 'τ_mix',
+ name: 'mixing lag',
+ meaning:
+ 'time for the vapour to mix before it burns. Shared by both streams and set by the slower one. A gas-phase propellant carries this lag and nothing else.',
+ },
+ {
+ sym: 'θ_c',
+ name: 'chamber residence time',
+ meaning:
+ 'L*/(Γ²c*), how long gas stays in the chamber. τ/θ_c is the lag that matters — a long lag is only dangerous relative to this.',
+ },
+ ],
+ },
{
title: 'Levers you can change (the sliders)',
entries: [
diff --git a/EngineDesign/frontend/src/components/stability/StabilityPanel.tsx b/EngineDesign/frontend/src/components/stability/StabilityPanel.tsx
index eed2bf0a1..a9a0d4045 100644
--- a/EngineDesign/frontend/src/components/stability/StabilityPanel.tsx
+++ b/EngineDesign/frontend/src/components/stability/StabilityPanel.tsx
@@ -2,6 +2,7 @@ import type { StabilityRichPayload, StabilityOverrides } from './types';
import { StabilityDiagnostics } from './StabilityDiagnostics';
import { StabilityGlossary } from './StabilityGlossary';
import { ChugStabilityMap } from './ChugStabilityMap';
+import { ChugRootLocus } from './ChugRootLocus';
import { AcousticDampingBars } from './AcousticDampingBars';
import { PhaseClock } from './PhaseClock';
import { VaporizationProfile } from './VaporizationProfile';
@@ -71,6 +72,8 @@ export function StabilityPanel({
const smd = overrides.smd_um ?? data.assumptions.smd_O_um;
const nVal = overrides.n_interaction ?? data.assumptions.n;
const chi = overrides.chi_acoustic ?? data.assumptions.chi_acoustic;
+ const lagModel = overrides.time_lag_model ?? data.assumptions.time_lag_model ?? 'leonardi_dtl';
+ const convModel = overrides.convection_model ?? data.assumptions.convection_model ?? 'none';
const setOverride = (patch: Partial) => {
onOverridesChange?.({ ...overrides, ...patch });
@@ -101,11 +104,48 @@ export function StabilityPanel({
{interactive && onOverridesChange && (
-
- setOverride({ eta_inj_O: v })} />
- setOverride({ smd_um: v })} />
- setOverride({ n_interaction: v })} />
- setOverride({ chi_acoustic: v })} />
+
+
+ setOverride({ eta_inj_O: v })} />
+ setOverride({ smd_um: v })} />
+ setOverride({ n_interaction: v })} />
+ setOverride({ chi_acoustic: v })} />
+
+
+
+
+
+
+ Against the GH2/LOX chug rig these models were validated on, the double time lag with no
+ convective correction reproduced the measured 66 Hz and the measured stability boundary
+ roughly six times more closely than the d²-law alone — and unlike the d²-law it does not
+ depend on the hot-gas conductivity, which nobody measures. Switch models here to see the
+ eigenvalues move.
+
)}
@@ -115,6 +155,7 @@ export function StabilityPanel({
+
diff --git a/EngineDesign/frontend/src/components/stability/types.ts b/EngineDesign/frontend/src/components/stability/types.ts
index 4f4adca11..c941a924d 100644
--- a/EngineDesign/frontend/src/components/stability/types.ts
+++ b/EngineDesign/frontend/src/components/stability/types.ts
@@ -1,5 +1,19 @@
/** Rich stability payload from results.stability_rich (plan §A5). */
+/** Per-stream conversion-lag decomposition (Leonardi 2017 eq. 5: tau_atom + tau_vap + tau_mix). */
+export interface LagBreakdown {
+ stream: string;
+ model: string;
+ phase: string;
+ convection: string;
+ tau_atom_s: number;
+ tau_vap_s: number;
+ tau_mix_s: number;
+ tau_total_s: number;
+ K_v: number;
+ notes: string[];
+}
+
export interface StabilityRichPayload {
summary: {
state: 'stable' | 'marginal' | 'unstable';
@@ -19,11 +33,41 @@ export interface StabilityRichPayload {
chug: {
alpha?: number;
freq_hz?: number;
+ /** Damping ratio of the dominant pole, zeta = -sigma/|s| (standard definition). */
zeta?: number;
margin: number;
boundary_curve: [number, number][];
pole?: { real: number; imag: number };
- design_streams?: Array<{ stream: string; eta_inj: number; tau_theta_c: number }>;
+ design_streams?: Array<{
+ stream: string;
+ /** Actual propellant name from the config — never assume "LOX"/"fuel". */
+ fluid?: string;
+ /** "liquid" | "gas" at the injector face; a gas carries only the mixing lag. */
+ phase?: string;
+ eta_inj: number;
+ tau_theta_c: number;
+ tau_s?: number;
+ tau_atom_s?: number | null;
+ tau_vap_s?: number | null;
+ tau_mix_s?: number | null;
+ }>;
+ /** s-plane eigenvalues of 1 + L(s) = 0 tracked as eta_inj sweeps (the root locus). */
+ root_locus?: Array<{
+ eta: number;
+ /** Re(s) = sigma, growth rate [1/s]. */
+ real: number;
+ /** Im(s) = omega [rad/s]. */
+ imag: number;
+ f_hz: number;
+ zeta: number;
+ }>;
+ locus_param?: string;
+ eta_window?: [number, number];
+ /** Where the locus crosses the imaginary axis — the neutral-stability stiffness. */
+ eta_critical?: { eta: number; f_hz: number };
+ lag_model?: string;
+ convection_model?: string;
+ lag_breakdown?: Record;
};
acoustic: {
margin: number;
@@ -57,6 +101,19 @@ export interface StabilityRichPayload {
eta_inj_F: number;
smd_O_um: number;
dP_reg_max_psi?: number;
+ /** Named models that produced this answer — printed so a report can't be misread. */
+ time_lag_model?: string;
+ convection_model?: string;
+ mixing_lag_fraction?: number;
+ injector_type?: string;
+ fluid_O?: string;
+ fluid_F?: string;
+ phase_O?: string;
+ phase_F?: string;
+ tau_conv_O_s?: number;
+ tau_conv_F_s?: number;
+ lag_breakdown?: Record;
+ fallbacks_used?: Array<{ name: string; value: unknown; unit?: string; reason?: string; count?: number }>;
};
sensitivity: {
acoustic_alpha_vs_n: [number, number];
@@ -69,4 +126,8 @@ export interface StabilityOverrides {
smd_um?: number;
n_interaction?: number;
chi_acoustic?: number;
+ /** Swap the conversion-lag model for this run without editing the config. */
+ time_lag_model?: 'leonardi_dtl' | 'd2_law';
+ convection_model?: 'none' | 'leonardi_eq8' | 'ranz_marshall';
+ mixing_lag_fraction?: number;
}
diff --git a/EngineDesign/scripts/chug_timelag_benchmark.py b/EngineDesign/scripts/chug_timelag_benchmark.py
new file mode 100644
index 000000000..28d84d8a0
--- /dev/null
+++ b/EngineDesign/scripts/chug_timelag_benchmark.py
@@ -0,0 +1,392 @@
+#!/usr/bin/env python3
+"""Score the two chug time-lag models against EXTERNAL data, not against this codebase.
+
+The anchor is the validation engine of
+
+ M. Leonardi, F. Nasuti, F. Di Matteo, J. Steelant, "A methodology to study the possible
+ occurrence of chugging in liquid rocket engines during transient start-up",
+ Acta Astronautica 139 (2017) 344-356. [L17]
+
+itself a re-analysis of the NASA gaseous-hydrogen / liquid-oxygen chug rig of ref. [25]. That rig is
+useful here for three reasons that no STAR config can supply: it has a MEASURED chug frequency, a
+MEASURED stability boundary, and one propellant injected as a GAS — which is the case STAR's old
+code could not express at all, because it ran the d^2-law droplet model on both streams.
+
+Four benchmarks, in order of how much they can prove:
+
+ A Solver vs experiment. Feed chug.py the paper's own lags and check it reproduces the measured
+ 66 Hz and the measured Delta_p_ox/pc ~ 0.35 inherent stability limit. Validates the loop
+ independently of any lag model.
+ B Lag model vs experiment. Same chamber, same drop size; compare each model's tau_vap against
+ the experiment-derived 4.4 ms (and tau_tot against 6.6 ms).
+ C Convection correction vs textbook. L17 eq. 8's (1 + 1.5 alpha) against Ranz-Marshall at the
+ same droplet Reynolds number.
+ D Blast radius. What each model does to the lags of STAR's own shipped engines.
+ E THE DECIDER. Each model's OWN lags driven end-to-end through chug.py, scored on the two
+ quantities ref. [25] actually measured. This is the only benchmark that compares models on an
+ output rather than on an intermediate, so it is the one that picks the shipping default.
+
+Run: python3 scripts/chug_timelag_benchmark.py
+Exit code is 0 when every benchmark that has a pass/fail criterion passes.
+"""
+
+from __future__ import annotations
+
+import math
+import os
+import sys
+
+import numpy as np
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from engine.pipeline.stability import chug, timelag # noqa: E402
+
+PA_PER_BAR = 1.0e5
+
+# ---------------------------------------------------------------------------
+# L17 Table 1 + §3 — the validation engine. Every number below is FROM THE PAPER.
+# ---------------------------------------------------------------------------
+D_C = 0.0508 # chamber diameter [m] L17 table 1
+D_TH = 0.0124 # throat diameter [m] L17 table 1
+LSTAR = 2.31 # characteristic length [m] L17 table 1
+L_C = 0.104 # chamber length [m] L17 table 1
+MR = 5.0 # mixture ratio [-] L17 table 1
+PC = 44.8 * PA_PER_BAR # chamber pressure [Pa] L17 table 1
+MDOT_OX = 0.249 # oxidizer mass flow [kg/s] L17 table 1
+V_CAV = 1.0e-5 # injector cavity volume [m^3] L17 table 1
+TC = 2038.0 # chamber temperature [K] L17 §3 (at eta_c* = 0.75)
+ETA_CSTAR = 0.75 # combustion efficiency [-] L17 §3
+
+TAU_OX_PAPER = 6.6e-3 # total oxidizer lag [s] L17 §3.1 (from the measured 66 Hz)
+TAU_FU_PAPER = 2.2e-3 # gaseous-fuel (mixing) lag [s] L17 §3.1
+TAU_VAP_PAPER = 4.4e-3 # oxidizer vaporization lag [s] L17 §3.1 (L50 / v_inj, ref. [39])
+D0_PAPER = 83.0e-6 # reference initial drop size [m] L17 §3.2
+F_CHUG_EXPERIMENT = 66.0 # measured chug frequency [Hz] L17 §3.1, ref. [25]
+DP_OX_LIMIT = 0.35 # inherent stability limit, Delta_p_ox/pc at Delta_p_fu/pc = 0.5 L17 §3.1
+DP_FU_FIXED = 0.5
+
+A_T = math.pi * (D_TH / 2.0) ** 2
+A_C = math.pi * (D_C / 2.0) ** 2
+MDOT_FU = MDOT_OX / MR
+MDOT_TOT = MDOT_OX + MDOT_FU
+
+# GH2/LOX product-gas properties at MR = 5, Tc = 2038 K. NOT from the paper: the paper does not
+# tabulate them. Handbook/CEA-typical values for a fuel-rich H2-O2 mixture, carried with an explicit
+# band so benchmark B reports a RANGE and no conclusion rests on a single guessed property.
+GAMMA = 1.26
+R_G = 640.0 # J/(kg.K), steam + excess H2 at MR 5
+K_G_BAND = (0.35, 0.70) # W/(m.K) — H2-rich products at ~2000 K
+CP_G = GAMMA * R_G / (GAMMA - 1.0)
+T_CRIT_O2 = 154.58 # K, NIST
+T_BOIL_O2 = 90.19 # K, NIST at 1 atm
+H_FG_O2 = 213000.0 # J/kg, NIST at 1 atm
+RHO_L_O2 = 1140.0 # kg/m^3
+
+
+def _cstar_from_pc() -> float:
+ """c* implied by the paper's own operating point: c* = Pc*A_t/mdot."""
+ return float(PC * A_T / MDOT_TOT)
+
+
+def _streams(eta_ox: float, eta_fu: float, tau_ox: float, tau_fu: float):
+ """L17 rig as two ChugStreams. The rig decouples the chamber from the feed lines (L17 fig. 3):
+ the injector cavities hold constant pressure, so there is no line inertance or resistance to
+ model — which is exactly why it is a clean test of the chamber+injector loop."""
+ reg = chug.Regulator(enabled=False)
+ o = chug.ChugStream("O", mdot=MDOT_OX, eta_inj=eta_ox, Pc=PC, dP_feed=0.0,
+ feed_length=0.0, feed_area=A_C, tau_conv=tau_ox, regulator=reg)
+ f = chug.ChugStream("F", mdot=MDOT_FU, eta_inj=eta_fu, Pc=PC, dP_feed=0.0,
+ feed_length=0.0, feed_area=A_C, tau_conv=tau_fu, regulator=reg)
+ return [o, f]
+
+
+def _chamber() -> chug.ChugChamber:
+ return chug.ChugChamber(cstar=_cstar_from_pc(), A_t=A_T, Lstar=LSTAR, gamma=GAMMA)
+
+
+# ---------------------------------------------------------------------------
+# A. Solver vs experiment
+# ---------------------------------------------------------------------------
+
+def bench_a() -> bool:
+ print("=" * 78)
+ print("A. chug.py vs the L17 / ref.[25] experiment (lags taken FROM the paper)")
+ print("=" * 78)
+ ch = _chamber()
+ print(f" c* implied by Pc*A_t/mdot : {ch.cstar:8.1f} m/s (eta_c* = {ETA_CSTAR} rig)")
+ print(f" theta_c = L*/(Gamma^2 c*) : {ch.theta_c()*1e3:8.3f} ms")
+
+ st = _streams(DP_OX_LIMIT, DP_FU_FIXED, TAU_OX_PAPER, TAU_FU_PAPER)
+ fast = chug.chug_margin_fast(st, ch)
+ f_pred = fast["f_chug_hz"]
+ err = abs(f_pred - F_CHUG_EXPERIMENT) / F_CHUG_EXPERIMENT * 100.0
+ print(f"\n chug frequency at the stability limit")
+ print(f" measured (ref.[25]) : {F_CHUG_EXPERIMENT:8.1f} Hz")
+ print(f" L17 constant-DTL model : {60.0:8.1f} Hz (+-5 Hz broadband resolution)")
+ print(f" L17 variable-DTL model : {65.0:8.1f} Hz")
+ print(f" STAR chug.py : {f_pred:8.1f} Hz ({err:5.1f} % from measured)")
+ ok_f = np.isfinite(f_pred) and err < 35.0
+
+ # Boundary: sweep Delta_p_ox/pc at fixed Delta_p_fu/pc and find the gain-margin crossing.
+ print(f"\n inherent stability limit, Delta_p_ox/pc at Delta_p_fu/pc = {DP_FU_FIXED}")
+ etas = np.linspace(0.15, 0.90, 151)
+ gms = [chug.chug_margin_fast(_streams(e, DP_FU_FIXED, TAU_OX_PAPER, TAU_FU_PAPER), ch)["gain_margin"]
+ for e in etas]
+ cross = None
+ for i in range(len(etas) - 1):
+ if (gms[i] - 1.0) * (gms[i + 1] - 1.0) < 0:
+ t = (1.0 - gms[i]) / (gms[i + 1] - gms[i])
+ cross = etas[i] + t * (etas[i + 1] - etas[i])
+ break
+ print(f" measured / L17 : {DP_OX_LIMIT:8.2f}")
+ if cross is None:
+ print(f" STAR chug.py : no gain-margin crossing in 0.15..0.90")
+ ok_b = False
+ else:
+ print(f" STAR chug.py : {cross:8.2f} "
+ f"({abs(cross-DP_OX_LIMIT)/DP_OX_LIMIT*100:.0f} % from measured)")
+ ok_b = abs(cross - DP_OX_LIMIT) / DP_OX_LIMIT < 0.60
+ print(f"\n -> {'PASS' if (ok_f and ok_b) else 'FAIL'}"
+ f" (frequency {'ok' if ok_f else 'off'}, boundary {'ok' if ok_b else 'off'})")
+ return ok_f and ok_b
+
+
+# ---------------------------------------------------------------------------
+# B. Lag model vs experiment
+# ---------------------------------------------------------------------------
+
+def _lox_stream(D0: float) -> timelag.StreamThermo:
+ return timelag.StreamThermo(
+ name="LOX", phase="liquid", rho_l=RHO_L_O2, mu_l=1.9e-4, sigma_l=0.013,
+ T_boil=T_BOIL_O2, T_crit=T_CRIT_O2, h_fg=H_FG_O2, D0=D0,
+ u_inj=float("nan"), d_orifice=float("nan"), # L17 does not publish the post geometry
+ )
+
+
+def _gh2_stream() -> timelag.StreamThermo:
+ return timelag.StreamThermo(name="GH2", phase="gas")
+
+
+def bench_b() -> bool:
+ print()
+ print("=" * 78)
+ print("B. tau_vap: each model vs the experiment-derived 4.4 ms (D0 = 83 um, L17 §3.2)")
+ print("=" * 78)
+ rho_g = PC / (R_G * TC)
+ u_g = MDOT_TOT / (rho_g * A_C)
+ print(f" chamber: Pc {PC/PA_PER_BAR:.1f} bar, Tc {TC:.0f} K, MR {MR}, "
+ f"rho_g {rho_g:.2f} kg/m3, u_g {u_g:.1f} m/s")
+
+ rows = []
+ for k_g in K_G_BAND:
+ ch = timelag.ChamberThermo(Pc=PC, Tc=TC, MR=MR, rho_g=rho_g, u_g=u_g, k_g=k_g, cp_g=CP_G)
+ streams = {"O": _lox_stream(D0_PAPER), "F": _gh2_stream()}
+ for model, conv in (("d2_law", "none"),
+ ("leonardi_dtl", "none"),
+ ("leonardi_dtl", "leonardi_eq8")):
+ lags = timelag.compute_lags(streams, ch, model=model, mix_fraction=0.5,
+ convection=conv)
+ rows.append((f"{model}/{conv}" if model == "leonardi_dtl" else model,
+ k_g, lags["O"], lags["F"]))
+
+ print(f"\n {'model':<28}{'k_g':>6}{'tau_vap':>11}{'err vs 4.4ms':>14}"
+ f"{'tau_tot(O)':>12}{'err vs 6.6ms':>14}{'tau(F)':>9}")
+ best = {}
+ for model, k_g, lo, lf in rows:
+ e_v = (lo.tau_vap - TAU_VAP_PAPER) / TAU_VAP_PAPER * 100.0
+ e_t = (lo.tau_total - TAU_OX_PAPER) / TAU_OX_PAPER * 100.0
+ tag = "-" if model.startswith("leonardi") else f"{k_g:.2f}"
+ print(f" {model:<28}{tag:>6}{lo.tau_vap*1e3:>10.3f}m{e_v:>13.0f}%"
+ f"{lo.tau_total*1e3:>11.3f}m{e_t:>13.0f}%{lf.tau_total*1e3:>8.3f}m")
+ best.setdefault(model, []).append(abs(e_v))
+
+ print(f"\n L17's own stated tau_vap for this point: {TAU_VAP_PAPER*1e3:.1f} ms")
+ print(f" gaseous fuel lag, measured (L17 §3.1) : {TAU_FU_PAPER*1e3:.1f} ms")
+ for name in sorted(best):
+ print(f" best-case |error| in tau_vap, {name:<28}: {min(best[name]):5.0f} %")
+ print(" -> L17 eq. 9's constant in its QUIESCENT form is the only variant inside 20 % of")
+ print(" the experiment-derived anchor, and it is the only one that does not depend on")
+ print(" the hot-gas conductivity k_g (which alone moves d2_law by a factor of 2).")
+
+ # The gas stream is the structural check: it must carry the mixing lag and NOTHING else.
+ _, _, _, lf = rows[0]
+ ok_gas = (lf.tau_atom == 0.0 and lf.tau_vap == 0.0 and lf.tau_total > 0.0)
+ print(f" gas-phase stream carries tau_mix only: {'PASS' if ok_gas else 'FAIL'} "
+ f"(atom {lf.tau_atom*1e3:.3f} ms, vap {lf.tau_vap*1e3:.3f} ms, "
+ f"mix {lf.tau_mix*1e3:.3f} ms)")
+ return ok_gas
+
+
+# ---------------------------------------------------------------------------
+# C. Convection correction vs textbook
+# ---------------------------------------------------------------------------
+
+def bench_c() -> bool:
+ print()
+ print("=" * 78)
+ print("C. L17 eq. 8 convective speed-up vs Ranz-Marshall")
+ print("=" * 78)
+ rho_g = PC / (R_G * TC)
+ mu_g = 7.0e-5 # Pa.s, H2-rich products ~2000 K (handbook)
+ u_g = MDOT_TOT / (rho_g * A_C)
+ print(f" {'Pc [bar]':>9}{'L17 1+1.5a':>13}{'Re_d':>10}{'Ranz-Marshall':>16}{'ratio':>9}")
+ ok = True
+ for pc_bar in (10.0, 44.8, 100.0, 200.0):
+ leo = timelag.convection_correction_leonardi(pc_bar)
+ rho = pc_bar * PA_PER_BAR / (R_G * TC)
+ u_slip = MDOT_TOT / (rho * A_C) # gas velocity; drop is ~stationary by comparison
+ Re_d = rho * u_slip * D0_PAPER / mu_g
+ rm = timelag.ranz_marshall_correction(Re_d)
+ print(f" {pc_bar:>9.1f}{leo:>13.3f}{Re_d:>10.1f}{rm:>16.3f}{leo/rm:>9.2f}")
+ if not (0.2 < leo / rm < 5.0):
+ ok = False
+ print("\n L17 eq. 8 depends on pressure ONLY (no slip velocity, no drop size), so it cannot")
+ print(" track Ranz-Marshall across conditions; it falls as Pc rises while the physical")
+ print(" correction rises. Both agree to within a factor of ~2 near the paper's own 44.8 bar,")
+ print(" which is where it was calibrated.")
+ print(f" -> {'PASS' if ok else 'FAIL'} (same order of magnitude over 10-200 bar)")
+ return ok
+
+
+# ---------------------------------------------------------------------------
+# D. Blast radius on STAR's own engines
+# ---------------------------------------------------------------------------
+
+def bench_d() -> bool:
+ print()
+ print("=" * 78)
+ print("D. Blast radius: what each model does to a STAR-class engine")
+ print("=" * 78)
+ # Representative 7.2 kN ethalox and 8 kN methalox points (design values, not a solve).
+ cases = [
+ ("ethalox LOX/ethanol", dict(
+ Pc=2.4e6, Tc=3094.0, MR=1.71, R_g=389.0, gamma=1.14,
+ ox=dict(name="LOX", rho=1140.0, mu=1.8e-4, sigma=0.013, Tb=90.19, Tc_=154.58,
+ hfg=213000.0, D0=80e-6, u=30.0, d=0.9e-3),
+ fu=dict(name="Ethanol", rho=789.0, mu=1.2e-3, sigma=0.0223, Tb=351.4, Tc_=514.0,
+ hfg=838000.0, D0=60e-6, u=25.0, d=0.7e-3))),
+ ("methalox LOX/CH4", dict(
+ Pc=2.4e6, Tc=3500.0, MR=2.8, R_g=360.0, gamma=1.18,
+ ox=dict(name="LOX", rho=1140.0, mu=1.8e-4, sigma=0.013, Tb=90.19, Tc_=154.58,
+ hfg=213000.0, D0=80e-6, u=30.0, d=0.9e-3),
+ fu=dict(name="Methane", rho=422.6, mu=1.1e-4, sigma=0.013, Tb=111.65, Tc_=190.56,
+ hfg=510000.0, D0=60e-6, u=35.0, d=0.7e-3))),
+ ]
+ A_c_star = math.pi * (0.10 / 2) ** 2
+ print(f" {'case':<22}{'stream':<9}{'d2_law':>10}{'leonardi':>11}{'ratio':>8}")
+ for label, c in cases:
+ rho_g = c["Pc"] / (c["R_g"] * c["Tc"])
+ cp_g = c["gamma"] * c["R_g"] / (c["gamma"] - 1.0)
+ u_g = 20.0 / (rho_g * A_c_star)
+ ch = timelag.ChamberThermo(Pc=c["Pc"], Tc=c["Tc"], MR=c["MR"], rho_g=rho_g,
+ u_g=u_g, k_g=0.20, cp_g=cp_g)
+ streams = {}
+ for key, d in (("O", c["ox"]), ("F", c["fu"])):
+ streams[key] = timelag.StreamThermo(
+ name=d["name"], phase="liquid", rho_l=d["rho"], mu_l=d["mu"], sigma_l=d["sigma"],
+ T_boil=d["Tb"], T_crit=d["Tc_"], h_fg=d["hfg"], D0=d["D0"],
+ u_inj=d["u"], d_orifice=d["d"])
+ a = timelag.compute_lags(streams, ch, model="d2_law", mix_fraction=0.0)
+ b = timelag.compute_lags(streams, ch, model="leonardi_dtl", mix_fraction=0.5)
+ for key in ("O", "F"):
+ r = b[key].tau_total / a[key].tau_total if a[key].tau_total > 0 else float("nan")
+ print(f" {label if key=='O' else '':<22}{streams[key].name:<9}"
+ f"{a[key].tau_total*1e3:>9.3f}m{b[key].tau_total*1e3:>10.3f}m{r:>8.2f}")
+ print(f" {'':<22}{' (leonardi split O: atom ' + f'{b[chr(79)].tau_atom*1e3:.3f}':<9}"
+ f" vap {b['O'].tau_vap*1e3:.3f} mix {b['O'].tau_mix*1e3:.3f} ms)")
+ return True
+
+
+# ---------------------------------------------------------------------------
+# E. The decider: each model end-to-end against the measured frequency and boundary
+# ---------------------------------------------------------------------------
+
+def _boundary_and_frequency(tau_ox: float, tau_fu: float):
+ """(Delta_p_ox/pc at the gain-margin crossing, chug frequency there)."""
+ ch = _chamber()
+ etas = np.linspace(0.10, 0.95, 341)
+ gms = [chug.chug_margin_fast(_streams(e, DP_FU_FIXED, tau_ox, tau_fu), ch)["gain_margin"]
+ for e in etas]
+ cross = None
+ for i in range(len(etas) - 1):
+ if (gms[i] - 1.0) * (gms[i + 1] - 1.0) < 0:
+ t = (1.0 - gms[i]) / (gms[i + 1] - gms[i])
+ cross = etas[i] + t * (etas[i + 1] - etas[i])
+ break
+ e = cross if cross is not None else DP_OX_LIMIT
+ f = chug.chug_margin_fast(_streams(e, DP_FU_FIXED, tau_ox, tau_fu), ch)["f_chug_hz"]
+ return cross, f
+
+
+def bench_e() -> bool:
+ print()
+ print("=" * 78)
+ print("E. THE DECIDER — each model's own lags, end-to-end, vs what ref.[25] measured")
+ print("=" * 78)
+ rho_g = PC / (R_G * TC)
+ u_g = MDOT_TOT / (rho_g * A_C)
+ MIX = 0.5
+ streams = {"O": _lox_stream(D0_PAPER), "F": _gh2_stream()}
+
+ trials = [("L17's own stated lags (reference)", TAU_OX_PAPER, TAU_FU_PAPER, None)]
+ for conv in ("none", "leonardi_eq8", "ranz_marshall"):
+ ch = timelag.ChamberThermo(Pc=PC, Tc=TC, MR=MR, rho_g=rho_g, u_g=u_g,
+ k_g=0.50, cp_g=CP_G)
+ lag = timelag.compute_lags(streams, ch, model="leonardi_dtl",
+ mix_fraction=MIX, convection=conv)
+ trials.append((f"leonardi_dtl convection={conv}",
+ lag["O"].tau_total, lag["F"].tau_total, "leonardi_dtl"))
+ for k_g in K_G_BAND:
+ ch = timelag.ChamberThermo(Pc=PC, Tc=TC, MR=MR, rho_g=rho_g, u_g=u_g, k_g=k_g, cp_g=CP_G)
+ lag = timelag.compute_lags(streams, ch, model="d2_law", mix_fraction=MIX)
+ trials.append((f"d2_law + mix k_g={k_g:.2f} W/mK",
+ lag["O"].tau_total, lag["F"].tau_total, "d2_law"))
+ ch = timelag.ChamberThermo(Pc=PC, Tc=TC, MR=MR, rho_g=rho_g, u_g=u_g, k_g=0.50, cp_g=CP_G)
+ lag = timelag.compute_lags(streams, ch, model="d2_law", mix_fraction=0.0)
+ trials.append(("d2_law, no mix (STAR before this change)",
+ lag["O"].tau_total, lag["F"].tau_total, "d2_law_old"))
+
+ print(f"\n {'lags from':<40}{'tau_O':>8}{'tau_F':>8}{'f':>8}{'f err':>8}"
+ f"{'bnd':>7}{'bnd err':>9}{'score':>7}")
+ scores: Dict[str, float] = {}
+ for label, t_o, t_f, key in trials:
+ c, f = _boundary_and_frequency(t_o, t_f)
+ fe = abs(f - F_CHUG_EXPERIMENT) / F_CHUG_EXPERIMENT * 100.0
+ be = abs(c - DP_OX_LIMIT) / DP_OX_LIMIT * 100.0 if c else float("nan")
+ print(f" {label:<40}{t_o*1e3:>7.2f}m{t_f*1e3:>7.2f}m{f:>8.1f}"
+ f"{(f-F_CHUG_EXPERIMENT)/F_CHUG_EXPERIMENT*100:>7.0f}%{c if c else float('nan'):>7.2f}"
+ f"{(c-DP_OX_LIMIT)/DP_OX_LIMIT*100 if c else float('nan'):>8.0f}%{fe+be:>7.0f}")
+ if key:
+ scores[label] = fe + be
+
+ winner = min(scores, key=scores.get)
+ print(f"\n score = |frequency error| + |boundary error|, in percent. Lower is better.")
+ print(f" WINNER: {winner} (score {scores[winner]:.0f})")
+ d2 = [v for k, v in scores.items() if k.startswith("d2_law + mix")]
+ print(f" d2_law spread across the k_g band alone: {min(d2):.0f} to {max(d2):.0f}")
+ print(f" leonardi_dtl convection=none has NO k_g dependence — the hot-gas conductivity")
+ print(f" drops out of the lag entirely, and it is a property nobody on this program measures.")
+ ok = winner.startswith("leonardi_dtl convection=none")
+ print(f"\n -> {'PASS' if ok else 'FAIL'}: shipping default should be "
+ f"time_lag_model=leonardi_dtl, convection=none")
+ return ok
+
+
+def main() -> int:
+ print("chug time-lag model benchmark — external anchors only\n")
+ results = {"A solver vs experiment": bench_a(),
+ "B lag model vs experiment": bench_b(),
+ "C convection vs textbook": bench_c(),
+ "D blast radius": bench_d(),
+ "E decider (end-to-end)": bench_e()}
+ print()
+ print("=" * 78)
+ for k, v in results.items():
+ print(f" {'PASS' if v else 'FAIL'} {k}")
+ print("=" * 78)
+ return 0 if all(results.values()) else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/EngineDesign/tests/test_api_nonfinite_serialization.py b/EngineDesign/tests/test_api_nonfinite_serialization.py
new file mode 100644
index 000000000..7b10d3af9
--- /dev/null
+++ b/EngineDesign/tests/test_api_nonfinite_serialization.py
@@ -0,0 +1,57 @@
+"""Non-finite floats must not take down an API response.
+
+A NaN anywhere in the results dict made json.dumps raise
+"Out of range float values are not JSON compliant", which the evaluate router
+turned into a blanket HTTP 500 on forward evaluation. NaN is a legal model output
+(e.g. a lag model that does not define K_v), so it must serialise as null.
+"""
+import math
+import numpy as np
+import pytest
+
+from backend.routers.evaluate import convert_numpy as ev_convert
+from backend.routers.flight import convert_numpy as fl_convert
+from backend.routers.timeseries import convert_numpy as ts_convert
+from backend.routers.optimizer import convert_numpy as op_convert
+
+CONVERTERS = [
+ pytest.param(ev_convert, id="evaluate"),
+ pytest.param(fl_convert, id="flight"),
+ pytest.param(ts_convert, id="timeseries"),
+ pytest.param(op_convert, id="optimizer"),
+]
+
+
+@pytest.mark.parametrize("convert", CONVERTERS)
+@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf"),
+ np.float64("nan"), np.float64("inf")])
+def test_non_finite_becomes_none(convert, bad):
+ assert convert(bad) is None
+
+
+@pytest.mark.parametrize("convert", CONVERTERS)
+def test_nested_payload_is_json_serialisable(convert):
+ """The shape that actually broke: NaN buried in the stability payload."""
+ import json
+ payload = {
+ "F": np.float64(8000.0),
+ "stability_rich": {"chug": {"lag_breakdown": {
+ "O": {"K_v": float("nan"), "tau_vap_s": 0.0037},
+ "F": {"K_v": np.float64("nan"), "tau_vap_s": 0.0141},
+ }}},
+ "series": [1.0, float("inf"), np.float64("nan"), 4.0],
+ }
+ out = convert(payload)
+ json.dumps(out) # must not raise
+ assert out["stability_rich"]["chug"]["lag_breakdown"]["O"]["K_v"] is None
+ assert out["stability_rich"]["chug"]["lag_breakdown"]["F"]["K_v"] is None
+ assert out["series"][1] is None and out["series"][2] is None
+
+
+@pytest.mark.parametrize("convert", CONVERTERS)
+def test_finite_values_are_untouched(convert):
+ """The guard must not eat real numbers."""
+ assert convert(np.float64(8000.0)) == pytest.approx(8000.0)
+ assert convert(0.0) == 0.0
+ assert convert(-1.5) == pytest.approx(-1.5)
+ assert convert(np.int64(26)) == 26
From c12d4a31e26e3434c25dbcee0e2006fb4dcd46ca Mon Sep 17 00:00:00 2001
From: Carlsaurus
Date: Mon, 14 Sep 2026 18:30:37 -0700
Subject: [PATCH 06/24] Stop the API turning a legitimate NaN into a 500
NaN and Inf are legal model outputs -- a lag model that does not define K_v, a
margin that is unbounded -- and the serializer was coercing them through a path
that json.dumps then refused, so a valid run came back as a server error instead
of a plot with a gap in it. They now serialize as null, which is what the charts
already handle.
np.integer was being caught by the same branch as np.floating and did not need
to be; splitting them keeps integer ids exact.
evaluate.py also takes the stability overrides (smd_um, time_lag_model) so the
panel can ask "what would this look like on the other lag model" without
editing the config.
---
EngineDesign/backend/routers/evaluate.py | 28 ++++++++++++++++++++--
EngineDesign/backend/routers/flight.py | 13 +++++++++-
EngineDesign/backend/routers/timeseries.py | 13 +++++++++-
3 files changed, 50 insertions(+), 4 deletions(-)
diff --git a/EngineDesign/backend/routers/evaluate.py b/EngineDesign/backend/routers/evaluate.py
index fb6b22dde..c3e82ac89 100644
--- a/EngineDesign/backend/routers/evaluate.py
+++ b/EngineDesign/backend/routers/evaluate.py
@@ -1,7 +1,10 @@
"""Engine evaluation endpoints."""
from fastapi import APIRouter, HTTPException, Depends
+from typing import Literal
+
from pydantic import BaseModel, Field
+import math
import numpy as np
from backend.session import UserSession, get_session
@@ -17,9 +20,20 @@
class StabilityOverrides(BaseModel):
"""Optional forward-mode knobs for rich stability re-evaluation."""
eta_inj_O: float | None = Field(default=None, gt=0, le=0.6, description="Oxidizer ΔP_inj/Pc")
- smd_um: float | None = Field(default=None, gt=0, le=200, description="LOX SMD [µm]")
+ smd_um: float | None = Field(default=None, gt=0, le=200, description="Oxidizer spray SMD [µm]")
n_interaction: float | None = Field(default=None, gt=0, le=2, description="Combustion interaction index n")
chi_acoustic: float | None = Field(default=None, gt=0, le=1, description="Acoustic sensitive-fraction χ")
+ time_lag_model: Literal["leonardi_dtl", "d2_law"] | None = Field(
+ default=None,
+ description="Conversion-lag model for the chug loop. Overrides stability.time_lag_model for this run only.",
+ )
+ convection_model: Literal["none", "leonardi_eq8", "ranz_marshall"] | None = Field(
+ default=None, description="Convective speed-up applied to the droplet lifetime.",
+ )
+ mixing_lag_fraction: float | None = Field(
+ default=None, ge=0, le=3,
+ description="Mixing lag as a fraction of the rate-limiting vaporization lag.",
+ )
class EvaluateRequest(BaseModel):
@@ -39,8 +53,18 @@ def convert_numpy(obj):
return [convert_numpy(item) for item in obj]
elif isinstance(obj, np.ndarray):
return obj.tolist()
- elif isinstance(obj, (np.integer, np.floating)):
+ elif isinstance(obj, np.floating):
+ # NaN/Inf are legal model outputs (a lag model that does not define K_v, a margin
+ # that could not be evaluated) but json.dumps rejects them outright --
+ # "Out of range float values are not JSON compliant" -- which surfaced as a blanket
+ # HTTP 500 on forward evaluation. Emit JSON null instead, so a missing number reads
+ # as missing rather than taking the whole response down.
+ v = obj.item()
+ return v if math.isfinite(v) else None
+ elif isinstance(obj, np.integer):
return obj.item()
+ elif isinstance(obj, float):
+ return obj if math.isfinite(obj) else None
elif isinstance(obj, np.bool_):
return bool(obj)
else:
diff --git a/EngineDesign/backend/routers/flight.py b/EngineDesign/backend/routers/flight.py
index 744d1129f..287171923 100644
--- a/EngineDesign/backend/routers/flight.py
+++ b/EngineDesign/backend/routers/flight.py
@@ -3,6 +3,7 @@
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel, Field
from typing import List, Optional, Literal
+import math
import numpy as np
import copy
@@ -62,8 +63,18 @@ def convert_numpy(obj):
return [convert_numpy(item) for item in obj]
elif isinstance(obj, np.ndarray):
return obj.tolist()
- elif isinstance(obj, (np.integer, np.floating)):
+ elif isinstance(obj, np.floating):
+ # NaN/Inf are legal model outputs (a lag model that does not define K_v, a margin
+ # that could not be evaluated) but json.dumps rejects them outright --
+ # "Out of range float values are not JSON compliant" -- which surfaced as a blanket
+ # HTTP 500 on forward evaluation. Emit JSON null instead, so a missing number reads
+ # as missing rather than taking the whole response down.
+ v = obj.item()
+ return v if math.isfinite(v) else None
+ elif isinstance(obj, np.integer):
return obj.item()
+ elif isinstance(obj, float):
+ return obj if math.isfinite(obj) else None
elif isinstance(obj, np.bool_):
return bool(obj)
else:
diff --git a/EngineDesign/backend/routers/timeseries.py b/EngineDesign/backend/routers/timeseries.py
index 94b060af7..ae36bfed5 100644
--- a/EngineDesign/backend/routers/timeseries.py
+++ b/EngineDesign/backend/routers/timeseries.py
@@ -4,6 +4,7 @@
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends
from pydantic import BaseModel, Field, ValidationError
from typing import List, Optional, Literal
+import math
import numpy as np
import pandas as pd
import io
@@ -35,8 +36,18 @@ def convert_numpy(obj):
return [convert_numpy(item) for item in obj]
elif isinstance(obj, np.ndarray):
return obj.tolist()
- elif isinstance(obj, (np.integer, np.floating)):
+ elif isinstance(obj, np.floating):
+ # NaN/Inf are legal model outputs (a lag model that does not define K_v, a margin
+ # that could not be evaluated) but json.dumps rejects them outright --
+ # "Out of range float values are not JSON compliant" -- which surfaced as a blanket
+ # HTTP 500 on forward evaluation. Emit JSON null instead, so a missing number reads
+ # as missing rather than taking the whole response down.
+ v = obj.item()
+ return v if math.isfinite(v) else None
+ elif isinstance(obj, np.integer):
return obj.item()
+ elif isinstance(obj, float):
+ return obj if math.isfinite(obj) else None
elif isinstance(obj, np.bool_):
return bool(obj)
else:
From e1fc27587a7c6280215511730831719b01cfa1eb Mon Sep 17 00:00:00 2001
From: Carlsaurus
Date: Mon, 14 Sep 2026 18:30:50 -0700
Subject: [PATCH 07/24] Say O/F, not mixture ratio
Display strings only. The MR keys stay as they are -- they are a wire contract
between the API, the CSV exports and the configs, and renaming them would break
every saved file.
---
EngineDesign/frontend/src/components/ControllerMode.tsx | 2 +-
.../frontend/src/components/Layer2Optimization.tsx | 8 ++++----
.../frontend/src/components/Layer3Optimization.tsx | 4 ++--
EngineDesign/frontend/src/components/OptimizerDemo.tsx | 2 +-
.../frontend/src/components/PressureCurveChart.tsx | 2 +-
EngineDesign/frontend/src/components/ResultsDisplay.tsx | 2 +-
6 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/EngineDesign/frontend/src/components/ControllerMode.tsx b/EngineDesign/frontend/src/components/ControllerMode.tsx
index c1157213c..c257af26d 100644
--- a/EngineDesign/frontend/src/components/ControllerMode.tsx
+++ b/EngineDesign/frontend/src/components/ControllerMode.tsx
@@ -758,7 +758,7 @@ export function ControllerMode({ config }: ControllerModeProps) {
{/* Mixture Ratio */}
- Mixture Ratio {isLoading && (Live)}
+ O/F Ratio {isLoading && (Live)}
diff --git a/EngineDesign/frontend/src/components/Layer3Optimization.tsx b/EngineDesign/frontend/src/components/Layer3Optimization.tsx
index 7d62d30c2..9ad6163c3 100644
--- a/EngineDesign/frontend/src/components/Layer3Optimization.tsx
+++ b/EngineDesign/frontend/src/components/Layer3Optimization.tsx
@@ -664,7 +664,7 @@ export function Layer3Optimization({
- Mixture Ratio (O/F)
+ O/F Ratio
@@ -679,7 +679,7 @@ export function Layer3Optimization({
-
+
diff --git a/EngineDesign/frontend/src/components/OptimizerDemo.tsx b/EngineDesign/frontend/src/components/OptimizerDemo.tsx
index 215fc9734..ec13d7c36 100644
--- a/EngineDesign/frontend/src/components/OptimizerDemo.tsx
+++ b/EngineDesign/frontend/src/components/OptimizerDemo.tsx
@@ -1154,7 +1154,7 @@ export function OptimizerDemo({ config }: OptimizerDemoProps) {
{/* O/F Ratio */}
{Array.isArray(layer3Results.performance.MR) && (
-
Mixture Ratio (O/F)
+
O/F Ratio
{
// Generate CSV and download
- const headers = ['time (s)', 'P_tank_O (psi)', 'P_tank_F (psi)', 'Pc (psi)', 'Thrust (kN)', 'Isp (s)', 'MR', 'mdot_total (kg/s)'];
+ const headers = ['time (s)', 'P_tank_O (psi)', 'P_tank_F (psi)', 'Pc (psi)', 'Thrust (kN)', 'Isp (s)', 'O/F', 'mdot_total (kg/s)'];
const rows = data.time.map((t, i) => [
t.toFixed(4),
data.P_tank_O_psi[i].toFixed(2),
diff --git a/EngineDesign/frontend/src/components/ResultsDisplay.tsx b/EngineDesign/frontend/src/components/ResultsDisplay.tsx
index 6ae91e7e7..c0784072f 100644
--- a/EngineDesign/frontend/src/components/ResultsDisplay.tsx
+++ b/EngineDesign/frontend/src/components/ResultsDisplay.tsx
@@ -161,7 +161,7 @@ export function ResultsDisplay({ results, isLoading, targetExitPressure }: Resul
color="yellow"
/>
Date: Mon, 14 Sep 2026 18:30:50 -0700
Subject: [PATCH 08/24] Bring the shipped configs back in line with the code
W_MOM was 30000 in default.yaml and 120000 in the 7000N doublet against a code
default of 75. Those weights date from when the momentum-flux ratio R was the
hard ablative guard; that guard was replaced by the spray resultant-tilt gate
and the weights were never brought down, so R was effectively a hard constraint
again -- and it outranked the actual O/F requirement (W_OF 20000). Measured on
the 8 kN ethalox point: W_MOM 30000 gives O/F 2.077, Isp 230.5, of_check FALSE;
W_MOM 1 gives O/F 1.6503, Isp 237.9, all seven gates pass.
Every new design inherits default.yaml, so this was reaching designs that had
nothing to do with the original ablative question.
Also records critical_temperature on each propellant -- the chug time lag needs
it and was falling back to a per-fluid assumption.
---
EngineDesign/configs/default.yaml | 9 ++++++++-
EngineDesign/configs/ethalox_doublet_7000N.yaml | 17 ++++++++++-------
.../configs/impinging_lox_ch4_8000N.yaml | 2 ++
EngineDesign/configs/propellants/ethalox.yaml | 2 ++
EngineDesign/configs/propellants/kerolox.yaml | 4 ++++
EngineDesign/configs/propellants/methalox.yaml | 2 ++
6 files changed, 28 insertions(+), 8 deletions(-)
diff --git a/EngineDesign/configs/default.yaml b/EngineDesign/configs/default.yaml
index 8d74feb69..ad6b8f8a0 100644
--- a/EngineDesign/configs/default.yaml
+++ b/EngineDesign/configs/default.yaml
@@ -1,6 +1,7 @@
fluids:
fuel:
name: Methane
+ critical_temperature: 190.56 # K, CoolProp Methane — chug time-lag evaporation constant (Leonardi 2017 eq. 9)
density: 422.6
viscosity: 0.00012
surface_tension: 0.0134
@@ -13,6 +14,7 @@ fluids:
molecular_weight: 16.04
oxidizer:
name: LOX
+ critical_temperature: 154.60 # K, CoolProp Oxygen — chug time-lag evaporation constant (Leonardi 2017 eq. 9)
density: 1140.0
viscosity: 0.00018
surface_tension: 0.013
@@ -434,7 +436,12 @@ design_requirements:
injector_dp_ratio_F_min: 0.15
injector_dp_ratio_F_max: 0.35
W_geom_ao_af_momentum: 3500.0
- W_MOM: 30000.0
+ # 75.0 is the code default (layer1_static_optimization.py:5451). The old 30000/120000
+ # dated from when R was the hard ablative guard; that guard was replaced by the spray
+ # resultant-tilt gate, but the weights were never brought back down. At 30000+ the
+ # superseded R band acts as a hard constraint AND outranks W_OF, which manufactured a
+ # fuel-tank requirement of 643 psi for a design point that closes at 582.
+ W_MOM: 75.0
impinging_momentum_R_min: 0.95
impinging_momentum_R_max: 1.05
# Sauter mean diameter (atomization) objective: two-sided deadband around the target.
diff --git a/EngineDesign/configs/ethalox_doublet_7000N.yaml b/EngineDesign/configs/ethalox_doublet_7000N.yaml
index d08a7249b..fb801130c 100644
--- a/EngineDesign/configs/ethalox_doublet_7000N.yaml
+++ b/EngineDesign/configs/ethalox_doublet_7000N.yaml
@@ -112,7 +112,12 @@ design_requirements:
W_IMPINGING_ANGLE: 400.0
W_IMPINGING_JET_ASYM: 180.0
W_IMP_GEOM: 1500.0
- W_MOM: 120000.0
+ # 75.0 is the code default (layer1_static_optimization.py:5451). The old 30000/120000
+ # dated from when R was the hard ablative guard; that guard was replaced by the spray
+ # resultant-tilt gate, but the weights were never brought back down. At 30000+ the
+ # superseded R band acts as a hard constraint AND outranks W_OF, which manufactured a
+ # fuel-tank requirement of 643 psi for a design point that closes at 582.
+ W_MOM: 75.0
W_SMD: 0.0
W_TANK_EQUAL: 30000.0
W_geom_ao_af_momentum: 3500.0
@@ -288,18 +293,14 @@ environment:
longitude: -117.8099547
feed_system:
fuel:
- A_hydraulic: 7.316855998167869e-05
K0: 0.95
K1: 0.0
- d_inlet: 0.009652
- line_size: 3/8_NPT
+ line_size: 3/8_TUBE_035
phi_type: none
oxidizer:
- A_hydraulic: 0.00012667686977437442
K0: 0.95
K1: 0.0
- d_inlet: 0.0127
- line_size: 1/2_NPT
+ line_size: 1/2_TUBE_035
phi_type: none
film_cooling:
apply_to_fraction_of_length: 0.6
@@ -326,6 +327,7 @@ fluids:
latent_heat: 838000.0
molecular_weight: 46.07
name: Ethanol
+ critical_temperature: 514.71 # K, CoolProp Ethanol — chug time-lag evaporation constant (Leonardi 2017 eq. 9)
specific_heat: 2440.0
surface_tension: 0.0223
temperature: 293.0
@@ -339,6 +341,7 @@ fluids:
latent_heat: 213000.0
molecular_weight: 32.0
name: LOX
+ critical_temperature: 154.60 # K, CoolProp Oxygen — chug time-lag evaporation constant (Leonardi 2017 eq. 9)
specific_heat: 2300.0
surface_tension: 0.013
temperature: 90.0
diff --git a/EngineDesign/configs/impinging_lox_ch4_8000N.yaml b/EngineDesign/configs/impinging_lox_ch4_8000N.yaml
index 624a2452e..3a3a9cc0f 100644
--- a/EngineDesign/configs/impinging_lox_ch4_8000N.yaml
+++ b/EngineDesign/configs/impinging_lox_ch4_8000N.yaml
@@ -244,6 +244,7 @@ fluids:
latent_heat: 510000.0
molecular_weight: 16.04
name: Methane
+ critical_temperature: 190.56 # K, CoolProp Methane — chug time-lag evaporation constant (Leonardi 2017 eq. 9)
specific_heat: 3348.0
surface_tension: 0.0134
temperature: 112.0
@@ -256,6 +257,7 @@ fluids:
latent_heat: null
molecular_weight: null
name: LOX
+ critical_temperature: 154.60 # K, CoolProp Oxygen — chug time-lag evaporation constant (Leonardi 2017 eq. 9)
specific_heat: 2300.0
surface_tension: 0.013
temperature: 90.0
diff --git a/EngineDesign/configs/propellants/ethalox.yaml b/EngineDesign/configs/propellants/ethalox.yaml
index 42d629910..88b01b108 100644
--- a/EngineDesign/configs/propellants/ethalox.yaml
+++ b/EngineDesign/configs/propellants/ethalox.yaml
@@ -17,6 +17,7 @@ fluids:
boiling_point: 351.4
molecular_weight: 46.07
bulk_modulus_pa: 1060000000.0 # ethanol @ ~293 K, standard handbook value (stability feed-acoustics)
+ critical_temperature: 514.71 # K, CoolProp Ethanol -- chug time-lag evaporation constant (Leonardi 2017 eq. 9)
oxidizer:
name: LOX
density: 1140.0
@@ -30,6 +31,7 @@ fluids:
boiling_point: 90.2
molecular_weight: 32.0
bulk_modulus_pa: 1500000000.0 # order-of-magnitude LOX; refine via test T5
+ critical_temperature: 154.60 # K, CoolProp Oxygen -- chug time-lag evaporation constant (Leonardi 2017 eq. 9)
# Chamber-gas properties for the Ingebo aerodynamic Weber number (rho_g = Pc/(R*T)).
#
# These are NOT free constants: the schema defaults (R=360, T=3500) are LOX/CH4 values, and
diff --git a/EngineDesign/configs/propellants/kerolox.yaml b/EngineDesign/configs/propellants/kerolox.yaml
index c11cc29eb..6cfc10ca0 100644
--- a/EngineDesign/configs/propellants/kerolox.yaml
+++ b/EngineDesign/configs/propellants/kerolox.yaml
@@ -18,6 +18,9 @@ fluids:
boiling_point: 489.0 # constants.py DEFAULT_FUEL_BOILING_POINT_K
molecular_weight: 170.0 # DRAFT (C12 kerosene surrogate) — TODO verify
bulk_modulus_pa: 1300000000.0 # DRAFT kerosene — TODO verify
+ critical_temperature: 658.10 # K, CoolProp n-Dodecane, the RP-1 surrogate this repo already
+ # uses in spalding.py FUEL_SURROGATES. RP-1 is a cut, not a
+ # compound, so this is a pseudo-critical stand-in — DRAFT.
oxidizer:
name: LOX
density: 1140.0
@@ -31,6 +34,7 @@ fluids:
boiling_point: 90.2
molecular_weight: 32.0
bulk_modulus_pa: 1500000000.0
+ critical_temperature: 154.60 # K, CoolProp Oxygen — chug time-lag evaporation constant (Leonardi 2017 eq. 9)
# Chamber-gas properties for the Ingebo aerodynamic Weber number (rho_g = Pc/(R*T)).
# Every preset states these explicitly: the SMDConfig schema defaults (R=360, T=3500) are a
# single hardcoded pair that cannot be right for three different propellants, and leaving a
diff --git a/EngineDesign/configs/propellants/methalox.yaml b/EngineDesign/configs/propellants/methalox.yaml
index e0bf0950e..c21871783 100644
--- a/EngineDesign/configs/propellants/methalox.yaml
+++ b/EngineDesign/configs/propellants/methalox.yaml
@@ -17,6 +17,7 @@ fluids:
boiling_point: 111.65
molecular_weight: 16.04
bulk_modulus_pa: null # TODO: LCH4 near NBP — measure/source before trusting feed acoustics for fuel side
+ critical_temperature: 190.56 # K, CoolProp Methane — chug time-lag evaporation constant (Leonardi 2017 eq. 9)
oxidizer:
name: LOX
density: 1140.0
@@ -30,6 +31,7 @@ fluids:
boiling_point: 90.2 # K, standard LOX (was _LOX_TBOIL_DEFAULT)
molecular_weight: 32.0
bulk_modulus_pa: 1500000000.0 # order-of-magnitude LOX (was hardcoded in stability/report.py); refine via test T5
+ critical_temperature: 154.60 # K, CoolProp Oxygen — chug time-lag evaporation constant (Leonardi 2017 eq. 9)
# Chamber-gas properties for the Ingebo aerodynamic Weber number (rho_g = Pc/(R*T)).
# Every preset states these explicitly: the SMDConfig schema defaults (R=360, T=3500) are a
# single hardcoded pair that cannot be right for three different propellants, and leaving a
From 07a69406975d2a3ce09ac7139ddb20deb9913bd7 Mon Sep 17 00:00:00 2001
From: Carlsaurus
Date: Mon, 14 Sep 2026 18:31:00 -0700
Subject: [PATCH 09/24] Stop the dev stack racing its own startup banner
dev.sh printed the URLs the instant tmux had spawned the panes. Vite serves in
about a second but importing backend.main takes several (numba warm plus the
router graph), so opening the app inside that window hit a dead API. Wait for
every declared port to accept a connection before printing.
The app also probed /api/health exactly once on mount and gave up, so a reload
in that same window left it stuck on "disconnected" until you reloaded again.
It retries now.
ConfigEditor gets hover text for the geometry knobs added with the chamber
volume fix. All three default to null, which is the pre-fix behaviour, so a
config that leaves them blank optimises as if the fix were not there -- worth
saying out loud in the UI rather than in a commit message nobody reads.
---
EngineDesign/frontend/src/App.tsx | 51 ++++++++++++++-----
.../frontend/src/components/ConfigEditor.tsx | 34 +++++++++++--
scripts/dev_common.sh | 34 +++++++++++++
3 files changed, 101 insertions(+), 18 deletions(-)
diff --git a/EngineDesign/frontend/src/App.tsx b/EngineDesign/frontend/src/App.tsx
index 5e3c79af2..3cdefbc34 100644
--- a/EngineDesign/frontend/src/App.tsx
+++ b/EngineDesign/frontend/src/App.tsx
@@ -47,25 +47,50 @@ function App() {
// Keep all tab panels mounted; hide inactive ones to preserve state
const tabPanelClass = (tab: Tab) => (activeTab === tab ? '' : 'hidden');
- // Check backend health and load config on mount
+ // Check backend health and load config on mount.
+ //
+ // This RETRIES. A single attempt raced the backend on every `dev.sh --restart`:
+ // vite is serving in about a second but importing backend.main takes ~6 s (numba
+ // warm plus the router graph), and dev.sh prints the URLs without waiting for
+ // /api/health. Reload inside that window and the one probe failed, isConnected
+ // latched false, and "Backend not connected" stayed up until a manual reload --
+ // while the backend had in fact come up fine seconds later.
+ //
+ // Backoff caps at ~30 s total, which covers a cold start with a CEA cache build.
+ // isConnected stays null (banner hidden) while retries are in flight, so a slow
+ // start reads as "still loading" rather than a false error.
useEffect(() => {
+ let cancelled = false;
+ const DELAYS_MS = [250, 500, 1000, 2000, 3000, 4000, 5000, 6000, 8000];
+
async function init() {
- const healthResult = await getHealth();
- if (healthResult.error) {
- setIsConnected(false);
- return;
- }
- setIsConnected(true);
+ for (let attempt = 0; attempt <= DELAYS_MS.length; attempt++) {
+ if (cancelled) return;
+ const healthResult = await getHealth();
+ if (cancelled) return;
- // The backend always has a config in the caller's session (the default is
- // loaded lazily per user), so fetch it unconditionally. DesignVersions may
- // then swap in the active document's working copy.
- const configResult = await getConfig();
- if (configResult.data) {
- setConfig(configResult.data.config);
+ if (!healthResult.error) {
+ setIsConnected(true);
+ // The backend always has a config in the caller's session (the default is
+ // loaded lazily per user), so fetch it unconditionally. DesignVersions may
+ // then swap in the active document's working copy.
+ const configResult = await getConfig();
+ if (!cancelled && configResult.data) {
+ setConfig(configResult.data.config);
+ }
+ return;
+ }
+
+ if (attempt === DELAYS_MS.length) break; // out of retries
+ await new Promise((r) => setTimeout(r, DELAYS_MS[attempt]));
}
+ if (!cancelled) setIsConnected(false);
}
+
init();
+ return () => {
+ cancelled = true;
+ };
}, []);
const handleConfigLoaded = (newConfig: EngineConfig) => {
diff --git a/EngineDesign/frontend/src/components/ConfigEditor.tsx b/EngineDesign/frontend/src/components/ConfigEditor.tsx
index f2bcc3e0d..6c6bc6f8b 100644
--- a/EngineDesign/frontend/src/components/ConfigEditor.tsx
+++ b/EngineDesign/frontend/src/components/ConfigEditor.tsx
@@ -124,6 +124,14 @@ const FIELD_LABELS: Record = {
layer1_W_MASS: 'Weight: Chamber Dry Mass',
layer1_chamber_mass_ref_kg: 'Chamber Mass Reference (kg)',
layer1_chamber_wall_density_kg_m3: 'Chamber Wall Effective Density (kg/m³)',
+ // Geometry/mixing knobs added with the chamber-volume fix. All three default to null,
+ // which is the PRE-FIX behaviour (45 deg cone, no barrel-length floor, no pitch limit) --
+ // so a config that leaves them blank optimises as if the fix were not there. They are the
+ // difference between L* pinning to max_Lstar and landing on a real design point.
+ layer1_contraction_half_angle_deg: 'Contraction Half-Angle (°) — blank = 45° (length-optimal, not mass-optimal)',
+ layer1_min_Lcyl_over_D: 'Min Barrel Length / Bore — blank = unenforced (cone can pose as mixing length)',
+ layer1_max_element_pitch_m: 'Max Injector Element Pitch (m) — blank = unenforced (bore is free to grow)',
+ layer1_infeasibility_gate_eps: 'Infeasibility Gate ε (blank = 0.002; relative residual tolerated as feasible)',
layer1_generations_per_restart: 'CMA Generations per Restart',
layer1_impinging_angle_deg_min: 'Included Impingement Angle Min (°)',
layer1_impinging_angle_deg_max: 'Included Impingement Angle Max (°)',
@@ -173,7 +181,7 @@ const FIELD_LABELS: Record = {
Lstar: 'L* (m)',
design_pressure: 'Design Pressure (Pa)',
design_thrust: 'Design Thrust (N)',
- design_MR: 'Design Mixture Ratio',
+ design_MR: 'Design O/F Ratio',
A_exit: 'Exit Area (m²)',
expansion_ratio: 'Expansion Ratio',
exit_diameter: 'Exit Diameter (m)',
@@ -231,8 +239,24 @@ const FIELD_LABELS: Record = {
date: 'Date',
};
-function getFieldLabel(key: string): string {
- return FIELD_LABELS[key] || key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
+// Labels are looked up by bare key, so a key that means different things in different
+// sections gets one of them wrong everywhere else. `length` is the live example: it was
+// showing feed_system.oxidizer.length -- the tank-outlet-to-manifold run, which only sets
+// the chug inertance -- as "Total Chamber Length (m)". Anything ambiguous goes here,
+// keyed by the section it lives under; this map wins over FIELD_LABELS.
+const FIELD_LABELS_BY_SECTION: Record> = {
+ feed_system: {
+ length: 'Feed Line Length (m) — chug inertance only; does NOT affect ΔP',
+ d_inlet: 'Line Bore (m) — derived from Feed Line Size; blank to re-derive',
+ A_hydraulic: 'Flow Area (m²) — derived from bore; blank to re-derive',
+ K0: 'Loss Coefficient K₀ — lumped for the WHOLE run (valve + fittings + friction)',
+ },
+};
+
+function getFieldLabel(key: string, path?: string[]): string {
+ const section = path && path.length > 0 ? path[0] : undefined;
+ const scoped = section ? FIELD_LABELS_BY_SECTION[section]?.[key] : undefined;
+ return scoped || FIELD_LABELS[key] || key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
interface InputFieldProps {
@@ -424,7 +448,7 @@ function SubSection({ title, data, path, onEdit, defaultExpanded = true }: SubSe
return (
onEdit(fieldPath, newValue)}
@@ -515,7 +539,7 @@ function SectionCard({ sectionKey, data, onEdit }: SectionCardProps) {
return (
onEdit(fieldPath, newValue)}
diff --git a/scripts/dev_common.sh b/scripts/dev_common.sh
index 0eb36b773..838984c41 100755
--- a/scripts/dev_common.sh
+++ b/scripts/dev_common.sh
@@ -129,6 +129,39 @@ _dev_run_preflight() {
fi
}
+# Wait until every declared service port accepts a connection, so the URLs we
+# print are actually usable.
+#
+# Without this the summary appeared the instant tmux had spawned the panes. Vite
+# serves in about a second but a Python API can take several (EngineDesign's
+# backend.main is ~6 s: numba warm plus the router graph). Open the UI inside
+# that window and a frontend that probes health once on mount latches "backend
+# not connected" and stays there until a manual reload -- the single most common
+# "it broke again" after ./dev.sh --restart.
+#
+# Best-effort: on timeout we print anyway with a note, never block the developer.
+_dev_wait_ready() {
+ local timeout="${DEV_READY_TIMEOUT:-45}"
+ local i port label deadline=$(( SECONDS + timeout )) pending=1
+ [ "${#DEV_SERVICE_PORTS[@]}" -gt 0 ] || return 0
+ while [ "$SECONDS" -lt "$deadline" ]; do
+ pending=0
+ for i in "${!DEV_SERVICE_PORTS[@]}"; do
+ port="${DEV_SERVICE_PORTS[$i]}"
+ _dev_port_open "$port" || pending=1
+ done
+ [ "$pending" -eq 0 ] && return 0
+ sleep 0.5
+ done
+ echo ""
+ for i in "${!DEV_SERVICE_PORTS[@]}"; do
+ port="${DEV_SERVICE_PORTS[$i]}"
+ label="${DEV_SERVICE_LABELS[$i]}"
+ _dev_port_open "$port" || echo " note: $label (port $port) not answering yet after ${timeout}s — check ./dev.sh --logs"
+ done
+ return 0
+}
+
_dev_print_summary() {
local i
echo ""
@@ -193,6 +226,7 @@ _dev_start() {
if [ "$attach" = "1" ]; then
exec tmux attach -t "$DEV_SESSION"
fi
+ _dev_wait_ready
_dev_print_summary
}
From 27f40811d5b75c4de98808b0e284c1251c9f8d4b Mon Sep 17 00:00:00 2001
From: Carlsaurus
Date: Mon, 14 Sep 2026 18:31:38 -0700
Subject: [PATCH 10/24] Ignore the native kernel's build output
`!engine/**` un-ignores everything under engine/, so `git add engine/` swept 607
CMake artifacts -- object files, generated makefiles, compiler probes -- into the
index. The C sources are not in those directories, and no tracked file is
affected by this.
---
EngineDesign/.gitignore | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/EngineDesign/.gitignore b/EngineDesign/.gitignore
index 03ba1d85e..192bd1a55 100644
--- a/EngineDesign/.gitignore
+++ b/EngineDesign/.gitignore
@@ -66,6 +66,12 @@ Thumbs.db
# Include all source directories
!engine/
!engine/**
+
+# ...but not the native kernel's build output. `!engine/**` un-ignores everything under
+# engine/, which swept 607 CMake artifacts (objects, generated makefiles, compiler probes)
+# into `git add engine/`. The C sources themselves live outside these directories.
+engine/native/build/
+engine/native/build_*/
!ui/
!ui/**
!copv/
From c7ffbeadd6daa564196246fe9174f76135cb0be8 Mon Sep 17 00:00:00 2001
From: Carlsaurus
Date: Tue, 15 Sep 2026 18:51:18 -0700
Subject: [PATCH 11/24] 180 lb vehicle at 8:1, and stop flying the pressurant
as propellant
New design point sized to three hard constraints, all exact:
wet mass 81.6466 kg = 180.000 lb
thrust 6405.4 N = 1440.0 lbf, so T/W = 8.0000 : 1
liquid propellant 11.000 L + COPV 5.000 L = 16.000 L
With volume capped instead of mass, the objective is density impulse rho*Isp,
not Isp, and with wet mass also capped dv = Isp*g0*ln(m_wet/(m_wet - m_prop))
where m_prop = rho_bulk*11 L. Swept O/F 1.30-2.50: the dv peak is at 1.85, not
at the Isp peak of 1.675 -- but it is worth 0.45 %, and staying at the required
1.65 costs 0.62 % while saving 40 K of chamber temperature. The optimum is flat;
1.65 stands.
Apogee 12765 ft at the modelled eta_c* 0.9504, 11500 ft at 0.8935. The modelled
value is optimistic against the 0.87 published comparable, so expect the middle
of that range. Both ends are inside the 4000-13000 ft window.
ui/flight_sim.py drained the entire COPV over the burn
(mdot = m_pressurant / burn_time) and handed it to a RocketPy tank as
liquid_mass_flow_rate_out, which subtracts it from vehicle mass -- so the
pressurant was being flown as propellant. It is not: it moves from the COPV into
the ullage the departing propellant leaves behind and is still on board at
burnout. The old line's own comment said "flows out to propellant tanks", which
is the reason it must not be expelled.
Measured A/B on this vehicle: apogee 12522 -> 12339 ft, so the bug inflated it
1.5 %. The ideal rocket equation says 51 m/s of phantom dv; the trajectory only
sees a fraction of that because the mass comes off gradually and the flight is
drag-dominated at Mach 0.87. Worse than the bias, draining the tank to exactly
-0.000 kg made RocketPy raise outright, so the sim failed on most points of an
efficiency sweep rather than answering wrongly. Only configs that actually set
press_tank.initial_gas_mass were affected; leaving it unset modelled no
pressurant at all.
design_audit.py had 8000 N and O/F 1.65 hardcoded, so every 6405 N candidate
reported FAILS: thrust. Targets now come from the config being audited.
---
EngineDesign/configs/ethalox_180lb_8to1.yaml | 689 ++++++++++++++++++
EngineDesign/scripts/design_audit.py | 13 +-
.../test_pressurant_is_not_propellant.py | 70 ++
EngineDesign/ui/flight_sim.py | 35 +-
4 files changed, 794 insertions(+), 13 deletions(-)
create mode 100644 EngineDesign/configs/ethalox_180lb_8to1.yaml
create mode 100644 EngineDesign/tests/test_pressurant_is_not_propellant.py
diff --git a/EngineDesign/configs/ethalox_180lb_8to1.yaml b/EngineDesign/configs/ethalox_180lb_8to1.yaml
new file mode 100644
index 000000000..128728612
--- /dev/null
+++ b/EngineDesign/configs/ethalox_180lb_8to1.yaml
@@ -0,0 +1,689 @@
+# CalSTAR ethalox -- 180 lb vehicle, 8:1 thrust-to-weight. 2026-09-15.
+#
+# Reproduce: python3 scripts/layer1_run.py --config configs/ethalox_180lb_8to1.yaml
+# Audit: python3 scripts/design_audit.py configs/ethalox_180lb_8to1.yaml
+# Robustness: python3 scripts/design_robustness.py configs/ethalox_180lb_8to1.yaml
+#
+# THE THREE HARD CONSTRAINTS, ALL EXACT:
+# wet mass 81.6466 kg = 180.000 lb
+# thrust 6405.4 N = 1440.0 lbf -> T/W = 8.0000 : 1
+# liquid 11.0000 L + COPV 5.0000 L = 16.0000 L
+#
+# 8 x 180 lbf is where the thrust number comes from. Nothing else set it.
+#
+# ENGINE
+# O/F 1.6566 Pc 434.20 psia Isp 238.72 s eta_c* 0.9504
+# 28 doublets, theta 40 / 48 deg (included 88), d_jet 1.453 / 1.260 mm
+# bore 127.000 mm = 5.0000 in, throat 43.64 mm, eps 5.69, L* 1.0000 m
+# burn 3.914 s, total impulse 25074 N.s, LOX-limited with 16 g of fuel residual
+#
+# PROPELLANT: WHY 11 L AT O/F 1.65 AND NOT SOMETHING DENSER
+# With volume capped instead of mass, the objective is DENSITY impulse (rho_bulk * Isp),
+# not Isp -- and with wet mass ALSO capped, dv = Isp*g0*ln(m_wet/(m_wet - m_prop)) where
+# m_prop = rho_bulk * 11 L. Ethanol is much less dense than LOX, so the density term pulls
+# O/F up while Isp pulls it back down. Swept 1.30 -> 2.50:
+# peak Isp at O/F 1.675 dv 330.6 m/s Tc 3311 K
+# peak dv at O/F 1.850 dv 332.1 m/s Tc 3342 K (+0.45 %)
+# the requirement at O/F 1.650 dv 330.1 m/s Tc 3302 K (-0.62 % off peak)
+# 0.62 % of dv is not worth 40 K on the ablative, a 12 % move off the stated O/F, and
+# re-balancing the injector. The optimum is flat; staying at 1.65 is the right call.
+# -> 11.000 L splits 5.8645 L LOX / 5.1355 L ethanol = 6.6856 + 4.0519 = 10.7375 kg.
+#
+# PRESSURANT: A 5 L COPV IS AMPLY SIZED
+# Dome-regulated. The regulator holds while the COPV stays above the tank setpoint, so
+# deliverable gas is V_copv*(rho(4500 psi) - rho(582 psi)) = 1.311 kg against the 0.585 kg
+# the tanks swallow -- 2.24x. At burnout the COPV is still at 2571 psi (isothermal).
+# Real gas, not ideal: N2 at 310 bar has Z = 1.150, so ideal over-states the fill by 15 %.
+# On-board gas 1.551 kg. Helium would save 1.33 kg, but note dv depends only on m_wet and
+# m_prop -- helium buys STRUCTURAL budget, not apogee.
+#
+# MASS BUDGET -- THE NUMBER TO BUILD TO
+# propellant 10.737 kg 23.7 lb fixed by 11 L at O/F 1.65
+# pressurant GN2 1.551 kg 3.4 lb fixed by 5 L at 4500 psi
+# STRUCTURE 69.358 kg 152.9 lb <-- everything else has to fit in this
+# engine+plumbing 7.000 kg 15.4 lb
+# LOX tank 2.000 kg 4.4 lb
+# fuel tank 1.700 kg 3.7 lb
+# COPV 3.299 kg 7.3 lb
+# airframe 55.359 kg 122.0 lb
+# The 152.9 lb is derived and binding. The split under it is an ALLOCATION, not a
+# measurement -- reallocate freely, but the total cannot move without breaking 8:1.
+#
+# APOGEE (RocketPy, 626.67 m pad, deterministic across repeat runs)
+# eta_c* 0.9504 (modelled) 3891 m = 12765 ft
+# eta_c* 0.9125 3632 m = 11917 ft
+# eta_c* 0.8935 3505 m = 11500 ft
+# eta_c* 0.8555 3255 m = 10678 ft
+# eta_c* 0.8174 3009 m = 9871 ft
+# Every point is inside the 4000-13000 ft window. The modelled 0.95 is optimistic against
+# a 0.87 published comparable, so the honest expectation is the MIDDLE of that list,
+# ~11500 ft, not the top. Thrust is fixed at 6405.4 N by the 8:1 requirement, so a worse
+# eta_c* does not reduce thrust -- it raises mdot and shortens the burn.
+#
+# TANK FILL IS 0.80, NOT 0.90
+# RocketPy's tank model would not run above ~0.80 on this geometry. 20 % ullage is
+# defensible for LOX anyway (boil-off, thermal expansion). It changes the tank ENVELOPE,
+# not the liquid: 13.750 L of tank for 11.000 L of propellant.
+#
+# READ THE 16 L RULE BEFORE COMMITTING
+# Held here: LIQUID propellant + COPV = 16.000 L exactly, which is what you asked for, and
+# it also clears a 16 L cap on propellant alone with 5 L to spare.
+# If the rule is measured on TANK volume instead, this vehicle is 13.750 + 5.000 = 18.750 L
+# and does NOT comply -- you would drop to ~8.8 L of liquid and lose roughly 1500 ft.
+# Check which one the rulebook means.
+#
+# STILL REQUIRES HARDWARE
+# FLOW-TEST the injector. Cd 0.80 is an inlet-geometry correlation, not a measurement.
+# Cd 0.72-0.88 moves thrust -4.4 / +3.8 % and keeps dP/Pc inside 0.20-0.40 throughout,
+# so a surprise there moves T/W off 8:1 rather than making the design infeasible.
+# COUNTERBORE THE FUEL ORIFICES. At 48 deg the fuel passage is 18.98 mm; run the 1.260 mm
+# drill the whole way and it is L/d 11.1. With the 4 mm counterbore it is L/d 3.5.
+# Spot-face every hole normal to its own axis -- incidence is 50 / 42 deg.
+propellant_preset: ethalox
+fluids:
+ fuel:
+ name: Ethanol
+ density: 789.0
+ viscosity: 0.0012
+ surface_tension: 0.0223
+ vapor_pressure: 5800.0
+ specific_heat: 2440.0
+ thermal_conductivity: 0.17
+ temperature: 293.0
+ latent_heat: 838000.0
+ boiling_point: 351.4
+ molecular_weight: 46.07
+ bulk_modulus_pa: 1060000000.0
+ critical_temperature: 514.71
+ injection_phase: null
+ oxidizer:
+ name: LOX
+ density: 1140.0
+ viscosity: 0.00018
+ surface_tension: 0.013
+ vapor_pressure: 101325.0
+ specific_heat: 2300.0
+ thermal_conductivity: 0.15
+ temperature: 90.0
+ latent_heat: 213000.0
+ boiling_point: 90.2
+ molecular_weight: 32.0
+ bulk_modulus_pa: 1500000000.0
+ critical_temperature: 154.6
+ injection_phase: null
+injector:
+ type: impinging
+ geometry:
+ oxidizer:
+ n_elements: 28
+ d_jet: 0.0014530882632717026
+ impingement_angle: 40.0
+ spacing: 0.008423896045553311
+ fuel:
+ n_elements: 28
+ d_jet: 0.001259635700224197
+ impingement_angle: 48.0
+ spacing: 0.01079760754492665
+feed_system:
+ fuel:
+ line_size: 1/2_TUBE_035
+ d_inlet: 0.010922
+ A_hydraulic: 9.369021288512731e-05
+ K0: 2.019
+ K1: 0.0
+ phi_type: none
+ length: 0.9144
+ oxidizer:
+ line_size: 1/2_TUBE_035
+ d_inlet: 0.010922
+ A_hydraulic: 9.369021288512731e-05
+ K0: 0.643
+ K1: 0.0
+ phi_type: none
+ length: 0.1016
+regen_cooling:
+ enabled: false
+ d_inlet: 0.009525
+ L_inlet: 0.5
+ n_channels: 100
+ channel_width: 0.0009
+ channel_height: 0.001
+ channel_length: 0.18162
+ d_outlet: null
+ L_outlet: 0.1
+ roughness: 0.0
+ K_manifold_split: 0.5
+ K_manifold_merge: 0.3
+ Cd_entrance_inf: 0.8
+ a_Re_entrance: 0.1
+ Cd_entrance_min: 0.6
+ Cd_exit_inf: 0.9
+ a_Re_exit: 0.1
+ Cd_exit_min: 0.7
+ use_heat_transfer: true
+ wall_thickness: 0.002
+ wall_thermal_conductivity: 320.0
+ chamber_inner_diameter: 0.08491
+ hot_gas_prandtl: 0.7
+ hot_gas_viscosity: 4.0e-05
+ hot_gas_thermal_conductivity: 0.12
+ radiation_emissivity_hot: 0.85
+ radiation_view_factor: 1.0
+ n_segments: 20
+ gas_turbulence_intensity: 0.1
+ coolant_turbulence_intensity: 0.05
+ recovery_factor: null
+film_cooling:
+ enabled: false
+ mass_fraction: 0.05
+ injection_temperature: null
+ effectiveness_ref: 0.45
+ decay_length: 0.05
+ apply_to_fraction_of_length: 0.6
+ slot_height: 0.00035
+ reference_blowing_ratio: 0.6
+ blowing_exponent: 0.62
+ turbulence_reference_intensity: 0.08
+ turbulence_sensitivity: 1.0
+ turbulence_exponent: 1.0
+ turbulence_min_multiplier: 0.5
+ reference_wall_temperature: 1100.0
+ density_override: null
+ cp_override: null
+ablative_cooling:
+ enabled: true
+ material_density: 1600.0
+ heat_of_ablation: 2500000.0
+ thermal_conductivity: 0.35
+ specific_heat: 1500.0
+ initial_thickness: 0.0127
+ surface_temperature_limit: 1200.0
+ coverage_fraction: 0.9
+ pyrolysis_temperature: 950.0
+ blowing_efficiency: 0.75
+ use_physics_based_blowing: true
+ blowing_coefficient: 0.5
+ blowing_min_reduction_factor: 0.1
+ turbulence_reference_intensity: 0.08
+ turbulence_sensitivity: 1.5
+ turbulence_exponent: 1.0
+ turbulence_max_multiplier: 3.0
+ throat_recession_multiplier: null
+ char_layer_conductivity: 0.2
+ char_layer_thickness: 0.001
+ surface_emissivity: 0.85
+ ambient_temperature: 300.0
+ radiative_sink_minimum_threshold: 400.0
+ radiative_sink_fallback_temperature: 600.0
+ track_geometry_evolution: true
+ nozzle_ablative: false
+graphite_insert:
+ enabled: true
+ material_density: 2260.0
+ heat_of_ablation: 15000000.0
+ thermal_conductivity: 100.0
+ specific_heat: 710.0
+ initial_thickness: 0.006
+ surface_temperature_limit: 2500.0
+ oxidation_temperature: 800.0
+ oxidation_rate: 1.0e-06
+ activation_energy: 190000.0
+ oxidation_reference_temperature: 973.0
+ oxidation_reference_pressure: 21000.0
+ recession_multiplier: null
+ sizing_only_mode: false
+ simplified_graphite_oxidation: false
+ simplified_oxidation_rate: 1.0e-05
+ sizing_recession_rate: 1.0e-08
+ axial_half_length_ratio: 0.75
+ axial_half_length: null
+ char_layer_conductivity: 5.0
+ char_layer_thickness: 0.0005
+ coverage_fraction: 1.0
+ emissivity: 0.8
+ ambient_temperature: 300.0
+ feedback_fraction_min: 0.0
+ feedback_fraction_max: 0.2
+ oxidation_enthalpy: 32800000.0
+ ablation_surface_temperature: 3000.0
+ ablation_transition_width: 200.0
+ oxidation_pressure_exponent: 0.5
+ oxidation_pre_exponential: null
+ mixture_mw: 0.024
+ oxidation_stoichiometry_ratio: 1.0
+ oxygen_mass_fraction: 0.05
+ oxygen_mole_fraction: null
+ friction_coefficient_override: null
+ reference_diffusivity: null
+ reference_diffusivity_temperature: 1500.0
+ reference_diffusivity_pressure: 1000000.0
+stainless_steel_case: null
+discharge:
+ fuel:
+ Cd_inf: 0.6
+ a_Re: 0.18
+ Cd_min: 0.35
+ use_geometry_cd: true
+ d_ref_m: 0.002
+ cd_small_hole_exponent: 0.2
+ cd_large_hole_log_gain: 0.015
+ cd_inf_max: 0.62
+ cd_inf_min_geom: 0.48
+ inlet_geometry: sharp
+ inlet_radius_ratio: null
+ orifice_l_over_d: 4.0
+ d_min_m: 0.0004
+ use_pressure_correction: false
+ P_ref: 5000000.0
+ a_P: 0.0
+ use_temperature_correction: false
+ T_ref: 300.0
+ a_T: 0.0
+ oxidizer:
+ Cd_inf: 0.6
+ a_Re: 0.18
+ Cd_min: 0.35
+ use_geometry_cd: true
+ d_ref_m: 0.002
+ cd_small_hole_exponent: 0.2
+ cd_large_hole_log_gain: 0.015
+ cd_inf_max: 0.62
+ cd_inf_min_geom: 0.48
+ inlet_geometry: sharp
+ inlet_radius_ratio: null
+ orifice_l_over_d: 4.0
+ d_min_m: 0.0004
+ use_pressure_correction: false
+ P_ref: 5000000.0
+ a_P: 0.0
+ use_temperature_correction: false
+ T_ref: 90.0
+ a_T: 0.0
+spray:
+ momentum_flux_ratio: true
+ spray_angle:
+ model: TMR
+ k: 0.5
+ n: 0.5
+ weber:
+ We_min: 15
+ smd:
+ model: ingebo
+ C: 0.5
+ m: 0.6
+ p: 0.0
+ C_ingebo: 3.9
+ chamber_gas_R: 389.0
+ chamber_gas_T: 3094.0
+ we_corr_max: null
+ pintle:
+ C: 15.0
+ B: 2.0
+ n: 0.5
+ p: 0.2
+ evaporation:
+ model: derived
+ C_evap: 1.562
+ cp_gas: 2200.0
+ apply_tau_res_correction: false
+ K: 300000.0
+ x_star_limit: 0.05
+ use_constraint: true
+ use_turbulence_corrections: false
+ turbulence_breakup_gain: 1.0
+ turbulence_penetration_gain: 0.5
+combustion:
+ cea:
+ use_parallel_cea_build: false
+ cea_parallel_workers: null
+ ox_name: LOX
+ fuel_name: Ethanol
+ expansion_ratio: 5.698558803827487
+ cache_file: output/cache/cea_cache_LOX_Ethanol_3D.npz
+ Pc_range:
+ - 1000000.0
+ - 9000000.0
+ MR_range:
+ - 1.0
+ - 2.5
+ eps_range:
+ - 4.0
+ - 15.0
+ n_points: 34
+ efficiency:
+ model: exponential
+ C: 0.3
+ K: 0.15
+ use_spray_correction: false
+ spray_penalty_factor: 0.8
+ use_mixture_coupling: false
+ use_cooling_coupling: true
+ use_turbulence_coupling: true
+ Em_peak: 0.96
+ mixing_sigma: 1.5
+ R_opt: null
+ mixture_efficiency_floor: 0.25
+ cooling_efficiency_floor: 0.25
+ turbulence_efficiency_floor: 0.3
+ target_turbulence_intensity: null
+ turbulence_penalty_exponent: null
+ target_smd_microns: null
+ xstar_limit_mm: null
+ xstar_penalty_exponent: null
+ we_reference: null
+ we_penalty_exponent: null
+ smd_penalty_exponent: null
+ use_advanced_model: true
+ Pc_gate: 1000000.0
+ use_finite_rate_chemistry: true
+ use_shifting_equilibrium: true
+ tau_ref: 1.0e-05
+ tau_ref_P: 4000000.0
+ tau_ref_T: 3500.0
+ n_pressure: 0.8
+ tau_Tc_floor_K: null
+ T_star_fuel_cap_K: 500.0
+ A0_hydrocarbon: 10000000.0
+ Ea_hydrocarbon: 80000.0
+ n_pre_hydrocarbon: 0.3
+ A0_ethanol: 50000000.0
+ Ea_ethanol: 140000.0
+ n_pre_ethanol: 0.25
+ A0_hydrogen: 1000000000.0
+ Ea_hydrogen: 40000.0
+ n_pre_hydrogen: 0.2
+chamber_geometry:
+ design_pressure: 2993691.4915861404
+ design_thrust: 6405.43651185836
+ design_MR: 1.6565517328133872
+ chamber_diameter: 0.127
+ Lstar: 1.0000009998704453
+ exit_diameter: 0.10417150932350908
+ expansion_ratio: 5.698558803827487
+ nozzle_efficiency: 0.95
+ A_throat: 0.0014956251532967633
+ A_exit: 0.008522907884545105
+ volume: 0.0014956266487281514
+ length: 0.12798074424989195
+ length_cylindrical: 0.09588581577769259
+ length_contraction: 0.032094928472199365
+ Cf: 1.430602328196876
+chamber: null
+nozzle: null
+solver:
+ method: brentq
+ Pc_bounds:
+ - 100000.0
+ - 8000000.0
+ tolerance: 1.0e-06
+ max_iterations: 100
+ closure:
+ max_iterations: 6
+ Cd_reduction_factor: 1.0
+ tolerance: 0.0001
+stability:
+ n_interaction: 0.5
+ chi_acoustic: 0.15
+ mach_nozzle_entrance: null
+ damping_injector_frac: 0.02
+ damping_twophase_frac: 0.03
+ droplet_loading: 1.0
+ acoustic_gate_alpha_offset: 350.0
+ time_lag_model: leonardi_dtl
+ convection_model: none
+ mixing_lag_fraction: 0.5
+ regulator_enabled: true
+ regulator_corner_hz: 3.0
+ regulator_Z_hf: 0.0
+ regulator_max_excursion_psi: 0.0
+optimizer:
+ mode: hybrid_cma_blocks
+ hybrid:
+ elite_k: 50
+ block_method: corr_greedy
+ num_blocks: 3
+ overlap_fraction: 0.0
+ cycles: 3
+ lambda0: 0.001
+ lambda_mult: 10.0
+ lambda_max: 1.0
+ lambda_normalize: true
+ per_block_budget_fraction: 0.5
+ refresh_every_pass: true
+ refresh_budget_fraction: 0.1
+ refresh_sigma_scale: 0.2
+ num_tracks: 1
+lox_tank:
+ lox_h: 0.4782536685178334
+ lox_radius: 0.06985
+ ox_tank_pos: 0.8
+ mass: 6.685586338227163
+ initial_pressure_psi: 582.0743266932028
+ tank_volume_m3: 0.0073306249999999995
+fuel_tank:
+ rp1_h: 0.35191107212355094
+ rp1_radius: 0.0762
+ fuel_tank_pos: 3.0
+ mass: 4.051870508016463
+ initial_pressure_psi: 582.0743266932028
+ tank_volume_m3: 0.006419375
+press_tank:
+ press_h: 0.27410072797083124
+ press_radius: 0.0762
+ pres_tank_pos: 3.6
+ dry_mass: 3.2988888888888885
+ initial_gas_mass: 1.551
+ mass: null
+ free_volume_L: 5.0
+rocket:
+ airframe_mass: 55.359280864867486
+ engine_mass: 7.0
+ lox_tank_structure_mass: 2.0
+ fuel_tank_structure_mass: 1.7
+ engine_cm_offset: 0.15
+ propulsion_dry_mass: 21.0
+ propulsion_cm_offset: 0.4
+ copv_dry_mass: 3.2988888888888885
+ inertia:
+ - 8.0
+ - 8.0
+ - 0.5
+ radius: 0.078359
+ rocket_length: 6.432614614439114
+ motor_position: 0.0
+ fins:
+ no_fins: 4
+ root_chord: 0.626872
+ tip_chord: 0.20066
+ fin_span: 0.20066
+ fin_position: 1.054535
+ nose_kind: vonKarman
+ nose_fineness_ratio: 4.5
+ nose_length: null
+ avionics_payload_length_m: 4.0
+ mass: null
+ cm_wo_motor: 3.861725449
+ dry_mass: null
+ motor_inertia: null
+ motor: null
+environment:
+ date:
+ - 2026
+ - 1
+ - 30
+ - 18
+ latitude: 35.34722
+ longitude: -117.8099547
+ elevation: 626.67
+ atmosphere_model: standard_atmosphere
+thrust:
+ burn_time: 3.914
+design_requirements:
+ target_thrust: 6405.439125975119
+ target_chamber_pressure_psi: 430.0
+ target_apogee: 3890.7
+ optimal_of_ratio: 1.65
+ target_burn_time: 3.914
+ max_lox_tank_pressure_psi: 600.0
+ max_fuel_tank_pressure_psi: 600.0
+ max_P_tank_O: null
+ max_P_tank_F: null
+ max_engine_length: 0.4
+ max_chamber_outer_diameter: 0.1651
+ metal_wall_thickness_per_side_m: 0.00635
+ max_nozzle_exit_diameter: 0.2032
+ min_Lstar: 1.0
+ max_Lstar: 1.0
+ min_stability_score: 0.58
+ require_stable_state: false
+ stability_margin_handicap: 0.0
+ min_stability_margin: 1.05
+ chugging_margin_min: 0.2
+ acoustic_margin_min: 0.1
+ feed_stability_min: 0.15
+ lox_tank_capacity_kg: 6.685586338227163
+ fuel_tank_capacity_kg: 4.051870508016463
+ propellant_tank_fill_factor: 0.8
+ copv_free_volume_L: 5.0
+ copv_free_volume_m3: null
+ injector_dp_ratio_O_min: 0.2
+ injector_dp_ratio_O_max: 0.4
+ injector_dp_ratio_F_min: 0.2
+ injector_dp_ratio_F_max: 0.4
+ feed_pressure_model: dome_regulated
+ W_geom_ao_af_momentum: 3500.0
+ W_MOM: 75.0
+ impinging_momentum_R_min: 0.95
+ impinging_momentum_R_max: 1.05
+ layer1_momentum_log_deadband_rel: null
+ layer1_impinging_angle_deg_min: 80.0
+ layer1_impinging_jet_angle_min_deg: 40.0
+ layer1_impinging_angle_deg_max: 90.0
+ W_IMPINGING_ANGLE: 400.0
+ W_IMPINGING_JET_ASYM: 180.0
+ layer1_impinging_jet_angle_max_asym_deg: 10.0
+ W_SMD: 0.0
+ target_smd_microns: 50.0
+ layer1_smd_rel_tol: 0.2
+ W_TANK_EQUAL: 800.0
+ layer1_tank_equal_scale_psi: 100.0
+ layer1_chamber_od_increment_in: 0.5
+ layer1_lock_tank_pressures: null
+ layer1_thrust_deadband_rel: null
+ layer1_derive_tank_from_dp_ratio: null
+ layer1_dp_ratio_target: null
+ layer1_derive_fuel_jet_from_of: null
+ layer1_tank_equal_inband_frac: null
+ layer1_chamber_od_snap_target: null
+ layer1_Lstar_from_smd: null
+ layer1_Lstar_smd_ref_um: null
+ layer1_Lstar_ref_m: null
+ layer1_Lstar_smd_exponent: null
+ layer1_Lstar_deadband_m: null
+ layer1_impingement_Ld_target: 4.0
+ layer1_resultant_tilt_max_deg: null
+ layer1_resultant_tilt_gate_tol_deg: 0.5
+ layer1_resultant_tilt_scale_deg: null
+ layer1_momentum_wall_side_multiplier: null
+ layer1_momentum_scale: null
+ layer1_momentum_gate_safe_slack: null
+ layer1_derive_impingement_spacing: null
+ layer1_impingement_Ld_tol: 1.0
+ layer1_ring_order_fuel_outboard: null
+ layer1_integer_jet_angles: null
+ layer1_derive_expansion_ratio: null
+ layer1_derive_throat_from_thrust: null
+ layer1_derive_max_iters: null
+ layer1_derive_thrust_tol_rel: null
+ layer1_tank_equal_tol_psi: null
+ layer1_of_deadband_rel: null
+ layer1_exit_pressure_deadband_rel: null
+ layer1_W_LSTAR: null
+ layer1_Lstar_target_m: null
+ layer1_W_MASS: 3000.0
+ layer1_contraction_half_angle_deg: null
+ layer1_min_Lcyl_over_D: null
+ layer1_max_element_pitch_m: 0.0225
+ layer1_chamber_wall_density_kg_m3: 3400.0
+ layer1_chamber_mass_ref_kg: 5.0
+ layer1_W_EXIT: null
+ W_IMP_GEOM: 1500.0
+ layer1_exit_pressure_inside_quad_scale: 0.38
+ layer1_impinging_n_doublets_max: 30
+ layer1_random_seed: 37
+ layer1_cma_warmstart_trials: 16
+ layer1_cma_warmstart_sigma_frac: 0.04
+ layer1_cma_restart0_sigma_scale: 0.48
+ layer1_lbfgs_gtol: 1.0e-09
+ layer1_lbfgs_second_pass: true
+ W_DP: 800.0
+ W_DP_O: 12000.0
+ W_DP_F: 175000.0
+ W_DP_HIGH: 25000.0
+ W_DP_CENTER: null
+ W_DP_O_FLOOR: null
+ injector_dp_ratio_O_soft_floor: null
+ layer1_A_throat_mm2_min: null
+ layer1_A_throat_mm2_max: null
+ layer1_cf_upper_bound_for_throat_floor: null
+ layer1_pc_fraction_for_throat_floor: null
+ layer1_enforce_ring_geometry: true
+ layer1_injector_spray_radius_frac: 0.7071
+ layer1_injector_spray_radius_tol: 0.08
+ layer1_injector_plate_thickness_m: 0.0127
+ layer1_injector_min_face_incidence_deg: 40.0
+ layer1_injector_counterbore_dia_m: 0.004
+ layer1_injector_center_clear_dia_m: 0.0381
+ layer1_injector_min_web_m: 0.002
+ layer1_injector_wall_clearance_m: 0.008
+ layer1_resultant_tilt_from_reach: true
+ layer1_resultant_tilt_reach_margin: 1.5
+ layer1_impingement_Ld_min: 3.0
+ layer1_impingement_Ld_max: 5.0
+ layer1_momentum_band_width: null
+ layer1_momentum_low_side_multiplier: null
+ layer1_generations_per_restart: null
+ max_chamber_length_m: null
+ objective_cache_rel: null
+ report_every_n: null
+ layer1_infeasibility_gate_eps: 0.002
+ layer1_W_THRUST: 60000.0
+ layer1_W_PC: null
+ layer1_W_OF: 20000.0
+ layer1_W_OF_low_MR_scale: 1.0
+ layer1_W_OF_high_MR_scale: 1.0
+ layer1_of_validation_tol: null
+ layer1_thrust_validation_rel_tol: null
+ W_CHAMBER_SHAPE: 2500.0
+ layer1_chamber_dt_ratio_min: 2.2
+ layer1_chamber_dt_ratio_max: 3.2
+ layer1_chamber_ld_ratio_min: 1.0
+ layer1_chamber_ld_ratio_max: 3.2
+ layer1_stagnation_pressure_frac_min: 0.35
+ layer1_stagnation_pressure_frac_max: 1.0
+ layer1_expansion_ratio_min: 3.0
+ layer1_expansion_ratio_max: 14.0
+ layer1_P_O_start_psi_min: null
+ layer1_P_O_start_psi_max: null
+ layer1_P_F_start_psi_min: null
+ layer1_P_F_start_psi_max: null
+ frozen_parameters:
+ A_throat_mm2: null
+ Lstar_mm: null
+ expansion_ratio: null
+ D_chamber_outer_mm: 165.1
+ d_pintle_tip_mm: null
+ h_gap_mm: null
+ n_orifices: null
+ d_orifice_mm: null
+ n_doublets: null
+ d_jet_O_mm: null
+ d_jet_F_mm: null
+ impingement_angle_O_deg: null
+ impingement_angle_F_deg: null
+ spacing_O_mm: null
+ spacing_F_mm: null
+ P_O_start_psi: null
+ P_F_start_psi: null
+pressure_curves: null
+design_valid_for: null
diff --git a/EngineDesign/scripts/design_audit.py b/EngineDesign/scripts/design_audit.py
index b3d5b476a..95c0c2090 100644
--- a/EngineDesign/scripts/design_audit.py
+++ b/EngineDesign/scripts/design_audit.py
@@ -45,9 +45,18 @@ def audit(f):
spray_radius_tol=rq.get('layer1_injector_spray_radius_tol') or 0.08)
PO = c.lox_tank.initial_pressure_psi*6894.757; PF = c.fuel_tank.initial_pressure_psi*6894.757
dpo, dpf = (PO-r['Pc'])/r['Pc'], (PF-r['Pc'])/r['Pc']
+ # Targets come from the config being audited, not from whatever design happened to be
+ # current when this script was written. Hardcoding 8000 N here made every 6405 N
+ # candidate report FAILS: thrust, which is the tool being stale, not the design.
+ F_tgt = float(rq.get('target_thrust') or 0.0)
+ OF_tgt = float(rq.get('optimal_of_ratio') or 0.0)
checks = [
- ("thrust 8000 +/-2%", abs(r['F']-8000)/8000 <= 0.02, f"{r['F']:.1f} N"),
- ("O/F 1.65 +/-5%", abs(r['MR']-1.65)/1.65 <= 0.05, f"{r['MR']:.4f}"),
+ (f"thrust {F_tgt:.0f} +/-2%",
+ F_tgt > 0 and abs(r['F']-F_tgt)/F_tgt <= 0.02,
+ f"{r['F']:.1f} N" + ("" if F_tgt > 0 else " (no target_thrust in config)")),
+ (f"O/F {OF_tgt:.3f} +/-5%",
+ OF_tgt > 0 and abs(r['MR']-OF_tgt)/OF_tgt <= 0.05,
+ f"{r['MR']:.4f}" + ("" if OF_tgt > 0 else " (no optimal_of_ratio in config)")),
("dP/Pc O in band", 0.20 <= dpo <= 0.40, f"{dpo:.3f}"),
("dP/Pc F in band", 0.20 <= dpf <= 0.40, f"{dpf:.3f}"),
("n <= 30", n <= 30, f"{n}"),
diff --git a/EngineDesign/tests/test_pressurant_is_not_propellant.py b/EngineDesign/tests/test_pressurant_is_not_propellant.py
new file mode 100644
index 000000000..ecce31b6e
--- /dev/null
+++ b/EngineDesign/tests/test_pressurant_is_not_propellant.py
@@ -0,0 +1,70 @@
+"""Pressurant gas is dead mass, not propellant.
+
+ui.flight_sim used to drain the entire COPV over the burn
+(``mdot = m_pressurant / burn_time``) and hand that to a RocketPy
+MassFlowRateBasedTank as ``liquid_mass_flow_rate_out``, which subtracts it from vehicle
+mass. But the gas moves from the COPV into the ullage the departing propellant leaves
+behind -- it is still on board at burnout. Flying it as propellant over-stated the mass
+ratio, and running the tank to exactly zero made RocketPy raise outright.
+"""
+import math
+import re
+from pathlib import Path
+
+import pytest
+
+FLIGHT_SIM = Path(__file__).resolve().parents[1] / "ui" / "flight_sim.py"
+
+
+def test_pressurant_mass_flow_is_zero():
+ src = FLIGHT_SIM.read_text()
+ block = src[src.index("m_pressurant = getattr"):src.index("print(f\" Pressurant")]
+ assert "m_pressurant / effective_burn_time" not in block, (
+ "the COPV is being drained over the burn again; pressurant is not propellant"
+ )
+ assert re.search(r"mdot_pressurant_avg\s*=\s*0\.0", block), (
+ "mdot_pressurant_avg must be 0.0 -- the gas stays on the vehicle"
+ )
+
+
+def test_pressurant_still_counts_toward_wet_mass():
+ """Dead mass, not absent mass. It must still be in the initial mass sum."""
+ src = FLIGHT_SIM.read_text()
+ assert "total_initial_mass = rocket_mass + motor_dry_mass + m_lox0 + m_rp10 + m_pressurant" in src, (
+ "pressurant dropped out of the initial mass; it is carried, just not expelled"
+ )
+
+
+def test_the_mass_ratio_error_this_caused():
+ """Quantifies the defect on the 180 lb / 11 L point, so the fix has a number on it.
+
+ Two different numbers, and they are not the same size -- worth keeping straight:
+
+ * the IDEAL rocket equation gains ~51 m/s from flying 1.551 kg of gas as propellant;
+ * the SIMULATED trajectory only gained 183 ft of apogee (12522 -> 12339 ft at
+ eta_c* 0.9315, measured A/B on this vehicle).
+
+ The trajectory effect is far smaller because the mass comes off gradually and the
+ flight is drag-dominated at Mach 0.87. Do not quote the 51 m/s as an apogee error.
+ """
+ m_wet, m_prop, m_gas = 180 * 0.45359237, 10.7375, 1.551
+ isp_eff = 238.72 * 9.80665
+ dv_wrong = isp_eff * math.log(m_wet / (m_wet - m_prop - m_gas)) # gas flown as propellant
+ dv_right = isp_eff * math.log(m_wet / (m_wet - m_prop)) # gas carried
+ assert dv_wrong > dv_right, "expelling the gas must inflate the mass ratio"
+ assert dv_wrong - dv_right == pytest.approx(51.0, abs=2.0), (
+ f"ideal-dv overstatement changed: {dv_wrong - dv_right:.1f} m/s"
+ )
+ # and the measured trajectory effect, which is the number that actually matters
+ apogee_bug_ft, apogee_fixed_ft = 12522.0, 12339.0
+ assert apogee_bug_ft > apogee_fixed_ft
+ assert (apogee_bug_ft / apogee_fixed_ft - 1) == pytest.approx(0.0148, abs=0.004), (
+ "the simulated apogee inflation was 1.5 %, not the 15 % the rocket equation implies"
+ )
+
+
+def test_zero_flow_cannot_empty_the_tank():
+ """The old model hit exactly -0.000 kg at burnout and RocketPy raised on it."""
+ m_gas, burn = 1.551, 3.918
+ assert m_gas - (m_gas / burn) * burn == pytest.approx(0.0, abs=1e-12), "the old model ran it dry"
+ assert m_gas - 0.0 * burn == m_gas, "the fixed model leaves the COPV full"
diff --git a/EngineDesign/ui/flight_sim.py b/EngineDesign/ui/flight_sim.py
index 519ea5324..bf4847433 100644
--- a/EngineDesign/ui/flight_sim.py
+++ b/EngineDesign/ui/flight_sim.py
@@ -695,16 +695,29 @@ def compute_component_inertia(mass, cm_pos, system_cm, radius, height=None):
spherical_caps=False
)
- # Estimate pressurant mass flow rate
- # Pressurant flows out to replace consumed propellant volume
- # Simplified: assume linear depletion over burn time
- # More accurate would be based on actual ullage volume increase rate
- if effective_burn_time > 0:
- mdot_pressurant_avg = m_pressurant / effective_burn_time
- else:
- mdot_pressurant_avg = 0.0
-
- print(f" Pressurant (N₂): {m_pressurant:.3f} kg initial, ~{mdot_pressurant_avg:.4f} kg/s avg flow")
+ # PRESSURANT DOES NOT LEAVE THE VEHICLE.
+ #
+ # This used to drain the whole COPV over the burn
+ # (mdot = m_pressurant / burn_time), which RocketPy subtracts from vehicle mass --
+ # i.e. the gas was being flown as propellant. It is not: it moves from the COPV
+ # into the ullage the departing propellant leaves behind, and every gram of it is
+ # still on board at burnout. The comment on the old line even said "flows out to
+ # propellant tanks", which is exactly the reason it must not be expelled.
+ #
+ # What it cost: on the 180 lb / 11 L point, 1.551 kg of N2 out of 81.647 kg wet.
+ # Burnout mass 69.36 kg instead of 70.90, so ln(m0/mf) went 0.1412 -> 0.1631 and
+ # ideal dv was over-stated by 51 m/s, about 13 %. It also drove the tank to
+ # exactly -0.000 kg at burnout, which RocketPy raises on, so the sim would
+ # intermittently fail outright rather than just answer wrongly.
+ #
+ # Only configs that actually declare press_tank.initial_gas_mass were affected;
+ # leaving it unset (the shipped configs) modelled no pressurant at all.
+ #
+ # The COPV -> tank transfer does shift the CG, which is not modelled here either
+ # way. That is a stability question, not a trajectory one.
+ mdot_pressurant_avg = 0.0
+
+ print(f" Pressurant (N₂): {m_pressurant:.3f} kg, carried as dead mass (not expelled)")
# Convert mdot_lox and mdot_fuel to RocketPy Functions if they're not already
# (MassFlowRateBasedTank expects Functions)
@@ -817,7 +830,7 @@ def compute_component_inertia(mass, cm_pos, system_cm, radius, height=None):
initial_liquid_mass=m_pressurant, # All mass starts as "liquid" (actually high-pressure gas)
initial_gas_mass=0.01, # Small amount
liquid_mass_flow_rate_in=0.0,
- liquid_mass_flow_rate_out=mdot_pressurant, # Gas flows out to propellant tanks
+ liquid_mass_flow_rate_out=mdot_pressurant, # zero: see the note at mdot_pressurant_avg
gas_mass_flow_rate_in=0.0,
gas_mass_flow_rate_out=0.0,
discretize=100,
From 3bfb717598badcfa77d82b4f5b1a8b4a2c473318 Mon Sep 17 00:00:00 2001
From: Carlsaurus
Date: Tue, 15 Sep 2026 19:06:43 -0700
Subject: [PATCH 12/24] Re-cut the 180 lb point at O/F 1.50, and size the COPV
from the config
Same three constraints, still exact: 180.000 lb wet, 8.0001:1, 11.000 L liquid +
5.000 L COPV = 16.000 L. RocketPy independently reports "Initial T/W ratio:
8.000" off its own mass model.
O/F 1.5103, Pc 433.96 psia, Isp 237.11 s, 27 doublets at 41/43 deg,
burn 3.854 s, impulse 24686 N.s, apogee 12404 ft modelled / 11170 ft at
eta_c* 0.893.
What 1.65 -> 1.50 costs and buys, measured both ways:
Isp 238.72 -> 237.11 s impulse -1.6 %
apogee 12767 -> 12404 ft -363 ft, -2.8 %
Tc 3303 -> 3226 K -77 K
q_throat 20.56 -> 19.20 MW/m2 -6.6 % (Bartz)
included 88 -> 84 deg more face-heating margin
fuel drill L/d 11.07 -> 9.08 counterbore no longer required
The heat-flux gain is 6.6 %, not the 2.3 % the Tc drop alone implies: c* RISES
0.19 % moving off the peak and h_g ~ (Pc/c*)^0.8, so h_g falls 3.0 % while
(T_aw - T_wall) falls 3.7 %. Both terms are doing work.
Two more flight-sim defects, both found by specifying a 5 L COPV honestly:
Fluid(name="GN2_COPV", density=200) was a number with nothing behind it. Real
GN2 at 4500 psi / 293 K is 310 kg/m3 (CoolProp, Z = 1.150), so 200 under-states
a charged bottle by 35 % and caps 5 L at 1.0 kg. A 5 L COPV holds 1.551 kg and
RocketPy rejected the tank outright as overfilled. Volume now comes from
free_volume_L -- the number the operator actually specifies, and the one a
propellant-volume budget counts -- and density is mass/volume, which assumes
nothing about fill pressure or gas species.
initial_gas_mass=0.01 was then added ALONGSIDE the liquid, so the tank held
m_pressurant + 0.01 kg: 0.0050322 m3 in a 0.0050000 m3 bottle, and RocketPy
raised "Input Function image must be within the domain". The stub now comes out
of the pressurant mass instead of on top of it, and the density carries 0.1 % of
solver ullage so a tank filled to exactly its own volume does not fail on float
equality. Mass is conserved exactly in both.
---
EngineDesign/configs/ethalox_180lb_8to1.yaml | 184 +++++++++---------
.../test_pressurant_is_not_propellant.py | 68 +++++++
EngineDesign/ui/flight_sim.py | 41 +++-
3 files changed, 195 insertions(+), 98 deletions(-)
diff --git a/EngineDesign/configs/ethalox_180lb_8to1.yaml b/EngineDesign/configs/ethalox_180lb_8to1.yaml
index 128728612..a40c0598a 100644
--- a/EngineDesign/configs/ethalox_180lb_8to1.yaml
+++ b/EngineDesign/configs/ethalox_180lb_8to1.yaml
@@ -1,4 +1,5 @@
-# CalSTAR ethalox -- 180 lb vehicle, 8:1 thrust-to-weight. 2026-09-15.
+# CalSTAR ethalox -- 180 lb vehicle, 8:1 thrust-to-weight, O/F 1.50. 2026-09-15.
+# Supersedes the O/F 1.65 build of this same design point (see THE O/F TRADE below).
#
# Reproduce: python3 scripts/layer1_run.py --config configs/ethalox_180lb_8to1.yaml
# Audit: python3 scripts/design_audit.py configs/ethalox_180lb_8to1.yaml
@@ -6,79 +7,80 @@
#
# THE THREE HARD CONSTRAINTS, ALL EXACT:
# wet mass 81.6466 kg = 180.000 lb
-# thrust 6405.4 N = 1440.0 lbf -> T/W = 8.0000 : 1
+# thrust 6405.5 N = 1440.0 lbf -> T/W = 8.0001 : 1
# liquid 11.0000 L + COPV 5.0000 L = 16.0000 L
-#
-# 8 x 180 lbf is where the thrust number comes from. Nothing else set it.
+# (RocketPy independently reports "Initial T/W ratio: 8.000" from its own mass model.)
#
# ENGINE
-# O/F 1.6566 Pc 434.20 psia Isp 238.72 s eta_c* 0.9504
-# 28 doublets, theta 40 / 48 deg (included 88), d_jet 1.453 / 1.260 mm
-# bore 127.000 mm = 5.0000 in, throat 43.64 mm, eps 5.69, L* 1.0000 m
-# burn 3.914 s, total impulse 25074 N.s, LOX-limited with 16 g of fuel residual
+# O/F 1.5103 Pc 433.96 psia Isp 237.11 s eta_c* 0.9501
+# 27 doublets, theta 41 / 43 deg (included 84), d_jet 1.452 / 1.328 mm
+# bore 127.000 mm = 5.0000 in, throat 43.84 mm, L* 1.0000 m
+# burn 3.854 s, impulse 24686 N.s, LOX-limited with 29 g of fuel residual
+# propellant 5.6030 L LOX + 5.3970 L ethanol = 6.3874 + 4.2583 = 10.6456 kg
#
-# PROPELLANT: WHY 11 L AT O/F 1.65 AND NOT SOMETHING DENSER
-# With volume capped instead of mass, the objective is DENSITY impulse (rho_bulk * Isp),
-# not Isp -- and with wet mass ALSO capped, dv = Isp*g0*ln(m_wet/(m_wet - m_prop)) where
-# m_prop = rho_bulk * 11 L. Ethanol is much less dense than LOX, so the density term pulls
-# O/F up while Isp pulls it back down. Swept 1.30 -> 2.50:
-# peak Isp at O/F 1.675 dv 330.6 m/s Tc 3311 K
-# peak dv at O/F 1.850 dv 332.1 m/s Tc 3342 K (+0.45 %)
-# the requirement at O/F 1.650 dv 330.1 m/s Tc 3302 K (-0.62 % off peak)
-# 0.62 % of dv is not worth 40 K on the ablative, a 12 % move off the stated O/F, and
-# re-balancing the injector. The optimum is flat; staying at 1.65 is the right call.
-# -> 11.000 L splits 5.8645 L LOX / 5.1355 L ethanol = 6.6856 + 4.0519 = 10.7375 kg.
+# THE O/F TRADE -- WHAT 1.65 -> 1.50 ACTUALLY BUYS
+# Measured, both designs converged and audited the same way:
+# O/F 1.65 O/F 1.50 delta
+# Isp 238.72 s 237.11 s -1.61 s
+# total impulse 25074 24686 -1.6 %
+# apogee (modelled) 12767 ft 12404 ft -363 ft (-2.8 %)
+# apogee (eta 0.893) 11502 ft 11170 ft -332 ft
+# Tc 3303 K 3226 K -77 K
+# q_throat (Bartz) 20.56 19.20 MW/m2 -6.6 %
+# doublets 28 27
+# included angle 88 84 deg more face-heating margin
+# fuel passage L/d 11.07 L/d 9.08 counterbore no longer required
+# Note the heat-flux gain is 6.6 %, NOT the 2.3 % the Tc drop alone suggests and not
+# more: c* RISES 0.19 % moving off the peak, and h_g ~ (Pc/c*)^0.8, so h_g only falls
+# 3.0 % while (T_aw - T_wall) falls 3.7 %. Both terms matter.
+# 363 ft for 6.6 % less flux on the liner is the trade. Both ends sit inside the
+# 4000-13000 ft window with room.
#
-# PRESSURANT: A 5 L COPV IS AMPLY SIZED
-# Dome-regulated. The regulator holds while the COPV stays above the tank setpoint, so
-# deliverable gas is V_copv*(rho(4500 psi) - rho(582 psi)) = 1.311 kg against the 0.585 kg
-# the tanks swallow -- 2.24x. At burnout the COPV is still at 2571 psi (isothermal).
-# Real gas, not ideal: N2 at 310 bar has Z = 1.150, so ideal over-states the fill by 15 %.
-# On-board gas 1.551 kg. Helium would save 1.33 kg, but note dv depends only on m_wet and
-# m_prop -- helium buys STRUCTURAL budget, not apogee.
+# APOGEE (RocketPy, 626.67 m pad, deterministic)
+# eta_c* 0.9501 (modelled) 3781 m = 12404 ft
+# eta_c* 0.9121 3529 m = 11577 ft
+# eta_c* 0.8931 3405 m = 11170 ft
+# eta_c* 0.8551 3161 m = 10370 ft
+# eta_c* 0.8171 2923 m = 9589 ft
+# The modelled 0.95 is optimistic against a 0.87 published comparable, so expect the
+# MIDDLE of that list, ~11200 ft. Thrust is pinned at 6405.5 N by the 8:1 requirement,
+# so a worse eta_c* does not cut thrust -- it raises mdot and shortens the burn.
#
# MASS BUDGET -- THE NUMBER TO BUILD TO
-# propellant 10.737 kg 23.7 lb fixed by 11 L at O/F 1.65
-# pressurant GN2 1.551 kg 3.4 lb fixed by 5 L at 4500 psi
-# STRUCTURE 69.358 kg 152.9 lb <-- everything else has to fit in this
+# propellant 10.646 kg 23.5 lb fixed by 11 L at O/F 1.50
+# pressurant GN2 1.551 kg 3.4 lb fixed by 5 L at 4500 psi (CoolProp, Z = 1.150)
+# STRUCTURE 69.450 kg 153.1 lb <-- everything else has to fit in this
# engine+plumbing 7.000 kg 15.4 lb
# LOX tank 2.000 kg 4.4 lb
# fuel tank 1.700 kg 3.7 lb
# COPV 3.299 kg 7.3 lb
-# airframe 55.359 kg 122.0 lb
-# The 152.9 lb is derived and binding. The split under it is an ALLOCATION, not a
-# measurement -- reallocate freely, but the total cannot move without breaking 8:1.
+# airframe 55.451 kg 122.2 lb
+# 153.1 lb is derived and binding. The split under it is an ALLOCATION, not a
+# measurement -- reallocate freely, the total cannot move without breaking 8:1.
#
-# APOGEE (RocketPy, 626.67 m pad, deterministic across repeat runs)
-# eta_c* 0.9504 (modelled) 3891 m = 12765 ft
-# eta_c* 0.9125 3632 m = 11917 ft
-# eta_c* 0.8935 3505 m = 11500 ft
-# eta_c* 0.8555 3255 m = 10678 ft
-# eta_c* 0.8174 3009 m = 9871 ft
-# Every point is inside the 4000-13000 ft window. The modelled 0.95 is optimistic against
-# a 0.87 published comparable, so the honest expectation is the MIDDLE of that list,
-# ~11500 ft, not the top. Thrust is fixed at 6405.4 N by the 8:1 requirement, so a worse
-# eta_c* does not reduce thrust -- it raises mdot and shortens the burn.
+# PRESSURANT
+# Dome-regulated. Deliverable gas V_copv*(rho(4500 psi) - rho(582 psi)) = 1.311 kg
+# against the 0.585 kg the tanks swallow: 2.24x. COPV ends the burn near 2500 psi.
+# dv depends only on m_wet and m_prop, so helium would buy STRUCTURAL budget, not apogee.
#
-# TANK FILL IS 0.80, NOT 0.90
-# RocketPy's tank model would not run above ~0.80 on this geometry. 20 % ullage is
-# defensible for LOX anyway (boil-off, thermal expansion). It changes the tank ENVELOPE,
-# not the liquid: 13.750 L of tank for 11.000 L of propellant.
+# TANK FILL IS 0.80
+# RocketPy's tank model will not run above ~0.80 on this geometry, and 20 % ullage is
+# defensible for LOX anyway. It changes the tank ENVELOPE, not the liquid:
+# 13.750 L of tank for 11.000 L of propellant.
#
# READ THE 16 L RULE BEFORE COMMITTING
-# Held here: LIQUID propellant + COPV = 16.000 L exactly, which is what you asked for, and
-# it also clears a 16 L cap on propellant alone with 5 L to spare.
-# If the rule is measured on TANK volume instead, this vehicle is 13.750 + 5.000 = 18.750 L
-# and does NOT comply -- you would drop to ~8.8 L of liquid and lose roughly 1500 ft.
-# Check which one the rulebook means.
+# Held here: LIQUID propellant + COPV = 16.000 L exactly, which is what was asked for,
+# and it also clears a 16 L cap on propellant alone with 5 L to spare.
+# If the rule is measured on TANK volume, this vehicle is 13.750 + 5.000 = 18.750 L and
+# does NOT comply -- you would drop to ~8.8 L of liquid and lose roughly 1500 ft.
#
# STILL REQUIRES HARDWARE
# FLOW-TEST the injector. Cd 0.80 is an inlet-geometry correlation, not a measurement.
-# Cd 0.72-0.88 moves thrust -4.4 / +3.8 % and keeps dP/Pc inside 0.20-0.40 throughout,
+# Cd 0.72-0.88 moves thrust -4.5 / +3.8 % and keeps dP/Pc inside 0.20-0.40 throughout,
# so a surprise there moves T/W off 8:1 rather than making the design infeasible.
-# COUNTERBORE THE FUEL ORIFICES. At 48 deg the fuel passage is 18.98 mm; run the 1.260 mm
-# drill the whole way and it is L/d 11.1. With the 4 mm counterbore it is L/d 3.5.
-# Spot-face every hole normal to its own axis -- incidence is 50 / 42 deg.
+# Spot-face every orifice normal to its own axis -- incidence is 49 / 47 deg.
+# At L/d 9.1 the fuel orifice no longer needs a counterbore; the 4 mm one is still
+# declared and still preferable if the shop is set up for it.
propellant_preset: ethalox
fluids:
fuel:
@@ -115,15 +117,15 @@ injector:
type: impinging
geometry:
oxidizer:
- n_elements: 28
- d_jet: 0.0014530882632717026
- impingement_angle: 40.0
- spacing: 0.008423896045553311
+ n_elements: 27
+ d_jet: 0.0014520812103684332
+ impingement_angle: 41.0
+ spacing: 0.008325373933677317
fuel:
- n_elements: 28
- d_jet: 0.001259635700224197
- impingement_angle: 48.0
- spacing: 0.01079760754492665
+ n_elements: 27
+ d_jet: 0.0013278308621349137
+ impingement_angle: 43.0
+ spacing: 0.010656598879149048
feed_system:
fuel:
line_size: 1/2_TUBE_035
@@ -338,7 +340,7 @@ combustion:
cea_parallel_workers: null
ox_name: LOX
fuel_name: Ethanol
- expansion_ratio: 5.698558803827487
+ expansion_ratio: 5.607547652431548
cache_file: output/cache/cea_cache_LOX_Ethanol_3D.npz
Pc_range:
- 1000000.0
@@ -393,21 +395,21 @@ combustion:
Ea_hydrogen: 40000.0
n_pre_hydrogen: 0.2
chamber_geometry:
- design_pressure: 2993691.4915861404
- design_thrust: 6405.43651185836
- design_MR: 1.6565517328133872
+ design_pressure: 2992068.773044018
+ design_thrust: 6405.482749795813
+ design_MR: 1.5103453830256488
chamber_diameter: 0.127
- Lstar: 1.0000009998704453
- exit_diameter: 0.10417150932350908
- expansion_ratio: 5.698558803827487
+ Lstar: 1.0000009986701235
+ exit_diameter: 0.10380762924022284
+ expansion_ratio: 5.607547652431548
nozzle_efficiency: 0.95
- A_throat: 0.0014956251532967633
- A_exit: 0.008522907884545105
- volume: 0.0014956266487281514
- length: 0.12798074424989195
- length_cylindrical: 0.09588581577769259
- length_contraction: 0.032094928472199365
- Cf: 1.430602328196876
+ A_throat: 0.001509299589646074
+ A_exit: 0.00846346937073574
+ volume: 0.0015093010969384815
+ length: 0.12890304269490516
+ length_cylindrical: 0.09695135516431024
+ length_contraction: 0.03195168753059491
+ Cf: 1.4184199807331177
chamber: null
nozzle: null
solver:
@@ -454,19 +456,19 @@ optimizer:
refresh_sigma_scale: 0.2
num_tracks: 1
lox_tank:
- lox_h: 0.4782536685178334
+ lox_h: 0.4569257044006979
lox_radius: 0.06985
ox_tank_pos: 0.8
- mass: 6.685586338227163
- initial_pressure_psi: 582.0743266932028
- tank_volume_m3: 0.0073306249999999995
+ mass: 6.387385409941898
+ initial_pressure_psi: 583.442324492869
+ tank_volume_m3: 0.007003712072304712
fuel_tank:
- rp1_h: 0.35191107212355094
+ rp1_h: 0.3698324864164217
rp1_radius: 0.0762
fuel_tank_pos: 3.0
- mass: 4.051870508016463
- initial_pressure_psi: 582.0743266932028
- tank_volume_m3: 0.006419375
+ mass: 4.258256939961265
+ initial_pressure_psi: 583.442324492869
+ tank_volume_m3: 0.006746287927695286
press_tank:
press_h: 0.27410072797083124
press_radius: 0.0762
@@ -476,7 +478,7 @@ press_tank:
mass: null
free_volume_L: 5.0
rocket:
- airframe_mass: 55.359280864867486
+ airframe_mass: 55.45109536120796
engine_mass: 7.0
lox_tank_structure_mass: 2.0
fuel_tank_structure_mass: 1.7
@@ -517,13 +519,13 @@ environment:
elevation: 626.67
atmosphere_model: standard_atmosphere
thrust:
- burn_time: 3.914
+ burn_time: 3.854
design_requirements:
target_thrust: 6405.439125975119
target_chamber_pressure_psi: 430.0
target_apogee: 3890.7
- optimal_of_ratio: 1.65
- target_burn_time: 3.914
+ optimal_of_ratio: 1.5
+ target_burn_time: 3.854
max_lox_tank_pressure_psi: 600.0
max_fuel_tank_pressure_psi: 600.0
max_P_tank_O: null
@@ -541,8 +543,8 @@ design_requirements:
chugging_margin_min: 0.2
acoustic_margin_min: 0.1
feed_stability_min: 0.15
- lox_tank_capacity_kg: 6.685586338227163
- fuel_tank_capacity_kg: 4.051870508016463
+ lox_tank_capacity_kg: 6.387385409941898
+ fuel_tank_capacity_kg: 4.258256939961265
propellant_tank_fill_factor: 0.8
copv_free_volume_L: 5.0
copv_free_volume_m3: null
diff --git a/EngineDesign/tests/test_pressurant_is_not_propellant.py b/EngineDesign/tests/test_pressurant_is_not_propellant.py
index ecce31b6e..131f817fe 100644
--- a/EngineDesign/tests/test_pressurant_is_not_propellant.py
+++ b/EngineDesign/tests/test_pressurant_is_not_propellant.py
@@ -68,3 +68,71 @@ def test_zero_flow_cannot_empty_the_tank():
m_gas, burn = 1.551, 3.918
assert m_gas - (m_gas / burn) * burn == pytest.approx(0.0, abs=1e-12), "the old model ran it dry"
assert m_gas - 0.0 * burn == m_gas, "the fixed model leaves the COPV full"
+
+
+# ---------------------------------------------------------------------------------------
+# COPV volume and density are declared, not assumed
+# ---------------------------------------------------------------------------------------
+
+def test_no_hardcoded_copv_density():
+ """Fluid(density=200) was a number with no source on it.
+
+ Real GN2 at 4500 psi / 293 K is 310 kg/m3 (CoolProp, Z = 1.150). At 200 a 5 L COPV
+ caps at 1.0 kg, and the 1.551 kg a 5 L bottle actually holds made RocketPy refuse the
+ tank as "overfilled" -- so the flight sim failed outright on a correctly specified COPV.
+ """
+ src = FLIGHT_SIM.read_text()
+ assert 'Fluid(name="GN2_COPV", density=200)' not in src, "the invented 200 kg/m3 is back"
+ assert "density=m_pressurant/(V_copv*0.999)" in src, (
+ "COPV density must be mass/volume from the config, not a constant "
+ "(the 0.999 is solver ullage -- see test_solver_ullage_is_small_and_conserves_mass)"
+ )
+
+
+def test_copv_geometry_follows_free_volume():
+ """press_radius x press_h and free_volume_L could disagree; free_volume_L wins.
+
+ It is the number the operator specifies and the one a propellant-volume budget counts.
+ """
+ src = FLIGHT_SIM.read_text()
+ block = src[src.index("free_L = getattr"):src.index("press_geom = CylindricalTank", src.index("free_L = getattr"))]
+ assert "V_copv = float(free_L)/1000.0" in block
+ assert "press_h_eff = V_copv/(np.pi*config.press_tank.press_radius**2)" in block
+
+
+def test_a_5L_copv_holds_what_a_5L_copv_holds():
+ """The case that broke it: 5 L, 1.551 kg of GN2 at 4500 psi."""
+ V, m = 0.005, 1.551
+ assert m / V == pytest.approx(310.2, abs=1.0), "5 L at 4500 psi is ~310 kg/m3"
+ assert m > V * 200.0, "at the old hardcoded 200 kg/m3 this tank reads as overfilled"
+ assert V * 200.0 == pytest.approx(1.0, abs=0.01), "the old cap was 1.0 kg"
+
+
+def test_gas_stub_comes_out_of_the_pressurant_mass():
+ """A flat 0.01 kg added ALONGSIDE the liquid over-filled the COPV.
+
+ With density derived as m_pressurant/V, the tank then held m_pressurant + 0.01 kg =
+ 0.0050322 m3 in a 0.0050000 m3 bottle, and RocketPy rejected it:
+ "Input Function image (0.00503...) must be within the domain (0.0, 0.005)".
+ """
+ src = FLIGHT_SIM.read_text()
+ assert "initial_gas_mass=0.01," not in src, "the additive 0.01 kg stub is back"
+ assert "initial_liquid_mass=max(0.0, m_pressurant - 1.0e-4)," in src
+ assert "initial_gas_mass=1.0e-4," in src
+
+ # the arithmetic that broke it, on the 5 L / 1.551 kg COPV
+ V, m = 0.005, 1.551
+ rho = m / V
+ assert (m + 0.01) / rho == pytest.approx(0.0050322, abs=1e-6), "the overflow"
+ assert (m + 0.01) / rho > V, "additive stub must exceed the tank"
+ assert m / rho == pytest.approx(V, rel=1e-12), "taking the stub out of m conserves volume"
+
+
+def test_solver_ullage_is_small_and_conserves_mass():
+ """The 0.1 % lives in the density, never in the mass."""
+ src = FLIGHT_SIM.read_text()
+ assert "density=m_pressurant/(V_copv*0.999)" in src
+ V, m = 0.005, 1.551
+ rho = m / (V * 0.999)
+ assert m / rho == pytest.approx(0.999 * V, rel=1e-12), "fluid sits just inside the domain"
+ assert rho * (0.999 * V) == pytest.approx(m, rel=1e-12), "mass is exact"
diff --git a/EngineDesign/ui/flight_sim.py b/EngineDesign/ui/flight_sim.py
index bf4847433..9ff59e4d4 100644
--- a/EngineDesign/ui/flight_sim.py
+++ b/EngineDesign/ui/flight_sim.py
@@ -680,7 +680,6 @@ def compute_component_inertia(mass, cm_pos, system_cm, radius, height=None):
# GN2 (gaseous nitrogen) for ullage and pressurant - density varies with pressure
# Use average density during blowdown (higher at start, lower at end)
gn2_ullage = Fluid(name="GN2", density=50) # kg/m³ approximate for ullage
- gn2_pressurant = Fluid(name="GN2_COPV", density=200) # kg/m³ higher density in COPV
# Pressurant (COPV) tank setup
m_pressurant = 0.0
@@ -688,10 +687,33 @@ def compute_component_inertia(mass, cm_pos, system_cm, radius, height=None):
if config.press_tank:
m_pressurant = getattr(config.press_tank, 'initial_gas_mass', None) or 0.0
if m_pressurant > 0:
- # Create pressurant tank geometry
+ # COPV VOLUME AND DENSITY COME FROM THE CONFIG, NOT FROM A CONSTANT.
+ #
+ # This used to build the tank from press_radius x press_h and fill it with
+ # Fluid(density=200), a number with no source on it. Real GN2 at 4500 psi / 293 K
+ # is 310 kg/m3 (CoolProp, Z = 1.150), so 200 under-states a charged COPV by 35 %
+ # and caps a 5 L bottle at 1.0 kg. A 5 L COPV actually holds 1.551 kg, and
+ # RocketPy then refused the tank outright as "overfilled".
+ #
+ # free_volume_L is the authoritative number: it is what the operator specifies and
+ # what a propellant-volume budget counts. Build the geometry to it and take the
+ # density as mass/volume, which is self-consistent by construction and assumes
+ # nothing about fill pressure or gas species.
+ free_L = getattr(config.press_tank, 'free_volume_L', None)
+ if free_L and free_L > 0:
+ V_copv = float(free_L)/1000.0
+ press_h_eff = V_copv/(np.pi*config.press_tank.press_radius**2)
+ else:
+ press_h_eff = config.press_tank.press_h
+ V_copv = np.pi*config.press_tank.press_radius**2*press_h_eff
+ # 0.1 % of solver ullage. RocketPy composes gas_height through
+ # geometry.inverse_volume, whose domain is exactly [0, V_copv], so a tank filled
+ # to precisely its own volume fails on float equality. Mass is conserved exactly;
+ # only the density carries the 0.1 %.
+ gn2_pressurant = Fluid(name="GN2_COPV", density=m_pressurant/(V_copv*0.999))
press_geom = CylindricalTank(
- radius=config.press_tank.press_radius,
- height=config.press_tank.press_h,
+ radius=config.press_tank.press_radius,
+ height=press_h_eff,
spherical_caps=False
)
@@ -717,7 +739,8 @@ def compute_component_inertia(mass, cm_pos, system_cm, radius, height=None):
# way. That is a stability question, not a trajectory one.
mdot_pressurant_avg = 0.0
- print(f" Pressurant (N₂): {m_pressurant:.3f} kg, carried as dead mass (not expelled)")
+ print(f" Pressurant (N₂): {m_pressurant:.3f} kg in {V_copv*1000:.2f} L "
+ f"({m_pressurant/V_copv:.0f} kg/m3), carried as dead mass (not expelled)")
# Convert mdot_lox and mdot_fuel to RocketPy Functions if they're not already
# (MassFlowRateBasedTank expects Functions)
@@ -827,8 +850,12 @@ def compute_component_inertia(mass, cm_pos, system_cm, radius, height=None):
flux_time=effective_burn_time,
liquid=gn2_pressurant, # Using "liquid" field for gas (RocketPy limitation)
gas=gn2_pressurant,
- initial_liquid_mass=m_pressurant, # All mass starts as "liquid" (actually high-pressure gas)
- initial_gas_mass=0.01, # Small amount
+ # The stub comes OUT OF the pressurant mass, not on top of it. It used to be a
+ # flat 0.01 kg added alongside initial_liquid_mass, so the tank held
+ # m_pressurant + 0.01 kg: with the density derived from m_pressurant/V that is
+ # 0.0050322 m3 in a 0.0050000 m3 bottle, and RocketPy rejected it outright.
+ initial_liquid_mass=max(0.0, m_pressurant - 1.0e-4),
+ initial_gas_mass=1.0e-4,
liquid_mass_flow_rate_in=0.0,
liquid_mass_flow_rate_out=mdot_pressurant, # zero: see the note at mdot_pressurant_avg
gas_mass_flow_rate_in=0.0,
From 08492a87ee3a71773301079a7f8cf2f2bcec294b Mon Sep 17 00:00:00 2001
From: Carlsaurus
Date: Tue, 15 Sep 2026 19:21:03 -0700
Subject: [PATCH 13/24] COPV to 4000 psi, and real component masses instead of
allocations
COPV fill 4500 -> 4000 psi: 1.551 -> 1.421 kg of GN2 on board (284.1 kg/m3,
CoolProp Z = 1.116). Deliverable gas still 1.189 kg against the 0.638 kg the
tanks swallow, 1.86x, and the bottle ends the burn near 2000 psi, so the
regulator still holds. The 0.29 lb goes back into the structure budget.
Mass model now carries hand-calculated component masses rather than the earlier
allocations. Tanks at 9 lb each as specified; engine 18.58 lb from geometry
(steel sleeve 7.06, injector 3.34, flanges 3.18, ablative liner 2.63, nozzle
1.61, bosses 0.55, graphite 0.21); feed dry 36.74 lb. Wet still closes at
180.000 lb and T/W at 8.0001:1, and since m_wet and m_prop are both unchanged
the apogee is unchanged at 12404 ft modelled.
Two findings recorded in the header:
A bare aluminium nozzle does not survive this engine. Lumped heat sink with
Bartz falling off as (At/A)^0.9 from 19.2 MW/m2 over a 3.854 s burn puts a 5 mm
wall at 1818 K where the graphite insert ends and 1067 K at the exit, even after
discounting Bartz to 0.6x, against a 933 K melt. Holding 400 K of rise needs
16-32 mm, and a wall that thick is not lumped anyway. Carrying 12 mm of ablative
inside a 3 mm aluminium shell instead: +0.96 lb over the aluminium that does not
work, against a 4.6-7.7 mm recession before blowing credit.
The 6.5 in OD was never optimised for 6405 N -- frozen_parameters pins it, and it
came from the 8 kN vehicle. 4.5 in bore is equally legal on Dc/Dt but comes out
HEAVIER (14.08 vs 13.79 lb): L* is fixed at 1.0 m, so a narrower bore needs a
longer barrel and the extra sleeve costs more than the diameter saves. The lever
on chamber mass is L*, not diameter.
---
EngineDesign/configs/ethalox_180lb_8to1.yaml | 72 +++++++++++++++-----
1 file changed, 54 insertions(+), 18 deletions(-)
diff --git a/EngineDesign/configs/ethalox_180lb_8to1.yaml b/EngineDesign/configs/ethalox_180lb_8to1.yaml
index a40c0598a..200854a07 100644
--- a/EngineDesign/configs/ethalox_180lb_8to1.yaml
+++ b/EngineDesign/configs/ethalox_180lb_8to1.yaml
@@ -46,17 +46,53 @@
# MIDDLE of that list, ~11200 ft. Thrust is pinned at 6405.5 N by the 8:1 requirement,
# so a worse eta_c* does not cut thrust -- it raises mdot and shortens the burn.
#
-# MASS BUDGET -- THE NUMBER TO BUILD TO
-# propellant 10.646 kg 23.5 lb fixed by 11 L at O/F 1.50
-# pressurant GN2 1.551 kg 3.4 lb fixed by 5 L at 4500 psi (CoolProp, Z = 1.150)
-# STRUCTURE 69.450 kg 153.1 lb <-- everything else has to fit in this
-# engine+plumbing 7.000 kg 15.4 lb
-# LOX tank 2.000 kg 4.4 lb
-# fuel tank 1.700 kg 3.7 lb
-# COPV 3.299 kg 7.3 lb
-# airframe 55.451 kg 122.2 lb
-# 153.1 lb is derived and binding. The split under it is an ALLOCATION, not a
-# measurement -- reallocate freely, the total cannot move without breaking 8:1.
+# MASS BUDGET -- FROM COMPONENT HAND CALCS, NOT ALLOCATIONS
+# propellant 10.646 kg 23.47 lb 11 L at O/F 1.50
+# pressurant GN2 1.421 kg 3.13 lb 5 L at 4000 psi, 284.1 kg/m3 (CoolProp Z=1.116)
+# engine + plumbing 13.626 kg 30.04 lb engine 18.58 + valves/lines 11.46 lb
+# LOX tank 4.082 kg 9.00 lb given
+# fuel tank 4.082 kg 9.00 lb given
+# COPV 3.300 kg 7.28 lb 5 L carbon-wrapped
+# airframe + recovery 44.489 kg 98.08 lb what is left
+# == WET 81.647 kg 180.000 lb -> T/W 8.0001 : 1
+#
+# ENGINE BREAKDOWN (18.58 lb):
+# mild steel sleeve 0.250 in 3.204 kg 7.06 lb O152.4->165.1 x 129 mm
+# aluminium injector 1.514 kg 3.34 lb 0.5 in face + 30 mm manifold
+# 2 flanges + bolts + seals 1.440 kg 3.18 lb
+# ablative liner 0.500 in 1.193 kg 2.63 lb chamber only
+# nozzle, 12 mm ablative
+# + 3 mm alu shell 0.731 kg 1.61 lb see THE NOZZLE below
+# igniter + instrument bosses 0.250 kg 0.55 lb
+# graphite throat insert 0.093 kg 0.21 lb 6 mm wall x 44 mm
+#
+# FEED, DRY (excluding gas) 16.665 kg 36.74 lb
+# tanks 18.00, COPV 7.28, 2x main valve 3.97, lines+fittings 2.43,
+# dome regulator 1.76, fill/vent/check 1.54, brackets 1.76
+#
+# THE NOZZLE CANNOT BE BARE ALUMINIUM
+# Lumped heat sink, dT = q*t_burn/(rho*cp*t_wall), Bartz falling off as (At/A)^0.9 from
+# 19.2 MW/m2 at the throat, 3.854 s burn. A 5 mm aluminium wall reaches:
+# eps 2.64 (where the graphite insert ends) 1818 K even at 0.6x Bartz
+# eps 5.61 (the exit) 1067 K even at 0.6x Bartz
+# against a 933 K melt. It melts everywhere, with or without discounting Bartz. Holding
+# 400 K of rise would take 16-32 mm of aluminium, and a wall that thick is not lumped --
+# the bore still runs far hotter than the average. Options priced:
+# A aluminium 5 mm 0.65 lb MELTS
+# B aluminium 20 mm heat sink 2.61 lb lumped-average only
+# C 12 mm ablative + 3 mm alu shell 1.61 lb <- carried here
+# D mild steel 5 mm 1.89 lb 782-1108 K: survives melt, past strength
+# C costs +0.96 lb over the aluminium that does not work. 12 mm of ablative against a
+# 4.6-7.7 mm recession (before blowing credit) leaves real margin.
+#
+# CHAMBER DIAMETER WAS NOT OPTIMISED FOR THIS THRUST
+# frozen_parameters.D_chamber_outer_mm = 165.1 pins the OD at 6.5 in; it was inherited
+# from the 8 kN vehicle, not re-derived at 6405 N. Dc/Dt came out 2.897 against a
+# [2.2, 3.2] band, so 4.5 in bore / 6.0 in OD is also legal. It is HEAVIER, not lighter:
+# 5.0 in bore chamber 140 mm ablative + sleeve + injector 13.79 lb
+# 4.5 in bore chamber 164 mm ablative + sleeve + injector 14.08 lb
+# L* is fixed at 1.0 m, so a narrower bore needs a longer barrel and the extra sleeve
+# costs more than the diameter saves. The lever on chamber mass is L*, not diameter.
#
# PRESSURANT
# Dome-regulated. Deliverable gas V_copv*(rho(4500 psi) - rho(582 psi)) = 1.311 kg
@@ -473,19 +509,19 @@ press_tank:
press_h: 0.27410072797083124
press_radius: 0.0762
pres_tank_pos: 3.6
- dry_mass: 3.2988888888888885
- initial_gas_mass: 1.551
+ dry_mass: 3.3
+ initial_gas_mass: 1.421
mass: null
free_volume_L: 5.0
rocket:
- airframe_mass: 55.45109536120796
- engine_mass: 7.0
- lox_tank_structure_mass: 2.0
- fuel_tank_structure_mass: 1.7
+ airframe_mass: 44.489321590096836
+ engine_mass: 13.626000000000001
+ lox_tank_structure_mass: 4.082331330000001
+ fuel_tank_structure_mass: 4.082331330000001
engine_cm_offset: 0.15
propulsion_dry_mass: 21.0
propulsion_cm_offset: 0.4
- copv_dry_mass: 3.2988888888888885
+ copv_dry_mass: 3.3
inertia:
- 8.0
- 8.0
From 30f8bac5c17359f83489b8a36d406a6709f6b953 Mon Sep 17 00:00:00 2001
From: Carlsaurus
Date: Tue, 15 Sep 2026 21:17:14 -0700
Subject: [PATCH 14/24] 24 doublets on a 15 deg pitch, tanks at 10 % ullage,
sleeve past the face
n_doublets pinned to 24 so the angular pitch is 360/24 = 15.000 deg exactly,
instead of the 13.333 deg a 27-element ring was asking the machinist for. Ran the
three clean divisors under the 30 cap; all pass every gate and audit CLEAN:
n=20 (18 deg) O/F 1.4497 Isp 235.82 O/F 3.4 % low
n=24 (15 deg) O/F 1.5045 Isp 236.99 on target <- shipped
n=30 (12 deg) O/F 1.5138 Isp 237.18 more holes for +0.08 %
Everything else holds exactly: 180.0000 lb wet, T/W 8.0000:1, liquid 11.000 L +
COPV 5.000 L = 16.000 L, O/F 1.50, 4000 psi COPV, aluminium injector.
Tanks now declare 0.90 fill (the 10 % ullage asked for): 6.2255 L LOX and 5.9967 L
fuel, 1.6446 and 1.5842 US gal. RocketPy's tank model refuses to run above ~0.80,
so the apogee numbers came off a larger envelope at identical propellant mass --
verified insensitive, 0.80/0.75/0.70 all return 12427 ft, because the trajectory
depends on mass and not on how much empty tank surrounds it.
Sleeve now runs 40 mm past the gas boundary so the injector plugs into it:
168.94 mm long against a 128.94 mm chamber. That also means the injector is sized
to the sleeve BORE (O152.4) rather than the engine OD (O165.1), which takes more
mass off the injector than the longer sleeve adds to the case. Engine 20.28 lb.
Apogee 12427 ft modelled, 11192 ft at eta_c* 0.893. Airframe + recovery budget
lands at 96.38 lb.
---
EngineDesign/configs/ethalox_180lb_8to1.yaml | 237 +++++++++----------
1 file changed, 106 insertions(+), 131 deletions(-)
diff --git a/EngineDesign/configs/ethalox_180lb_8to1.yaml b/EngineDesign/configs/ethalox_180lb_8to1.yaml
index 200854a07..e61c067e1 100644
--- a/EngineDesign/configs/ethalox_180lb_8to1.yaml
+++ b/EngineDesign/configs/ethalox_180lb_8to1.yaml
@@ -1,122 +1,97 @@
-# CalSTAR ethalox -- 180 lb vehicle, 8:1 thrust-to-weight, O/F 1.50. 2026-09-15.
-# Supersedes the O/F 1.65 build of this same design point (see THE O/F TRADE below).
+# CalSTAR ethalox -- 180 lb vehicle, 8:1, O/F 1.50, 24 doublets. 2026-09-15.
#
# Reproduce: python3 scripts/layer1_run.py --config configs/ethalox_180lb_8to1.yaml
# Audit: python3 scripts/design_audit.py configs/ethalox_180lb_8to1.yaml
-# Robustness: python3 scripts/design_robustness.py configs/ethalox_180lb_8to1.yaml
#
-# THE THREE HARD CONSTRAINTS, ALL EXACT:
-# wet mass 81.6466 kg = 180.000 lb
+# HARD CONSTRAINTS, ALL EXACT
+# wet mass 81.6466 kg = 180.0000 lb
# thrust 6405.5 N = 1440.0 lbf -> T/W = 8.0001 : 1
# liquid 11.0000 L + COPV 5.0000 L = 16.0000 L
-# (RocketPy independently reports "Initial T/W ratio: 8.000" from its own mass model.)
#
# ENGINE
-# O/F 1.5103 Pc 433.96 psia Isp 237.11 s eta_c* 0.9501
-# 27 doublets, theta 41 / 43 deg (included 84), d_jet 1.452 / 1.328 mm
-# bore 127.000 mm = 5.0000 in, throat 43.84 mm, L* 1.0000 m
-# burn 3.854 s, impulse 24686 N.s, LOX-limited with 29 g of fuel residual
-# propellant 5.6030 L LOX + 5.3970 L ethanol = 6.3874 + 4.2583 = 10.6456 kg
+# O/F 1.5045 Pc 433.99 psia Isp 236.99 s eta_c* 0.9500
+# 24 doublets on a 15.000 deg pitch (360/24 exactly -- no 13.333 deg nonsense)
+# theta 43 / 45 deg (included 88), d_jet 1.540 / 1.412 mm
+# bore 127.000 mm = 5.0000 in, throat 43.84 mm, exit 103.78 mm, eps 5.603, L* 1.0000 m
+# burn 3.858 s, impulse 24711 N.s, LOX-limited with 13 g of fuel residual
+# web 7.85 / 10.97 mm, centre clear O69.64, wall land 15.22 mm
#
-# THE O/F TRADE -- WHAT 1.65 -> 1.50 ACTUALLY BUYS
-# Measured, both designs converged and audited the same way:
-# O/F 1.65 O/F 1.50 delta
-# Isp 238.72 s 237.11 s -1.61 s
-# total impulse 25074 24686 -1.6 %
-# apogee (modelled) 12767 ft 12404 ft -363 ft (-2.8 %)
-# apogee (eta 0.893) 11502 ft 11170 ft -332 ft
-# Tc 3303 K 3226 K -77 K
-# q_throat (Bartz) 20.56 19.20 MW/m2 -6.6 %
-# doublets 28 27
-# included angle 88 84 deg more face-heating margin
-# fuel passage L/d 11.07 L/d 9.08 counterbore no longer required
-# Note the heat-flux gain is 6.6 %, NOT the 2.3 % the Tc drop alone suggests and not
-# more: c* RISES 0.19 % moving off the peak, and h_g ~ (Pc/c*)^0.8, so h_g only falls
-# 3.0 % while (T_aw - T_wall) falls 3.7 %. Both terms matter.
-# 363 ft for 6.6 % less flux on the liner is the trade. Both ends sit inside the
-# 4000-13000 ft window with room.
+# Why 24 and not 20 or 30 -- all three are clean divisors and all three audit CLEAN:
+# n=20 (18 deg) O/F 1.4497 Isp 235.82 O/F 3.4 % low
+# n=24 (15 deg) O/F 1.5045 Isp 236.99 on target <- this one
+# n=30 (12 deg) O/F 1.5138 Isp 237.18 more holes for +0.08 % Isp
#
-# APOGEE (RocketPy, 626.67 m pad, deterministic)
-# eta_c* 0.9501 (modelled) 3781 m = 12404 ft
-# eta_c* 0.9121 3529 m = 11577 ft
-# eta_c* 0.8931 3405 m = 11170 ft
-# eta_c* 0.8551 3161 m = 10370 ft
-# eta_c* 0.8171 2923 m = 9589 ft
-# The modelled 0.95 is optimistic against a 0.87 published comparable, so expect the
-# MIDDLE of that list, ~11200 ft. Thrust is pinned at 6405.5 N by the 8:1 requirement,
-# so a worse eta_c* does not cut thrust -- it raises mdot and shortens the burn.
+# TANKS AT 10 % ULLAGE
+# mass kg mass lb liquid L liquid gal TANK L TANK gal
+# LOX 6.3874 14.082 5.6030 1.4801 6.2255 1.6446
+# ethanol 4.2583 9.388 5.3970 1.4257 5.9967 1.5842
+# TOTAL 10.6456 23.470 11.0000 2.9059 12.2222 3.2288
+# Buy 1.65 gal of LOX tank and 1.59 gal of fuel tank. Liquid + COPV is still
+# 16.000 L exactly; tanks + COPV is 17.222 L if the rule is measured on tankage.
#
-# MASS BUDGET -- FROM COMPONENT HAND CALCS, NOT ALLOCATIONS
-# propellant 10.646 kg 23.47 lb 11 L at O/F 1.50
-# pressurant GN2 1.421 kg 3.13 lb 5 L at 4000 psi, 284.1 kg/m3 (CoolProp Z=1.116)
-# engine + plumbing 13.626 kg 30.04 lb engine 18.58 + valves/lines 11.46 lb
-# LOX tank 4.082 kg 9.00 lb given
-# fuel tank 4.082 kg 9.00 lb given
-# COPV 3.300 kg 7.28 lb 5 L carbon-wrapped
-# airframe + recovery 44.489 kg 98.08 lb what is left
-# == WET 81.647 kg 180.000 lb -> T/W 8.0001 : 1
+# ENGINE MASS 20.28 lb -- SLEEVE RUNS PAST THE GAS BOUNDARY
+# The injector plugs INTO the sleeve, so the sleeve is longer than the chamber and
+# the injector is sized to the sleeve BORE, not the engine OD:
+# gas boundary (face -> throat) 128.94 mm
+# injector insertion 40.00 mm 0.5 in face + O-ring land + pilot
+# steel sleeve 168.94 mm bore O152.4, OD O165.1
+# injector OD O152.4 (not O165.1 -- it has to fit inside)
#
-# ENGINE BREAKDOWN (18.58 lb):
-# mild steel sleeve 0.250 in 3.204 kg 7.06 lb O152.4->165.1 x 129 mm
-# aluminium injector 1.514 kg 3.34 lb 0.5 in face + 30 mm manifold
+# mild steel sleeve 0.250 in 4.200 kg 9.26 lb
# 2 flanges + bolts + seals 1.440 kg 3.18 lb
-# ablative liner 0.500 in 1.193 kg 2.63 lb chamber only
-# nozzle, 12 mm ablative
-# + 3 mm alu shell 0.731 kg 1.61 lb see THE NOZZLE below
+# aluminium injector 1.290 kg 2.84 lb
+# ablative liner 0.500 in 1.193 kg 2.63 lb
+# nozzle, 12 mm abl + 3 mm alu 0.731 kg 1.61 lb
# igniter + instrument bosses 0.250 kg 0.55 lb
-# graphite throat insert 0.093 kg 0.21 lb 6 mm wall x 44 mm
+# graphite throat insert 0.093 kg 0.21 lb
+# ENGINE 9.198 kg 20.28 lb
#
-# FEED, DRY (excluding gas) 16.665 kg 36.74 lb
-# tanks 18.00, COPV 7.28, 2x main valve 3.97, lines+fittings 2.43,
-# dome regulator 1.76, fill/vent/check 1.54, brackets 1.76
+# MASS BUDGET
+# propellant 10.646 kg 23.47 lb
+# pressurant GN2 1.421 kg 3.13 lb 5 L at 4000 psi, 284.1 kg/m3
+# engine + plumbing 14.398 kg 31.74 lb engine 20.28 + valves/lines 11.46
+# LOX tank 4.082 kg 9.00 lb given
+# fuel tank 4.082 kg 9.00 lb given
+# COPV 3.300 kg 7.28 lb
+# airframe + recovery 43.718 kg 96.38 lb what is left
+# == WET 81.647 kg 180.000 lb
#
-# THE NOZZLE CANNOT BE BARE ALUMINIUM
-# Lumped heat sink, dT = q*t_burn/(rho*cp*t_wall), Bartz falling off as (At/A)^0.9 from
-# 19.2 MW/m2 at the throat, 3.854 s burn. A 5 mm aluminium wall reaches:
-# eps 2.64 (where the graphite insert ends) 1818 K even at 0.6x Bartz
-# eps 5.61 (the exit) 1067 K even at 0.6x Bartz
-# against a 933 K melt. It melts everywhere, with or without discounting Bartz. Holding
-# 400 K of rise would take 16-32 mm of aluminium, and a wall that thick is not lumped --
-# the bore still runs far hotter than the average. Options priced:
-# A aluminium 5 mm 0.65 lb MELTS
-# B aluminium 20 mm heat sink 2.61 lb lumped-average only
-# C 12 mm ablative + 3 mm alu shell 1.61 lb <- carried here
-# D mild steel 5 mm 1.89 lb 782-1108 K: survives melt, past strength
-# C costs +0.96 lb over the aluminium that does not work. 12 mm of ablative against a
-# 4.6-7.7 mm recession (before blowing credit) leaves real margin.
+# APOGEE (RocketPy, 626.67 m pad)
+# eta_c* 0.9500 (modelled) 3788 m = 12427 ft
+# eta_c* 0.9120 3536 m = 11599 ft
+# eta_c* 0.8930 3411 m = 11192 ft
+# eta_c* 0.8170 2928 m = 9607 ft
+# All inside the 4000-13000 ft window. The modelled 0.95 is optimistic against a
+# 0.87 published comparable, so plan on ~11200 ft.
#
-# CHAMBER DIAMETER WAS NOT OPTIMISED FOR THIS THRUST
-# frozen_parameters.D_chamber_outer_mm = 165.1 pins the OD at 6.5 in; it was inherited
-# from the 8 kN vehicle, not re-derived at 6405 N. Dc/Dt came out 2.897 against a
-# [2.2, 3.2] band, so 4.5 in bore / 6.0 in OD is also legal. It is HEAVIER, not lighter:
-# 5.0 in bore chamber 140 mm ablative + sleeve + injector 13.79 lb
-# 4.5 in bore chamber 164 mm ablative + sleeve + injector 14.08 lb
-# L* is fixed at 1.0 m, so a narrower bore needs a longer barrel and the extra sleeve
-# costs more than the diameter saves. The lever on chamber mass is L*, not diameter.
+# NOTE ON THE FILL FACTOR: this file declares 0.90 (the 10 % ullage asked for).
+# RocketPy's tank model refuses to run above ~0.80 on this geometry, so the apogee
+# numbers above were produced on a larger tank ENVELOPE with identical propellant
+# mass. Verified insensitive -- 0.80 / 0.75 / 0.70 all return 12427 ft, because the
+# trajectory depends on mass, not on how much empty tank surrounds it.
#
-# PRESSURANT
-# Dome-regulated. Deliverable gas V_copv*(rho(4500 psi) - rho(582 psi)) = 1.311 kg
-# against the 0.585 kg the tanks swallow: 2.24x. COPV ends the burn near 2500 psi.
-# dv depends only on m_wet and m_prop, so helium would buy STRUCTURAL budget, not apogee.
+# MATERIALS AS DECIDED
+# Aluminium injector. The face thermal analysis bounds rather than decides: q_face
+# is a 0.3-1.0x q_wall correlation and the verdict moves across that band. What it
+# does say is that aluminium never melts in any case run (worst 826 K on a bare
+# centre disc, 933 K melt), and that the "it yields" case rested on treating the
+# face as an unsupported disc across the full bore. Backed by manifold webs at
+# 40 mm the bending stress is 1.9 MPa, not 19.1, and the margin at 745 K is ~5x.
+# Put two or three thermocouples in the face on the first fire; that settles it
+# for the price of some wire.
#
-# TANK FILL IS 0.80
-# RocketPy's tank model will not run above ~0.80 on this geometry, and 20 % ullage is
-# defensible for LOX anyway. It changes the tank ENVELOPE, not the liquid:
-# 13.750 L of tank for 11.000 L of propellant.
+# FUEL plenum against the face, LOX routed to its orifices through it. Not a
+# thermal argument -- aluminium burns in oxygen and these are the hot surfaces.
#
-# READ THE 16 L RULE BEFORE COMMITTING
-# Held here: LIQUID propellant + COPV = 16.000 L exactly, which is what was asked for,
-# and it also clears a 16 L cap on propellant alone with 5 L to spare.
-# If the rule is measured on TANK volume, this vehicle is 13.750 + 5.000 = 18.750 L and
-# does NOT comply -- you would drop to ~8.8 L of liquid and lose roughly 1500 ft.
+# Nozzle is NOT bare aluminium: 5 mm of it reaches 1067 K at the exit even at
+# 0.6x Bartz, against a 933 K melt. 12 mm of ablative inside a 3 mm aluminium
+# shell instead, +0.96 lb.
#
# STILL REQUIRES HARDWARE
-# FLOW-TEST the injector. Cd 0.80 is an inlet-geometry correlation, not a measurement.
-# Cd 0.72-0.88 moves thrust -4.5 / +3.8 % and keeps dP/Pc inside 0.20-0.40 throughout,
-# so a surprise there moves T/W off 8:1 rather than making the design infeasible.
-# Spot-face every orifice normal to its own axis -- incidence is 49 / 47 deg.
-# At L/d 9.1 the fuel orifice no longer needs a counterbore; the 4 mm one is still
-# declared and still preferable if the shop is set up for it.
+# FLOW-TEST the injector. Cd 0.80 is a correlation. Cd 0.72-0.88 moves thrust
+# -4.5/+3.8 % and keeps dP/Pc inside 0.20-0.40 throughout, so a surprise there
+# moves T/W off 8:1 rather than making the design infeasible.
+# Spot-face every orifice normal to its own axis -- incidence is 47 / 45 deg.
propellant_preset: ethalox
fluids:
fuel:
@@ -153,15 +128,15 @@ injector:
type: impinging
geometry:
oxidizer:
- n_elements: 27
- d_jet: 0.0014520812103684332
- impingement_angle: 41.0
- spacing: 0.008325373933677317
- fuel:
- n_elements: 27
- d_jet: 0.0013278308621349137
+ n_elements: 24
+ d_jet: 0.0015401930216699016
impingement_angle: 43.0
- spacing: 0.010656598879149048
+ spacing: 0.009391018152114631
+ fuel:
+ n_elements: 24
+ d_jet: 0.001411659822714408
+ impingement_angle: 45.0
+ spacing: 0.012377887448060874
feed_system:
fuel:
line_size: 1/2_TUBE_035
@@ -376,7 +351,7 @@ combustion:
cea_parallel_workers: null
ox_name: LOX
fuel_name: Ethanol
- expansion_ratio: 5.607547652431548
+ expansion_ratio: 5.603148518333867
cache_file: output/cache/cea_cache_LOX_Ethanol_3D.npz
Pc_range:
- 1000000.0
@@ -431,21 +406,21 @@ combustion:
Ea_hydrogen: 40000.0
n_pre_hydrogen: 0.2
chamber_geometry:
- design_pressure: 2992068.773044018
- design_thrust: 6405.482749795813
- design_MR: 1.5103453830256488
+ design_pressure: 2992289.2184762955
+ design_thrust: 6405.481289424799
+ design_MR: 1.5045478910520989
chamber_diameter: 0.127
- Lstar: 1.0000009986701235
- exit_diameter: 0.10380762924022284
- expansion_ratio: 5.607547652431548
+ Lstar: 1.0000000097339177
+ exit_diameter: 0.10378210998994541
+ expansion_ratio: 5.603148518333867
nozzle_efficiency: 0.95
- A_throat: 0.001509299589646074
- A_exit: 0.00846346937073574
- volume: 0.0015093010969384815
- length: 0.12890304269490516
- length_cylindrical: 0.09695135516431024
- length_contraction: 0.03195168753059491
- Cf: 1.4184199807331177
+ A_throat: 0.0015097420082978874
+ A_exit: 0.008459308696860705
+ volume: 0.001509742022993592
+ length: 0.12893277440893333
+ length_cylindrical: 0.0969857103935357
+ length_contraction: 0.031947064015397625
+ Cf: 1.417899534027444
chamber: null
nozzle: null
solver:
@@ -492,19 +467,19 @@ optimizer:
refresh_sigma_scale: 0.2
num_tracks: 1
lox_tank:
- lox_h: 0.4569257044006979
+ lox_h: 0.4061561816895093
lox_radius: 0.06985
ox_tank_pos: 0.8
mass: 6.387385409941898
- initial_pressure_psi: 583.442324492869
- tank_volume_m3: 0.007003712072304712
+ initial_pressure_psi: 583.1563408165323
+ tank_volume_m3: 0.006225521842048633
fuel_tank:
- rp1_h: 0.3698324864164217
+ rp1_h: 0.3287399879257082
rp1_radius: 0.0762
fuel_tank_pos: 3.0
mass: 4.258256939961265
- initial_pressure_psi: 583.442324492869
- tank_volume_m3: 0.006746287927695286
+ initial_pressure_psi: 583.1563408165323
+ tank_volume_m3: 0.005996700380173588
press_tank:
press_h: 0.27410072797083124
press_radius: 0.0762
@@ -514,8 +489,8 @@ press_tank:
mass: null
free_volume_L: 5.0
rocket:
- airframe_mass: 44.489321590096836
- engine_mass: 13.626000000000001
+ airframe_mass: 43.717321590096844
+ engine_mass: 14.398
lox_tank_structure_mass: 4.082331330000001
fuel_tank_structure_mass: 4.082331330000001
engine_cm_offset: 0.15
@@ -555,13 +530,13 @@ environment:
elevation: 626.67
atmosphere_model: standard_atmosphere
thrust:
- burn_time: 3.854
+ burn_time: 3.858
design_requirements:
target_thrust: 6405.439125975119
target_chamber_pressure_psi: 430.0
target_apogee: 3890.7
optimal_of_ratio: 1.5
- target_burn_time: 3.854
+ target_burn_time: 3.858
max_lox_tank_pressure_psi: 600.0
max_fuel_tank_pressure_psi: 600.0
max_P_tank_O: null
@@ -581,7 +556,7 @@ design_requirements:
feed_stability_min: 0.15
lox_tank_capacity_kg: 6.387385409941898
fuel_tank_capacity_kg: 4.258256939961265
- propellant_tank_fill_factor: 0.8
+ propellant_tank_fill_factor: 0.9
copv_free_volume_L: 5.0
copv_free_volume_m3: null
injector_dp_ratio_O_min: 0.2
@@ -714,7 +689,7 @@ design_requirements:
h_gap_mm: null
n_orifices: null
d_orifice_mm: null
- n_doublets: null
+ n_doublets: 24
d_jet_O_mm: null
d_jet_F_mm: null
impingement_angle_O_deg: null
From ba50bedef08d610dd0a83316bef6cf6570980375 Mon Sep 17 00:00:00 2001
From: Carlsaurus
Date: Tue, 15 Sep 2026 22:46:00 -0700
Subject: [PATCH 15/24] Spec the COPV off the MSA G1 sheet instead of three
disagreeing guesses
45 ft3 / 4500 psi 30-minute standard. The sheet quotes 10.40 lb FULL and that is
full of breathing AIR, not oxygen, so the charge backs out cleanly: 3.37 lb of air
at 1 atm / 70 F leaves 7.03 lb (3.188 kg) dry, in 4.619 L of water volume derived
from charge mass over real air density at service pressure.
That replaces 2.969 kg at 4.5 L in eight EngineDesign configs (no source field at
all), 3.500 kg at 4.687 L in the feed-twin drawing (tagged estimated, reference
literally said "weigh it"), and the 3.300 kg at 5.000 L this design was carrying.
On kg/L those were -4.4 %, +8.2 % and -4.4 % against the real part.
The real bottle is SMALLER than the assumed 5 L, so 0.381 L comes back into the
propellant budget: liquid 11.000 -> 11.381 L, 10.646 -> 11.014 kg, and the COPV is
0.49 lb lighter installed. Constraints still exact: 180.0000 lb wet, 8.0000:1,
liquid + COPV = 16.0000 L.
Seed selection now on total impulse rather than Isp. The shipped seed delivers
O/F 1.4993 against a 1.5000 load and strands 3 g; two seeds with higher Isp
(237.15, 237.22) delivered 1.511-1.513, stranded 33-39 g of fuel, and produced
LESS total impulse. Isp is not the figure of merit when the load ratio is fixed.
Apogee 13208 ft at the modelled eta_c* 0.9499, which is 208 ft OVER the 13000 ft
ceiling. It only gets there if the engine makes 0.95 against a 0.87 published
comparable; the realistic 0.88-0.91 band lands 11900-12400 ft. De-loading 0.099 kg
(tanks to 89.2 % instead of 90 %) puts the modelled nominal on 13000 exactly --
a fill-level call on the pad, not a redesign.
---
EngineDesign/configs/ethalox_180lb_8to1.yaml | 214 ++++++++++---------
1 file changed, 113 insertions(+), 101 deletions(-)
diff --git a/EngineDesign/configs/ethalox_180lb_8to1.yaml b/EngineDesign/configs/ethalox_180lb_8to1.yaml
index e61c067e1..b89381be6 100644
--- a/EngineDesign/configs/ethalox_180lb_8to1.yaml
+++ b/EngineDesign/configs/ethalox_180lb_8to1.yaml
@@ -1,97 +1,109 @@
-# CalSTAR ethalox -- 180 lb vehicle, 8:1, O/F 1.50, 24 doublets. 2026-09-15.
+# CalSTAR ethalox -- 180 lb, 8:1, O/F 1.50, 24 doublets, MSA G1 45 scf COPV. 2026-09-15.
#
# Reproduce: python3 scripts/layer1_run.py --config configs/ethalox_180lb_8to1.yaml
# Audit: python3 scripts/design_audit.py configs/ethalox_180lb_8to1.yaml
#
# HARD CONSTRAINTS, ALL EXACT
# wet mass 81.6466 kg = 180.0000 lb
-# thrust 6405.5 N = 1440.0 lbf -> T/W = 8.0001 : 1
-# liquid 11.0000 L + COPV 5.0000 L = 16.0000 L
+# thrust 6405.5 N = 1440.0 lbf -> T/W = 8.0000 : 1
+# liquid 11.3810 L + COPV 4.6190 L = 16.0000 L
#
-# ENGINE
-# O/F 1.5045 Pc 433.99 psia Isp 236.99 s eta_c* 0.9500
-# 24 doublets on a 15.000 deg pitch (360/24 exactly -- no 13.333 deg nonsense)
-# theta 43 / 45 deg (included 88), d_jet 1.540 / 1.412 mm
-# bore 127.000 mm = 5.0000 in, throat 43.84 mm, exit 103.78 mm, eps 5.603, L* 1.0000 m
-# burn 3.858 s, impulse 24711 N.s, LOX-limited with 13 g of fuel residual
-# web 7.85 / 10.97 mm, centre clear O69.64, wall land 15.22 mm
+# THE COPV IS NOW A REAL PART, NOT AN ESTIMATE
+# MSA G1, 45 ft3 / 4500 psi, 30-minute standard carbon cylinder.
+# The specsheet quotes 10.40 lb FULL, and that is full of breathing AIR, not oxygen:
+# full 10.40 lb = 4.717 kg
+# air charge 3.37 lb = 1.529 kg (45 ft3 of free air at 1 atm / 70 F)
+# -> DRY 7.03 lb = 3.188 kg
+# water volume 4.619 L (charge mass / real air density at 4500 psi)
+# + GN2 at 4000 psi 2.89 lb = 1.312 kg -> installed 9.92 lb = 4.500 kg
+# This replaces three disagreeing internal numbers: 2.969 kg at 4.5 L in eight
+# EngineDesign configs (no source field at all), 3.500 kg at 4.687 L in the feed-twin
+# drawing (tagged "estimated", reference said "weigh it"), and the 3.300 kg at 5.000 L
+# this design carried. On kg/L those were -4.4 %, +8.2 % and -4.4 % against the real part.
+# The real bottle being SMALLER than the assumed 5 L frees 0.381 L into propellant.
+#
+# Dome-regulated: tanks need 0.587 kg, the bottle delivers 1.098 kg -> 1.87x, and it
+# ends the burn near 2010 psi against a 582 psi setpoint, so the regulator holds.
+# NOTE a 4500 psi cylinder filled to 4000 is not a lighter cylinder -- you save the
+# 0.29 lb of nitrogen and nothing else.
#
-# Why 24 and not 20 or 30 -- all three are clean divisors and all three audit CLEAN:
-# n=20 (18 deg) O/F 1.4497 Isp 235.82 O/F 3.4 % low
-# n=24 (15 deg) O/F 1.5045 Isp 236.99 on target <- this one
-# n=30 (12 deg) O/F 1.5138 Isp 237.18 more holes for +0.08 % Isp
+# ENGINE
+# O/F 1.4993 Pc 433.97 psia Isp 236.90 s eta_c* 0.9499
+# 24 doublets on a 15.000 deg pitch (360/24 exactly)
+# theta 43 / 46 deg (included 89), d_jet 1.536 / 1.411 mm
+# bore 127.000 mm = 5.0000 in, throat 43.85 mm, exit 103.76 mm, L* 1.0000 m
+# burn 3.994 s, impulse 25581 N.s
+# LOX-limited with 3 g of residual -- the delivered O/F 1.4993 lands on the 1.5000
+# load, so essentially nothing is left in either tank. Two other seeds had higher Isp
+# (237.15 and 237.22) and LESS total impulse, because they delivered O/F 1.511-1.513
+# against a 1.500 load and stranded 33-39 g of fuel. Isp is not the figure of merit
+# when the load ratio is fixed.
+# web 7.47 / 10.64 mm, centre clear O66.73, wall land 16.47 mm
#
# TANKS AT 10 % ULLAGE
# mass kg mass lb liquid L liquid gal TANK L TANK gal
-# LOX 6.3874 14.082 5.6030 1.4801 6.2255 1.6446
-# ethanol 4.2583 9.388 5.3970 1.4257 5.9967 1.5842
-# TOTAL 10.6456 23.470 11.0000 2.9059 12.2222 3.2288
-# Buy 1.65 gal of LOX tank and 1.59 gal of fuel tank. Liquid + COPV is still
-# 16.000 L exactly; tanks + COPV is 17.222 L if the rule is measured on tankage.
+# LOX 6.6086 14.570 5.7970 1.5314 6.4412 1.7016
+# ethanol 4.4057 9.713 5.5840 1.4751 6.2044 1.6390
+# TOTAL 11.0144 24.283 11.3810 3.0065 12.6456 3.3406
+# Buy 1.70 gal of LOX tank and 1.64 gal of fuel tank.
#
# ENGINE MASS 20.28 lb -- SLEEVE RUNS PAST THE GAS BOUNDARY
-# The injector plugs INTO the sleeve, so the sleeve is longer than the chamber and
-# the injector is sized to the sleeve BORE, not the engine OD:
-# gas boundary (face -> throat) 128.94 mm
+# The injector plugs INTO the sleeve, so the sleeve is longer than the chamber and the
+# injector is sized to the sleeve BORE, not the engine OD:
+# gas boundary (face -> throat) 128.97 mm
# injector insertion 40.00 mm 0.5 in face + O-ring land + pilot
-# steel sleeve 168.94 mm bore O152.4, OD O165.1
-# injector OD O152.4 (not O165.1 -- it has to fit inside)
+# steel sleeve 168.97 mm bore O152.4, OD O165.1
+# injector OD O152.4 it has to fit inside
#
-# mild steel sleeve 0.250 in 4.200 kg 9.26 lb
+# mild steel sleeve 0.250 in 4.201 kg 9.26 lb
# 2 flanges + bolts + seals 1.440 kg 3.18 lb
# aluminium injector 1.290 kg 2.84 lb
-# ablative liner 0.500 in 1.193 kg 2.63 lb
-# nozzle, 12 mm abl + 3 mm alu 0.731 kg 1.61 lb
+# ablative liner 0.500 in 1.194 kg 2.63 lb
+# nozzle, 12 mm abl + 3 mm alu 0.730 kg 1.61 lb
# igniter + instrument bosses 0.250 kg 0.55 lb
# graphite throat insert 0.093 kg 0.21 lb
# ENGINE 9.198 kg 20.28 lb
#
# MASS BUDGET
-# propellant 10.646 kg 23.47 lb
-# pressurant GN2 1.421 kg 3.13 lb 5 L at 4000 psi, 284.1 kg/m3
+# propellant 11.014 kg 24.28 lb
+# pressurant GN2 1.312 kg 2.89 lb 4.619 L at 4000 psi
# engine + plumbing 14.398 kg 31.74 lb engine 20.28 + valves/lines 11.46
# LOX tank 4.082 kg 9.00 lb given
# fuel tank 4.082 kg 9.00 lb given
-# COPV 3.300 kg 7.28 lb
-# airframe + recovery 43.718 kg 96.38 lb what is left
+# COPV 3.188 kg 7.03 lb MSA G1 specsheet, air backed out
+# airframe + recovery 43.571 kg 96.05 lb what is left
# == WET 81.647 kg 180.000 lb
#
-# APOGEE (RocketPy, 626.67 m pad)
-# eta_c* 0.9500 (modelled) 3788 m = 12427 ft
-# eta_c* 0.9120 3536 m = 11599 ft
-# eta_c* 0.8930 3411 m = 11192 ft
-# eta_c* 0.8170 2928 m = 9607 ft
-# All inside the 4000-13000 ft window. The modelled 0.95 is optimistic against a
-# 0.87 published comparable, so plan on ~11200 ft.
+# APOGEE -- READ THIS BEFORE COMMITTING
+# eta_c* 0.9499 (modelled) 4026 m = 13208 ft <- 208 ft OVER the 13000 ft ceiling
+# eta_c* 0.9309 3892 m = 12769 ft
+# eta_c* 0.9119 3760 m = 12334 ft
+# eta_c* 0.8929 3628 m = 11904 ft
+# eta_c* 0.8169 3118 m = 10231 ft
+# The modelled nominal busts the ceiling by 1.6 %. It only gets there if the engine
+# performs to a 0.95 eta_c*, which is optimistic against the 0.87 published comparable
+# -- the realistic 0.88-0.91 band lands 11900-12400 ft, comfortably inside.
+# If you want the MODELLED nominal under 13000 as well, de-load 0.099 kg (fill the
+# tanks to 89.2 % instead of 90 %) and it comes to 13000 exactly. 0.194 kg gets 12800.
+# Sensitivity is 0.908 ft per N.s, so this is a fill-level decision on the pad, not a
+# redesign.
#
-# NOTE ON THE FILL FACTOR: this file declares 0.90 (the 10 % ullage asked for).
-# RocketPy's tank model refuses to run above ~0.80 on this geometry, so the apogee
-# numbers above were produced on a larger tank ENVELOPE with identical propellant
-# mass. Verified insensitive -- 0.80 / 0.75 / 0.70 all return 12427 ft, because the
-# trajectory depends on mass, not on how much empty tank surrounds it.
+# FILL FACTOR: this file declares 0.90 (the 10 % ullage asked for). RocketPy's tank
+# model refuses above ~0.80, so the apogee numbers were produced on a larger tank
+# ENVELOPE at identical propellant mass -- verified insensitive, 0.80/0.75/0.70 all
+# returned the same apogee, because the trajectory depends on mass and not on how much
+# empty tank surrounds it.
#
# MATERIALS AS DECIDED
-# Aluminium injector. The face thermal analysis bounds rather than decides: q_face
-# is a 0.3-1.0x q_wall correlation and the verdict moves across that band. What it
-# does say is that aluminium never melts in any case run (worst 826 K on a bare
-# centre disc, 933 K melt), and that the "it yields" case rested on treating the
-# face as an unsupported disc across the full bore. Backed by manifold webs at
-# 40 mm the bending stress is 1.9 MPa, not 19.1, and the margin at 745 K is ~5x.
-# Put two or three thermocouples in the face on the first fire; that settles it
-# for the price of some wire.
-#
-# FUEL plenum against the face, LOX routed to its orifices through it. Not a
-# thermal argument -- aluminium burns in oxygen and these are the hot surfaces.
-#
-# Nozzle is NOT bare aluminium: 5 mm of it reaches 1067 K at the exit even at
-# 0.6x Bartz, against a 933 K melt. 12 mm of ablative inside a 3 mm aluminium
-# shell instead, +0.96 lb.
+# Aluminium injector. FUEL plenum against the face with LOX routed through it -- that
+# one is oxygen compatibility, not thermal. Nozzle is 12 mm of ablative inside a 3 mm
+# aluminium shell; bare 5 mm aluminium reaches 1067 K at the exit even at 0.6x Bartz
+# against a 933 K melt. Put two or three thermocouples in the face on the first fire.
#
# STILL REQUIRES HARDWARE
-# FLOW-TEST the injector. Cd 0.80 is a correlation. Cd 0.72-0.88 moves thrust
-# -4.5/+3.8 % and keeps dP/Pc inside 0.20-0.40 throughout, so a surprise there
-# moves T/W off 8:1 rather than making the design infeasible.
-# Spot-face every orifice normal to its own axis -- incidence is 47 / 45 deg.
+# FLOW-TEST the injector. Cd 0.80 is a correlation. Cd 0.72-0.88 moves thrust -4.5/+3.8 %
+# and keeps dP/Pc inside 0.20-0.40 throughout.
+# Spot-face every orifice normal to its own axis -- incidence is 47 / 44 deg.
propellant_preset: ethalox
fluids:
fuel:
@@ -129,14 +141,14 @@ injector:
geometry:
oxidizer:
n_elements: 24
- d_jet: 0.0015401930216699016
+ d_jet: 0.0015361478255479359
impingement_angle: 43.0
- spacing: 0.009391018152114631
+ spacing: 0.009010387728636004
fuel:
n_elements: 24
- d_jet: 0.001411659822714408
- impingement_angle: 45.0
- spacing: 0.012377887448060874
+ d_jet: 0.0014105372348721397
+ impingement_angle: 46.0
+ spacing: 0.012046846972657375
feed_system:
fuel:
line_size: 1/2_TUBE_035
@@ -351,7 +363,7 @@ combustion:
cea_parallel_workers: null
ox_name: LOX
fuel_name: Ethanol
- expansion_ratio: 5.603148518333867
+ expansion_ratio: 5.598521540485944
cache_file: output/cache/cea_cache_LOX_Ethanol_3D.npz
Pc_range:
- 1000000.0
@@ -406,21 +418,21 @@ combustion:
Ea_hydrogen: 40000.0
n_pre_hydrogen: 0.2
chamber_geometry:
- design_pressure: 2992289.2184762955
- design_thrust: 6405.481289424799
- design_MR: 1.5045478910520989
+ design_pressure: 2992123.1854115925
+ design_thrust: 6405.486128556911
+ design_MR: 1.4992994717934465
chamber_diameter: 0.127
- Lstar: 1.0000000097339177
- exit_diameter: 0.10378210998994541
- expansion_ratio: 5.603148518333867
+ Lstar: 1.0000002573548417
+ exit_diameter: 0.1037600811326615
+ expansion_ratio: 5.598521540485944
nozzle_efficiency: 0.95
- A_throat: 0.0015097420082978874
- A_exit: 0.008459308696860705
- volume: 0.001509742022993592
- length: 0.12893277440893333
- length_cylindrical: 0.0969857103935357
- length_contraction: 0.031947064015397625
- Cf: 1.417899534027444
+ A_throat: 0.0015103483768447478
+ A_exit: 0.008455717921403302
+ volume: 0.001510348765540215
+ length: 0.1289737160011753
+ length_cylindrical: 0.09703298776603714
+ length_contraction: 0.031940728235138174
+ Cf: 1.4174100000015877
chamber: null
nozzle: null
solver:
@@ -467,36 +479,36 @@ optimizer:
refresh_sigma_scale: 0.2
num_tracks: 1
lox_tank:
- lox_h: 0.4061561816895093
+ lox_h: 0.42022395489166414
lox_radius: 0.06985
ox_tank_pos: 0.8
- mass: 6.387385409941898
- initial_pressure_psi: 583.1563408165323
- tank_volume_m3: 0.006225521842048633
+ mass: 6.608621213686249
+ initial_pressure_psi: 584.2669657943025
+ tank_volume_m3: 0.006441151280395955
fuel_tank:
- rp1_h: 0.3287399879257082
+ rp1_h: 0.3401263456893168
rp1_radius: 0.0762
fuel_tank_pos: 3.0
- mass: 4.258256939961265
- initial_pressure_psi: 583.1563408165323
- tank_volume_m3: 0.005996700380173588
+ mass: 4.405747475790832
+ initial_pressure_psi: 584.2669657943025
+ tank_volume_m3: 0.006204404275159601
press_tank:
- press_h: 0.27410072797083124
- press_radius: 0.0762
+ press_h: 0.31334079903733364
+ press_radius: 0.0685
pres_tank_pos: 3.6
- dry_mass: 3.3
- initial_gas_mass: 1.421
+ dry_mass: 3.188
+ initial_gas_mass: 1.312
mass: null
- free_volume_L: 5.0
+ free_volume_L: 4.619
rocket:
- airframe_mass: 43.717321590096844
+ airframe_mass: 43.56959525052292
engine_mass: 14.398
lox_tank_structure_mass: 4.082331330000001
fuel_tank_structure_mass: 4.082331330000001
engine_cm_offset: 0.15
propulsion_dry_mass: 21.0
propulsion_cm_offset: 0.4
- copv_dry_mass: 3.3
+ copv_dry_mass: 3.188
inertia:
- 8.0
- 8.0
@@ -530,13 +542,13 @@ environment:
elevation: 626.67
atmosphere_model: standard_atmosphere
thrust:
- burn_time: 3.858
+ burn_time: 3.994
design_requirements:
target_thrust: 6405.439125975119
target_chamber_pressure_psi: 430.0
target_apogee: 3890.7
optimal_of_ratio: 1.5
- target_burn_time: 3.858
+ target_burn_time: 3.994
max_lox_tank_pressure_psi: 600.0
max_fuel_tank_pressure_psi: 600.0
max_P_tank_O: null
@@ -554,10 +566,10 @@ design_requirements:
chugging_margin_min: 0.2
acoustic_margin_min: 0.1
feed_stability_min: 0.15
- lox_tank_capacity_kg: 6.387385409941898
- fuel_tank_capacity_kg: 4.258256939961265
+ lox_tank_capacity_kg: 6.608621213686249
+ fuel_tank_capacity_kg: 4.405747475790832
propellant_tank_fill_factor: 0.9
- copv_free_volume_L: 5.0
+ copv_free_volume_L: 4.619
copv_free_volume_m3: null
injector_dp_ratio_O_min: 0.2
injector_dp_ratio_O_max: 0.4
From a4f3b164195ec95b0def3f15d65bd951b6810f4a Mon Sep 17 00:00:00 2001
From: Carlsaurus
Date: Tue, 15 Sep 2026 23:20:05 -0700
Subject: [PATCH 16/24] Forward Mode shows the whole engine, and fix the root
locus layout
/api/evaluate already returns 61 diagnostic keys and ResultsDisplay rendered a
handful of them, so Forward Mode showed a strictly smaller picture of the same
engine than Layer 1 did. Nothing was missing from the backend -- it was a display
gap. Two new sections, no duplicates of what was already there:
Injector & Spray -- effective SMD (same mass-flux blend Layer 1 uses,
MR/(1+MR)*D32_O + 1/(1+MR)*D32_F), per-stream SMD, impingement angle, momentum
ratio R, effective injector area / A_throat (same Cd*A_geom definition Layer 1
computes), x*, jet-to-jet relative velocity, Cd and d_jet per stream, Weber
numbers, bulk velocities, element counts, spray quality.
Injector Geometry -- pitch circles, ring offset, standoff and L/d, impingement
circle and the chamber area it feeds, webs, centre clear, wall land, face
incidence. The solver does not return these; they are derived with the SAME
deriveInjectorLayout the Chamber Geometry drawing uses rather than a second copy
of the arithmetic.
Root locus had four layout defects, all measured off the rendered SVG:
- the zeta ray labels were placed at pad.t - 3, i.e. ABOVE the plot frame; they
floated in the gap under the subtitle, detached from the rays they name
- "sigma = 0" sat on the top frame line and fought those labels for the same
few pixels; moved to the bottom of the boundary, which is empty
- the start and end eta labels overlapped the zeta=0.5 label by 7.5 x 7.1 px;
split vertically, start below its point and end above
- the rotated Hz axis title laid out from x=283.8 to x=346.2 against a 320-wide
viewBox and was clipped; frame widened to 348 with r=54
And the real problem underneath them: yMin was pinned to 0 while this locus lives
at omega 400-460 rad/s, so the data occupied the top 11 % of the frame and a
shallow arc read as a flat line. Omega now zooms to the data, dropping to 0 only
when the data goes near it, with the zeta rays clipped to the axes since they
start off-frame.
---
.../frontend/src/components/ForwardMode.tsx | 2 +-
.../src/components/ResultsDisplay.tsx | 123 +++++++++++++++++-
.../components/stability/ChugRootLocus.tsx | 78 +++++++----
3 files changed, 173 insertions(+), 30 deletions(-)
diff --git a/EngineDesign/frontend/src/components/ForwardMode.tsx b/EngineDesign/frontend/src/components/ForwardMode.tsx
index 1c2c6b8ab..c26aae155 100644
--- a/EngineDesign/frontend/src/components/ForwardMode.tsx
+++ b/EngineDesign/frontend/src/components/ForwardMode.tsx
@@ -203,7 +203,7 @@ export function ForwardMode({ config }: ForwardModeProps) {
{/* Results section */}
-
+
@@ -610,6 +614,121 @@ export function ResultsDisplay({ results, isLoading, targetExitPressure }: Resul
)}
+ {/* ---------------------------------------------------------------------------------
+ INJECTOR AND SPRAY.
+
+ /api/evaluate already returns 61 diagnostic keys; this view rendered a handful of
+ them, so Forward Mode showed a strictly smaller picture of the same engine than
+ Layer 1 did -- no SMD, no Weber numbers, no discharge coefficients, no momentum
+ ratio. Nothing here is recomputed: every number is read straight off the solver,
+ and the effective SMD uses the same mass-flux blend Layer 1 uses
+ (MR/(1+MR)*D32_O + 1/(1+MR)*D32_F, _impinging_smd_penalty_with_angle).
+ --------------------------------------------------------------------------------- */}
+ {(() => {
+ // Values here are mixed number/string/boolean, so keep it unknown and narrow at use.
+ const d = (results as unknown as Record).diagnostics as
+ Record | undefined;
+ if (!d) return null;
+ const num = (k: string): number | undefined => {
+ const v = d[k];
+ return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
+ };
+ const d32o = num('D32_O');
+ const d32f = num('D32_F');
+ const mr = num('MR') ?? results.MR;
+ const smdEff = (d32o !== undefined && d32f !== undefined && mr && mr > 0)
+ ? (mr / (1 + mr)) * d32o + (1 / (1 + mr)) * d32f
+ : (d32o ?? d32f);
+ const aEff = (num('A_eff_O') ?? 0) + (num('A_eff_F') ?? 0);
+ const areaRatio = results.A_throat ? aEff / results.A_throat : undefined;
+ const um = (m: number | undefined) => (m === undefined ? '—' : formatNumber(m * 1e6, 1));
+ const mm = (m: number | undefined) => (m === undefined ? '—' : formatNumber(m * 1e3, 3));
+ return (
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ })()}
+
+ {/* ---------------------------------------------------------------------------------
+ INJECTOR GEOMETRY. Pitch circles, standoff and web are pure geometry from the
+ design variables -- the solver does not return them, so they are derived here with
+ the SAME function the Chamber Geometry drawing uses, rather than a second copy.
+ --------------------------------------------------------------------------------- */}
+ {(() => {
+ const cfgInj = config?.injector as Record | undefined;
+ if (!cfgInj || String(cfgInj.type ?? '').toLowerCase() !== 'impinging') return null;
+ const geom = cfgInj.geometry as Record> | undefined;
+ const cg = config?.chamber_geometry as Record | undefined;
+ const ox = geom?.oxidizer;
+ const fu = geom?.fuel;
+ const bore = Number(cg?.chamber_diameter ?? 0);
+ if (!ox || !fu || !(bore > 0)) return null;
+ const req = (config?.design_requirements ?? {}) as Record;
+ const n = (v: unknown) => { const x = Number(v); return Number.isFinite(x) ? x : 0; };
+ const { g } = deriveInjectorLayout({
+ oxidizer: { n_elements: Number(ox.n_elements), d_jet: Number(ox.d_jet), impingement_angle: Number(ox.impingement_angle), spacing: Number(ox.spacing) },
+ fuel: { n_elements: Number(fu.n_elements), d_jet: Number(fu.d_jet), impingement_angle: Number(fu.impingement_angle), spacing: Number(fu.spacing) },
+ boreDiameter: bore,
+ centerClearDiameter: n(req.layer1_injector_center_clear_dia_m),
+ minWeb: n(req.layer1_injector_min_web_m),
+ wallClearance: n(req.layer1_injector_wall_clearance_m),
+ plateThickness: n(req.layer1_injector_plate_thickness_m) || 0.0127,
+ counterboreDiameter: n(req.layer1_injector_counterbore_dia_m),
+ });
+ const MM = 1000;
+ const f2 = (v: number) => formatNumber(v, 2);
+ return (
+ }
+ >
+
+
+ );
+ })()}
+
{/* Additional Thermodynamic Properties */}
pad.t + plotH - ((w - yMin) / (yMax - yMin)) * plotH;
const x0 = toX(0); // the stability boundary
+ const clipId = 'locus-plot-clip';
const branch = locus.map((p) => `${toX(p.real)},${toY(p.imag)}`).join(' ');
@@ -122,6 +133,11 @@ export function ChugRootLocus({ data }: Props) {
orient="auto" markerUnits="strokeWidth">
+ {/* Zooming omega means the zeta rays leave from off-frame; clip them to the axes
+ rather than letting them draw across the margins. */}
+
+
+
{/* half-plane shading: the single most important thing on the chart */}
@@ -156,22 +172,25 @@ export function ChugRootLocus({ data }: Props) {
))}
{/* constant-zeta rays from the origin */}
- {rays.map((r) => {
- const px = toX(r.x);
- const py = toY(r.y);
- if (!Number.isFinite(px) || !Number.isFinite(py)) return null;
- return (
-
-
+ {rays.map((r) => {
+ const px = toX(r.x);
+ const py = toY(r.y);
+ if (!Number.isFinite(px) || !Number.isFinite(py)) return null;
+ return (
+
-
- );
- })}
+ );
+ })}
+
+ {/* Ray labels go INSIDE the frame. They used to be placed at pad.t - 3, which is
+ above the plot entirely -- they floated in the gap under the subtitle, detached
+ from the rays they name. */}
{rays.map((r) => (
-
+ {/* At the TOP this sat on the frame line and fought the zeta labels for the same
+ few pixels. The boundary is a full-height line; label it where nothing else is. */}
+
σ = 0
@@ -203,14 +224,17 @@ export function ChugRootLocus({ data }: Props) {
<>
-
+ {/* Start label goes BELOW its point and end label above: the sweep starts at the
+ top-left where the zeta=0.5 ray label also lives, and the two overlapped by
+ 7.5 x 7.1 px. Splitting them vertically separates them for any locus shape. */}
+
η={locus[0].eta.toFixed(2)}
η={locus[locus.length - 1].eta.toFixed(2)}
@@ -238,12 +262,12 @@ export function ChugRootLocus({ data }: Props) {
← decaying · growing →
-
+
Im(s) = ω [rad/s]
-
+
f = ω/2π [Hz]
From 0761670241b2d6d0775f2e21990ba14140cb5170 Mon Sep 17 00:00:00 2001
From: Carlsaurus
Date: Sat, 19 Sep 2026 21:58:00 -0700
Subject: [PATCH 17/24] A tee rides its run, a line can be routed by hand, and
crossings hop
The geometry behind the new connection tool, with no UI in it.
A junction rides the run between the two things its two run lines
connect, found from the drawing every time rather than remembered: a
fixed fraction of the way along, put back there when either end moves,
kept in place when only the routing changed, and slid along the run when
dragged, because along the run is the only place it can be. Its lines
are re-pointed at the faces the run actually uses there, and each half
is handed the run's own corners on its side, so the two halves draw the
run. A part put into one half shortens the run; nothing has to be told.
A run can carry the corners a person put on it. `routeThrough` honours
them exactly and still leaves each port along the port's own axis; a
corner dragged behind its port steps across first rather than turning
round through the symbol. `dragSegment` moves one segment across its
axis and keeps both ends where they are, adding a stub and a corner at a
port; `jogSegment` puts a detour in.
Where a vertical line crosses a horizontal one it hops, in its own path,
so the drawing states the distinction the code has always kept: crossing
is not joining. Lines publish their corners to one small store and read
each other's.
A valve, a regulator or a disconnect goes *into* a line: the run breaks
around it, turned to face the way the run goes, and deleting it heals
the run, exactly as a mid-line tee does.
Ports anchor where React Flow anchors them -- the handle's outer edge in
its facing direction, not its centre; the centre was a three-pixel kink
in every run a tee was put back on. A tee is never seated on a port
nothing has measured: the fallback for one picks the face nearest the
tee, which made the seat depend on itself.
---
.../frontend/src/components/pid/attach.ts | 12 +
.../src/components/pid/edgeGeometry.ts | 63 +++
.../frontend/src/components/pid/hops.test.ts | 32 ++
.../frontend/src/components/pid/hops.ts | 75 ++++
.../src/components/pid/junctions.test.ts | 197 +++++++++
.../frontend/src/components/pid/junctions.ts | 384 ++++++++++++++++++
.../frontend/src/components/pid/lineHit.ts | 32 +-
.../frontend/src/components/pid/route.ts | 284 +++++++++++++
.../src/components/pid/routing.test.ts | 135 ++++++
.../src/components/pid/splitEdge.test.ts | 125 +++++-
.../frontend/src/components/pid/splitEdge.ts | 281 ++++++++++---
11 files changed, 1552 insertions(+), 68 deletions(-)
create mode 100644 pid-designer/frontend/src/components/pid/edgeGeometry.ts
create mode 100644 pid-designer/frontend/src/components/pid/hops.test.ts
create mode 100644 pid-designer/frontend/src/components/pid/hops.ts
create mode 100644 pid-designer/frontend/src/components/pid/junctions.test.ts
create mode 100644 pid-designer/frontend/src/components/pid/junctions.ts
create mode 100644 pid-designer/frontend/src/components/pid/routing.test.ts
diff --git a/pid-designer/frontend/src/components/pid/attach.ts b/pid-designer/frontend/src/components/pid/attach.ts
index b507923c7..3d4361e86 100644
--- a/pid-designer/frontend/src/components/pid/attach.ts
+++ b/pid-designer/frontend/src/components/pid/attach.ts
@@ -41,6 +41,18 @@ export const TAPPED = new Set(['PT', 'PG']);
export const isTapped = (type?: string) => !!type && TAPPED.has(type);
+/**
+ * Hardware that sits *in* a run: one port in, one port out, and the pipe is
+ * the same pipe on both sides. The same set `feedtwin.pid.document` calls
+ * INLINE_TYPES, and it has to stay the same set, because this is what
+ * decides that dropping one on a line breaks the line around it -- and that
+ * deleting one from a line heals the line -- and feed-twin has to agree that
+ * what it then reads is one run with a part in it.
+ */
+export const INLINE = new Set(['MAN', 'ROT', 'SOL', 'PR', 'RV', 'CV', 'QD']);
+
+export const isInline = (type?: string) => !!type && INLINE.has(type);
+
/**
* How big a node is, before ReactFlow has measured it.
*
diff --git a/pid-designer/frontend/src/components/pid/edgeGeometry.ts b/pid-designer/frontend/src/components/pid/edgeGeometry.ts
new file mode 100644
index 000000000..a1e8c6c42
--- /dev/null
+++ b/pid-designer/frontend/src/components/pid/edgeGeometry.ts
@@ -0,0 +1,63 @@
+import { useMemo, useSyncExternalStore } from 'react';
+import type { Pt } from './route';
+
+/**
+ * Where every drawn line goes, as the lines themselves report it.
+ *
+ * A line needs to know where the others are to hop over them (see hops.ts),
+ * and the others are React Flow edges, each deciding its own route inside its
+ * own render. Rather than re-deriving all of that in one place -- and keeping
+ * a second copy of the routing in step -- each edge publishes the corners it
+ * drew and reads everyone else's.
+ *
+ * Publishing happens after render and only when the corners changed, and the
+ * change notice is coalesced into a microtask. That is what stops it looping:
+ * an edge that re-renders because a neighbour moved publishes nothing new,
+ * so nothing wakes anyone again.
+ */
+
+const corners = new Map();
+const listeners = new Set<() => void>();
+let version = 0;
+let scheduled = false;
+
+function bump() {
+ version++;
+ if (scheduled) return;
+ scheduled = true;
+ queueMicrotask(() => {
+ scheduled = false;
+ for (const l of listeners) l();
+ });
+}
+
+const sameCorners = (a: Pt[], b: Pt[]) =>
+ a.length === b.length && a.every((p, i) => p.x === b[i].x && p.y === b[i].y);
+
+export function publishEdge(id: string, pts: Pt[]): void {
+ const prev = corners.get(id);
+ if (prev && sameCorners(prev, pts)) return;
+ corners.set(id, pts);
+ bump();
+}
+
+export function unpublishEdge(id: string): void {
+ if (corners.delete(id)) bump();
+}
+
+const subscribe = (l: () => void) => { listeners.add(l); return () => { listeners.delete(l); }; };
+const snapshot = () => version;
+
+/** Every other line's corners, refreshed whenever any line moves. */
+export function useOtherEdges(id: string): Pt[][] {
+ const v = useSyncExternalStore(subscribe, snapshot, snapshot);
+ return useMemo(() => {
+ void v;
+ const out: Pt[][] = [];
+ for (const [k, pts] of corners) if (k !== id) out.push(pts);
+ return out;
+ }, [v, id]);
+}
+
+/** For tests and the odd caller that wants the table rather than a hook. */
+export const drawnCorners = () => new Map(corners);
diff --git a/pid-designer/frontend/src/components/pid/hops.test.ts b/pid-designer/frontend/src/components/pid/hops.test.ts
new file mode 100644
index 000000000..26b06b865
--- /dev/null
+++ b/pid-designer/frontend/src/components/pid/hops.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest';
+import { crossingsOf, pathWithHops } from './hops';
+import type { Pt } from './route';
+
+const P = (x: number, y: number): Pt => ({ x, y });
+
+describe('where lines cross', () => {
+ const vertical = [P(100, 0), P(100, 200)];
+ const horizontal = [P(0, 100), P(200, 100)];
+
+ it('is found on the vertical line, and only there', () => {
+ expect(crossingsOf(vertical, [horizontal])).toEqual([P(100, 100)]);
+ expect(crossingsOf(horizontal, [vertical])).toEqual([]);
+ });
+
+ it('is not a line ending on another, or one running alongside', () => {
+ expect(crossingsOf([P(100, 0), P(100, 100)], [horizontal])).toEqual([]); // ends on it: a tee's business
+ expect(crossingsOf([P(100, 0), P(100, 200)], [[P(100, 50), P(100, 150)]])).toEqual([]);
+ expect(crossingsOf([P(100, 0), P(100, 200)], [[P(97, 100), P(200, 100)]])).toEqual([]); // too near its end for a hop
+ });
+
+ it('is drawn as a semicircle in the line, bulging the same way both ways up', () => {
+ const down = pathWithHops(vertical, [P(100, 100)]);
+ const up = pathWithHops([P(100, 200), P(100, 0)], [P(100, 100)]);
+ expect(down).toBe('M 100,0 L 100,95 A 5 5 0 0 1 100,105 L 100,200');
+ expect(up).toBe('M 100,200 L 100,105 A 5 5 0 0 0 100,95 L 100,0');
+ });
+
+ it('draws nothing special when there is nothing to hop', () => {
+ expect(pathWithHops(horizontal, [])).toBe('M 0,100 L 200,100');
+ });
+});
diff --git a/pid-designer/frontend/src/components/pid/hops.ts b/pid-designer/frontend/src/components/pid/hops.ts
new file mode 100644
index 000000000..0194618f9
--- /dev/null
+++ b/pid-designer/frontend/src/components/pid/hops.ts
@@ -0,0 +1,75 @@
+import { pointsToPath } from './route';
+import type { Pt } from './route';
+
+/**
+ * Where lines cross without meeting, the drawing says so.
+ *
+ * Nothing in this tool infers a connection from two paths overlapping, and it
+ * never will: a crossing on a drawing is usually one line passing over
+ * another, and guessing wrong either invents a leak path or hides a real one.
+ * But the code keeping that distinction was no use to a reader if the drawing
+ * did not show it -- a crossing and a tee looked the same on the sheet, one
+ * with a dot and one without, and the dot is ten pixels.
+ *
+ * So the vertical line hops the horizontal one, the way a schematic has drawn
+ * a crossing for a century. Vertical over horizontal is a convention, chosen
+ * because every crossing between orthogonal runs is one of each, so it needs
+ * no tie-break. The hop is drawn into the line's own path rather than masked
+ * over it, so it exports with the line and takes the line's colour.
+ */
+
+const EPS = 1e-6;
+
+/** Radius of the hop, and how far from a corner or an end one may sit. */
+export const HOP_R = 5;
+
+/**
+ * The points where the vertical segments of `mine` cross a horizontal
+ * segment of any of `others`, strictly inside both -- a line ending on
+ * another is a tee's business, not a hop's.
+ */
+export function crossingsOf(mine: Pt[], others: Pt[][], r = HOP_R): Pt[] {
+ const out: Pt[] = [];
+ for (let i = 0; i < mine.length - 1; i++) {
+ const p = mine[i], q = mine[i + 1];
+ if (Math.abs(p.x - q.x) > EPS) continue; // only vertical segments hop
+ const x = p.x;
+ const y1 = Math.min(p.y, q.y), y2 = Math.max(p.y, q.y);
+ for (const o of others) {
+ for (let j = 0; j < o.length - 1; j++) {
+ const a = o[j], b = o[j + 1];
+ if (Math.abs(a.y - b.y) > EPS) continue; // over horizontal ones
+ const y = a.y;
+ const x1 = Math.min(a.x, b.x), x2 = Math.max(a.x, b.x);
+ if (x > x1 + r && x < x2 - r && y > y1 + r && y < y2 - r) out.push({ x, y });
+ }
+ }
+ }
+ return out;
+}
+
+/**
+ * The path, with a semicircle over each crossing. The bulge is always to
+ * the right of the vertical, whichever way the line is travelling, so a run
+ * drawn upward and one drawn downward hop the same way.
+ */
+export function pathWithHops(pts: Pt[], hops: Pt[], r = HOP_R): string {
+ if (hops.length === 0 || pts.length < 2) return pointsToPath(pts);
+ let d = `M ${pts[0].x},${pts[0].y}`;
+ for (let i = 0; i < pts.length - 1; i++) {
+ const p = pts[i], q = pts[i + 1];
+ const vertical = Math.abs(p.x - q.x) < EPS;
+ const lo = Math.min(p.y, q.y), hi = Math.max(p.y, q.y);
+ const here = vertical
+ ? hops.filter(h => Math.abs(h.x - p.x) < EPS && h.y > lo + r - EPS && h.y < hi - r + EPS)
+ : [];
+ if (here.length === 0) { d += ` L ${q.x},${q.y}`; continue; }
+ const s = q.y > p.y ? 1 : -1;
+ here.sort((a, b) => (a.y - b.y) * s);
+ for (const h of here) {
+ d += ` L ${p.x},${h.y - r * s} A ${r} ${r} 0 0 ${s > 0 ? 1 : 0} ${p.x},${h.y + r * s}`;
+ }
+ d += ` L ${q.x},${q.y}`;
+ }
+ return d;
+}
diff --git a/pid-designer/frontend/src/components/pid/junctions.test.ts b/pid-designer/frontend/src/components/pid/junctions.test.ts
new file mode 100644
index 000000000..7b1920f79
--- /dev/null
+++ b/pid-designer/frontend/src/components/pid/junctions.test.ts
@@ -0,0 +1,197 @@
+import { describe, expect, it } from 'vitest';
+import { Position } from '@xyflow/react';
+import type { Edge, Node } from '@xyflow/react';
+import { branchFace, junctionEnd, reseatJunctions, runFaces, slideAlong } from './junctions';
+import type { Along, EndLookup } from './junctions';
+import { insertInline, splitEdgeAt } from './splitEdge';
+import type { Pt } from './route';
+
+const P = (x: number, y: number): Pt => ({ x, y });
+
+const part = (id: string, x: number, y: number): Node => ({
+ id, type: 'MAN', position: { x, y }, measured: { width: 60, height: 60 },
+ data: { componentType: 'MAN', label: id },
+});
+
+/** Ports where the symbols keep them: sides at the middle of each edge. */
+const endOf: EndLookup = (node, handle) => {
+ if ((node.data as { componentType?: string }).componentType === 'JUNCTION') {
+ return handle ? junctionEnd(node.position, handle as 'l' | 'r' | 't' | 'b') : null;
+ }
+ const { x, y } = node.position;
+ switch (handle) {
+ case 'l': return { x, y: y + 30, side: Position.Left };
+ case 'r': return { x: x + 60, y: y + 30, side: Position.Right };
+ case 't': return { x: x + 30, y, side: Position.Top };
+ case 'b': return { x: x + 30, y: y + 60, side: Position.Bottom };
+ default: return null;
+ }
+};
+
+function run() {
+ const nodes = [part('A', 0, 0), part('B', 400, 0)];
+ const edges: Edge[] = [{ id: 'A-B', source: 'A', sourceHandle: 'r', target: 'B', targetHandle: 'l', type: 'smoothstep', data: {} }];
+ return { nodes, edges };
+}
+const drawn = (nodes: Node[]) => ({ a: endOf(nodes[0], 'r')!, b: endOf(nodes[1], 'l')! });
+const alongOf = (n: Node) => (n.data as { along: Along }).along;
+const centre = (n: Node) => P(n.position.x + 5, n.position.y + 5);
+
+describe('which face a line takes', () => {
+ it('runs enter and leave by the faces along the run', () => {
+ expect(runFaces(P(1, 0))).toEqual({ in: 'l', out: 'r' });
+ expect(runFaces(P(0, -1))).toEqual({ in: 'b', out: 't' });
+ });
+
+ it('a branch comes in across the run, on its own side', () => {
+ expect(branchFace(P(1, 0), P(200, -100), P(200, 30))).toBe('t');
+ expect(branchFace(P(1, 0), P(200, 300), P(200, 30))).toBe('b');
+ expect(branchFace(P(0, 1), P(0, 100), P(200, 100))).toBe('l');
+ expect(branchFace(P(0, 1), P(400, 100), P(200, 100))).toBe('r');
+ });
+});
+
+describe('a tee that rides its run', () => {
+ it('remembers how far along it went in, and which faces the run uses', () => {
+ const { nodes, edges } = run();
+ const split = splitEdgeAt(nodes, edges, 'A-B', P(160, 30), undefined, drawn(nodes))!;
+ const j = split.nodes.find(n => n.id === split.junctionId)!;
+ expect(alongOf(j).t).toBeCloseTo((160 - 60) / (400 - 60));
+ expect(alongOf(j).in).toBe('l');
+ expect(alongOf(j).out).toBe('r');
+ expect(j.position).toEqual(P(155, 25));
+ });
+
+ it('is put back at the same fraction when an end of the run moves', () => {
+ const { nodes, edges } = run();
+ const split = splitEdgeAt(nodes, edges, 'A-B', P(230, 30), undefined, drawn(nodes))!;
+ const moved = split.nodes.map(n => (n.id === 'B' ? { ...n, position: { x: 740, y: 0 } } : n));
+ const re = reseatJunctions(moved, split.edges, endOf);
+ const j = re.nodes.find(n => n.id === split.junctionId)!;
+ expect(centre(j).x).toBeCloseTo(400); // halfway along 60..740
+ expect(centre(j).y).toBeCloseTo(30);
+ });
+
+ it('hands each half the run\'s corners on its side, so the halves draw the run', () => {
+ const { nodes, edges } = run();
+ const split = splitEdgeAt(nodes, edges, 'A-B', P(230, 30), undefined, drawn(nodes))!;
+ const moved = split.nodes.map(n => (n.id === 'B' ? { ...n, position: { x: 400, y: 600 } } : n));
+ const re = reseatJunctions(moved, split.edges, endOf);
+ const into = re.edges.find(e => e.target === split.junctionId)!.data as { waypoints?: Pt[]; viaRun?: boolean };
+ const outOf = re.edges.find(e => e.source === split.junctionId)!.data as { waypoints?: Pt[]; viaRun?: boolean };
+ // The run goes right along y=30, down x=230, right along y=630: the tee
+ // is on the vertical leg, so the upstream half owns the first corner and
+ // the downstream half the second.
+ expect(into.viaRun).toBe(true);
+ expect(into.waypoints).toEqual([P(230, 30)]);
+ expect(outOf.viaRun).toBe(true);
+ expect(outOf.waypoints).toEqual([P(230, 630)]);
+ });
+
+ it('turns its lines when the run turns under it', () => {
+ const { nodes, edges } = run();
+ const split = splitEdgeAt(nodes, edges, 'A-B', P(230, 30), undefined, drawn(nodes))!;
+ const moved = split.nodes.map(n => (n.id === 'B' ? { ...n, position: { x: 400, y: 600 } } : n));
+ const re = reseatJunctions(moved, split.edges, endOf);
+ const j = re.nodes.find(n => n.id === split.junctionId)!;
+ expect(alongOf(j).in).toBe('t');
+ expect(alongOf(j).out).toBe('b');
+ expect(re.edges.find(e => e.target === j.id)!.targetHandle).toBe('t');
+ expect(re.edges.find(e => e.source === j.id)!.sourceHandle).toBe('b');
+ });
+
+ it('follows a half somebody has routed by hand', () => {
+ const { nodes, edges } = run();
+ const split = splitEdgeAt(nodes, edges, 'A-B', P(230, 30), undefined, drawn(nodes))!;
+ const detour = [P(100, 30), P(100, -50), P(180, -50), P(180, 30)];
+ const byHand = split.edges.map(e => (e.source === 'A'
+ ? { ...e, data: { ...e.data, waypoints: detour, viaRun: undefined } }
+ : e));
+ const seated = reseatJunctions(split.nodes, split.edges, endOf);
+ const re = reseatJunctions(seated.nodes, byHand, endOf);
+ // The corners stay a person's, and the tee stays exactly where it was:
+ // the run's ends did not move, so re-routing one half is not a reason
+ // to move the tee -- it takes a fresh fraction of the longer run instead.
+ expect((re.edges.find(e => e.source === 'A')!.data as { waypoints?: Pt[] }).waypoints).toEqual(detour);
+ const j = re.nodes.find(n => n.id === split.junctionId)!;
+ expect(centre(j)).toEqual(P(230, 30));
+ expect(alongOf(j).t).toBeCloseTo((40 + 80 + 80 + 80 + 50) / 500);
+ // And now that it rides the detoured run, moving B carries it along it.
+ const movedB = re.nodes.map(n => (n.id === 'B' ? { ...n, position: { x: 900, y: 0 } } : n));
+ const again = reseatJunctions(movedB, re.edges, endOf);
+ expect(centre(again.nodes.find(n => n.id === split.junctionId)!).x).toBeGreaterThan(230);
+ });
+
+ it('hands back the same arrays when nothing needs doing', () => {
+ const { nodes, edges } = run();
+ const split = splitEdgeAt(nodes, edges, 'A-B', P(230, 30), undefined, drawn(nodes))!;
+ const once = reseatJunctions(split.nodes, split.edges, endOf);
+ const twice = reseatJunctions(once.nodes, once.edges, endOf);
+ expect(twice.nodes).toBe(once.nodes);
+ expect(twice.edges).toBe(once.edges);
+ });
+
+ it('keeps its place when a valve goes into one of its halves, and rides the shorter run', () => {
+ const { nodes, edges } = run();
+ const split = splitEdgeAt(nodes, edges, 'A-B', P(160, 30), undefined, drawn(nodes))!;
+ const down = split.edges.find(e => e.source === split.junctionId)!;
+ const ins = insertInline(split.nodes, split.edges, down.id, P(300, 30), part('V', 0, 0))!;
+ const re = reseatJunctions(ins.nodes, ins.edges, endOf);
+ const j = re.nodes.find(n => n.id === split.junctionId)!;
+ expect(centre(j)).toEqual(P(160, 30)); // did not move
+ expect(alongOf(j).to).toBe('V'); // rides A..V now
+ expect(alongOf(j).t).toBeCloseTo((160 - 60) / (270 - 60)); // V's inlet is at x=270
+ // And moving B no longer moves it: B is not on its run.
+ const movedB = re.nodes.map(n => (n.id === 'B' ? { ...n, position: { x: 900, y: 0 } } : n));
+ const again = reseatJunctions(movedB, re.edges, endOf);
+ expect(centre(again.nodes.find(n => n.id === split.junctionId)!)).toEqual(P(160, 30));
+ });
+
+ it('settles a chain of tees along one pipe', () => {
+ const { nodes, edges } = run();
+ const first = splitEdgeAt(nodes, edges, 'A-B', P(230, 30), undefined, drawn(nodes))!;
+ const half = first.edges.find(e => e.source === 'A')!;
+ const j1 = first.nodes.find(n => n.id === first.junctionId)!;
+ const second = splitEdgeAt(first.nodes, first.edges, half.id, P(120, 30), undefined, { a: endOf(nodes[0], 'r')!, b: endOf(j1, 'l')! })!;
+ // As the app does: a seat after the split, before anything moves. The
+ // outer tee's run is now inner..B, and it keeps its place on it.
+ const settled = reseatJunctions(second.nodes, second.edges, endOf);
+ expect(centre(settled.nodes.find(n => n.id === first.junctionId)!)).toEqual(P(230, 30));
+ const moved = settled.nodes.map(n => (n.id === 'B' ? { ...n, position: { x: 740, y: 0 } } : n));
+ const re = reseatJunctions(moved, settled.edges, endOf);
+ const outer = re.nodes.find(n => n.id === first.junctionId)!;
+ const inner = re.nodes.find(n => n.id === second.junctionId)!;
+ // The two ride each other's runs -- inner rides A..outer, outer rides
+ // inner..B -- so the answer is the fixed point where both fractions hold
+ // at once, not a number either could give alone. What must be true: both
+ // stretched right with the pipe, in order, on the line.
+ expect(centre(outer).x).toBeGreaterThan(300);
+ expect(centre(outer).x).toBeLessThan(740);
+ expect(centre(inner).x).toBeGreaterThan(120);
+ expect(centre(inner).x).toBeLessThan(centre(outer).x);
+ expect(centre(inner).y).toBeCloseTo(30);
+ expect(centre(outer).y).toBeCloseTo(30);
+ // Stable: a second pass changes nothing.
+ const again = reseatJunctions(re.nodes, re.edges, endOf);
+ expect(again.nodes).toBe(re.nodes);
+ });
+
+ it('stops riding when a run line is gone, and stays put', () => {
+ const { nodes, edges } = run();
+ const split = splitEdgeAt(nodes, edges, 'A-B', P(230, 30), undefined, drawn(nodes))!;
+ const j = split.nodes.find(n => n.id === split.junctionId)!;
+ const re = reseatJunctions(split.nodes, split.edges.filter(e => e.target !== 'B'), endOf);
+ const after = re.nodes.find(n => n.id === split.junctionId)!;
+ expect((after.data as { along?: Along }).along).toBeUndefined();
+ expect(after.position).toEqual(j.position);
+ });
+
+ it('slides along the run when dragged, rather than off it', () => {
+ const { nodes, edges } = run();
+ const split = splitEdgeAt(nodes, edges, 'A-B', P(230, 30), undefined, drawn(nodes))!;
+ const j = split.nodes.find(n => n.id === split.junctionId)!;
+ const slid = slideAlong(j, alongOf(j), { x: 295, y: 120 }, split.edges, new Map(split.nodes.map(n => [n.id, n])), endOf)!;
+ expect(slid.position).toEqual(P(295, 25)); // back on the pipe
+ expect(slid.along.t).toBeCloseTo((300 - 60) / 340);
+ });
+});
diff --git a/pid-designer/frontend/src/components/pid/junctions.ts b/pid-designer/frontend/src/components/pid/junctions.ts
new file mode 100644
index 000000000..f93310224
--- /dev/null
+++ b/pid-designer/frontend/src/components/pid/junctions.ts
@@ -0,0 +1,384 @@
+import type { Edge, Node, XYPosition } from '@xyflow/react';
+import { Position } from '@xyflow/react';
+import {
+ faceTowards, nearestOnPolyline, pathPoints, pointAt, routeOrthogonal, routeThrough,
+} from './route';
+import type { End, Pt } from './route';
+import type { PIDNodeData } from './types';
+import { nodeSize } from './attach';
+
+/**
+ * A tee is a point in a run, and it stays one.
+ *
+ * A junction used to be a node with a position like any other, and that was
+ * the whole of what made it feel broken: move the tank at one end of a run
+ * and its tees stayed behind on the canvas, so the two halves of the line
+ * re-routed around a point that was no longer on it and the run grew a kink
+ * it never had. A tee dragged by hand could be put anywhere at all, off the
+ * pipe included.
+ *
+ * So a junction now rides the run between the two things its run connects
+ * -- whatever is on the far end of each of its two run lines, found from the
+ * drawing every time rather than remembered -- and it sits a fixed fraction
+ * of the way along that run. Move either end and it is put back at the same
+ * fraction; drag it and it slides, because along the run is the only place
+ * it can be; put a valve into one of its halves and its run simply got
+ * shorter. Its lines are re-pointed at the right faces each time, chosen
+ * from where the run actually goes at that point rather than from which of
+ * four handles a drag happened to land on, and each half is handed the run's
+ * own corners on its side of the tee so the two halves draw the run.
+ */
+
+export type Face = 't' | 'b' | 'l' | 'r';
+
+export interface Along {
+ /** How far along the run, as a fraction of its length. */
+ t: number;
+ /** The faces the run enters and leaves by. Anything else on the tee is a branch. */
+ in: Face;
+ out: Face;
+ /**
+ * What was on each end of the run when the fraction was last taken. Kept
+ * only to notice a change: when a part goes into one half the run is a
+ * different run, and the tee keeps its place on the drawing rather than
+ * its fraction of a run that no longer exists.
+ */
+ from?: string;
+ to?: string;
+ /**
+ * Where the run's two ports were when the fraction was last taken. If they
+ * have not moved, the run has only been re-routed, and the tee keeps its
+ * place on the drawing; if one has, the run itself moved, and the tee
+ * keeps its fraction of it.
+ */
+ ends?: { a: Pt; b: Pt };
+}
+
+/** Half the junction dot: its position is its top-left, its centre is +5. */
+export const J_HALF = 5;
+
+export const junctionData = (n: Node) => n.data as unknown as PIDNodeData & { along?: Along };
+
+export const isJunction = (n: Node | undefined) =>
+ !!n && (n.data as unknown as PIDNodeData)?.componentType === 'JUNCTION';
+
+export const faceOfDir = (d: Pt): Face =>
+ Math.abs(d.x) >= Math.abs(d.y) ? (d.x >= 0 ? 'r' : 'l') : (d.y >= 0 ? 'b' : 't');
+
+/** The faces a run enters and leaves a tee by, from the way it runs there. */
+export const runFaces = (dir: Pt): { in: Face; out: Face } =>
+ ({ in: faceOfDir({ x: -dir.x, y: -dir.y }), out: faceOfDir(dir) });
+
+/**
+ * The face a branch enters a tee by: across the run, on the side the branch
+ * comes from. Never one of the run's own two faces -- a branch that landed on
+ * one of those drew itself along the run and on top of it.
+ */
+export function branchFace(runDir: Pt, from: Pt, at: Pt): Face {
+ if (Math.abs(runDir.x) >= Math.abs(runDir.y)) return from.y < at.y ? 't' : 'b';
+ return from.x < at.x ? 'l' : 'r';
+}
+
+/** The way the run goes into a tee, from the face it enters by. */
+export const runDirOf = (along: Along): Pt =>
+ ({ l: { x: 1, y: 0 }, r: { x: -1, y: 0 }, t: { x: 0, y: 1 }, b: { x: 0, y: -1 } } as Record)[along.in];
+
+const SIDE_OF: Record = {
+ t: Position.Top, b: Position.Bottom, l: Position.Left, r: Position.Right,
+};
+
+/**
+ * Where a line anchors on a junction's face, given the dot's top-left.
+ *
+ * Eight from the centre, not five. The dot is ten across with a two-pixel
+ * border, and each face's handle is ten across and centred on the edge of
+ * the box *inside* the border -- so the handle's outer edge, which is where
+ * React Flow anchors a line, sits three pixels beyond the dot. What the
+ * designer measures off the rendered handle says the same; this is only for
+ * a tee nothing has measured yet, and for tests.
+ */
+export const J_ANCHOR = J_HALF + 3;
+
+export function junctionEnd(position: XYPosition, face: Face): End {
+ const c = { x: position.x + J_HALF, y: position.y + J_HALF };
+ const off: Record = { t: { x: 0, y: -J_ANCHOR }, b: { x: 0, y: J_ANCHOR }, l: { x: -J_ANCHOR, y: 0 }, r: { x: J_ANCHOR, y: 0 } };
+ return { x: c.x + off[face].x, y: c.y + off[face].y, side: SIDE_OF[face] };
+}
+
+export const centreOfJunction = (n: Node): Pt => ({ x: n.position.x + J_HALF, y: n.position.y + J_HALF });
+
+/**
+ * Where a port is and which way it faces, given the node it is on.
+ *
+ * The designer answers this from React Flow's measured handle bounds; a test
+ * answers it from a table. Either way it is asked with the node's *current*
+ * position, which is what lets a run be re-drawn while its ends are moving.
+ */
+export type EndLookup = (node: Node, handleId: string | null | undefined) => End | null;
+
+/**
+ * A fallback for a port nothing has measured: the node's centre, facing the
+ * point it is being joined to. Right for symmetric symbols and near enough
+ * for the rest until the real bounds arrive a render later.
+ */
+export function endTowards(node: Node, towards: Pt): End {
+ const { w, h } = nodeSize(node);
+ const c = { x: node.position.x + w / 2, y: node.position.y + h / 2 };
+ const f = faceTowards(towards.x, towards.y, c.x, c.y) as Face;
+ const edge: Record = { t: { x: c.x, y: node.position.y }, b: { x: c.x, y: node.position.y + h }, l: { x: node.position.x, y: c.y }, r: { x: node.position.x + w, y: c.y } };
+ return { ...edge[f], side: SIDE_OF[f] };
+}
+
+/** How an edge touches a node, if it does. */
+export function endAt(e: Edge, nodeId: string): { end: 'source' | 'target'; handle: string | null | undefined } | null {
+ if (e.source === nodeId) return { end: 'source', handle: e.sourceHandle };
+ if (e.target === nodeId) return { end: 'target', handle: e.targetHandle };
+ return null;
+}
+
+/** Corners a person put on a line. Corners the run put there do not count. */
+const handCorners = (e: Edge): Pt[] => {
+ const d = (e.data ?? {}) as { waypoints?: Pt[]; viaRun?: boolean };
+ return d.viaRun ? [] : (d.waypoints ?? []);
+};
+
+/** The run a tee rides: its two run lines, what is on their far ends, and the run drawn without the tee. */
+export interface Run {
+ inEdge: Edge;
+ outEdge: Edge;
+ from: Node;
+ fromHandle: string | null | undefined;
+ to: Node;
+ toHandle: string | null | undefined;
+ a: End;
+ b: End;
+ path: Pt[];
+ /**
+ * One of the two ports could not be looked up, so `a` or `b` is a guess.
+ *
+ * A tee is never seated on a guess. The guess (`endTowards`) picks the
+ * face of the symbol nearest the tee, so it depends on where the tee is
+ * -- and a seat that moves the tee then changes the guess, which moves the
+ * seat, which is a loop that took the whole page down before React Flow
+ * had measured a single handle. The guess is fine for drawing a tee's
+ * lines; it is not fine for deciding where the tee goes.
+ */
+ unmeasured: boolean;
+}
+
+export function runOf(
+ junction: Node, along: Along, edges: Edge[], nodesById: Map, endOf: EndLookup,
+): Run | null {
+ let inEdge: Edge | undefined;
+ let outEdge: Edge | undefined;
+ for (const e of edges) {
+ const at = endAt(e, junction.id);
+ if (!at) continue;
+ if (at.handle === along.in && !inEdge) inEdge = e;
+ else if (at.handle === along.out && !outEdge) outEdge = e;
+ }
+ if (!inEdge || !outEdge) return null;
+ const farEnd = (e: Edge) => (e.source === junction.id
+ ? { id: e.target, handle: e.targetHandle }
+ : { id: e.source, handle: e.sourceHandle });
+ const f = farEnd(inEdge), t = farEnd(outEdge);
+ const from = nodesById.get(f.id), to = nodesById.get(t.id);
+ if (!from || !to) return null;
+ const c = centreOfJunction(junction);
+ const ma = endOf(from, f.handle);
+ const mb = endOf(to, t.handle);
+ const a = ma ?? endTowards(from, c);
+ const b = mb ?? endTowards(to, c);
+ const corners = [...handCorners(inEdge), ...handCorners(outEdge)];
+ const offset = ((inEdge.data ?? {}) as { offset?: number }).offset ?? ((outEdge.data ?? {}) as { offset?: number }).offset ?? 0;
+ const route = corners.length ? routeThrough(a, b, corners) : routeOrthogonal(a, b, offset);
+ return {
+ inEdge, outEdge, from, fromHandle: f.handle, to, toHandle: t.handle, a, b,
+ path: pathPoints(route.d), unmeasured: !ma || !mb,
+ };
+}
+
+function withHandle(e: Edge, end: 'source' | 'target', handle: Face): Edge {
+ if (end === 'source') return e.sourceHandle === handle ? e : { ...e, sourceHandle: handle };
+ return e.targetHandle === handle ? e : { ...e, targetHandle: handle };
+}
+
+const sameCorners = (a: Pt[] | undefined, b: Pt[]) =>
+ !!a && a.length === b.length && a.every((p, i) => Math.abs(p.x - b[i].x) < 1e-6 && Math.abs(p.y - b[i].y) < 1e-6);
+
+/**
+ * Give a half of a run the run's own corners on its side of the tee.
+ *
+ * A half that routes itself from the tee cannot always reproduce the run: a
+ * tee seated near a corner leaves its downstream half twenty pixels to make
+ * a Z in, and the router, quite rightly, sends it round the houses instead.
+ * So the halves are told the corners, and told again every time the tee is
+ * re-seated. `viaRun` marks corners that came from here, so a half somebody
+ * has since routed by hand -- which drops the mark -- is left alone, and is
+ * what the run is then drawn through.
+ */
+function withRunCorners(e: Edge, corners: Pt[]): Edge {
+ const data = (e.data ?? {}) as { waypoints?: Pt[]; viaRun?: boolean };
+ if (data.waypoints?.length && !data.viaRun) return e;
+ if (corners.length === 0) {
+ if (!data.waypoints && !data.viaRun) return e;
+ const rest = { ...data } as Record;
+ delete rest.waypoints;
+ delete rest.viaRun;
+ return { ...e, data: rest };
+ }
+ if (sameCorners(data.waypoints, corners) && data.viaRun) return e;
+ return { ...e, data: { ...data, waypoints: corners, viaRun: true, offset: 0 } };
+}
+
+/**
+ * Point every line on a tee at the right face for where the run goes there,
+ * and hand its two halves the run's corners.
+ *
+ * The run's two lines take the faces the run enters and leaves by; each
+ * branch takes the face across the run on its own side. Which lines are the
+ * run is read off the faces the tee recorded last time, so this is stable
+ * under repeated calls.
+ */
+export function repointJunction(
+ edges: Edge[], junction: Node, along: Along, dir: Pt, nodesById: Map,
+ corners?: { upstream: Pt[]; downstream: Pt[] },
+): { edges: Edge[]; along: Along } {
+ const faces = runFaces(dir);
+ const centre = centreOfJunction(junction);
+ let changed = false;
+ let runIn = false, runOut = false;
+ const out = edges.map(e => {
+ const at = endAt(e, junction.id);
+ if (!at) return e;
+ let face: Face;
+ if (at.handle === along.in && !runIn) {
+ runIn = true;
+ face = faces.in;
+ if (corners) { const c = withRunCorners(e, corners.upstream); if (c !== e) { changed = true; e = c; } }
+ } else if (at.handle === along.out && !runOut) {
+ runOut = true;
+ face = faces.out;
+ if (corners) { const c = withRunCorners(e, corners.downstream); if (c !== e) { changed = true; e = c; } }
+ } else {
+ const otherId = at.end === 'source' ? e.target : e.source;
+ const other = nodesById.get(otherId);
+ const { w, h } = other ? nodeSize(other) : { w: 0, h: 0 };
+ const otherC = other ? { x: other.position.x + w / 2, y: other.position.y + h / 2 } : centre;
+ face = branchFace(dir, otherC, centre);
+ }
+ const next = withHandle(e, at.end, face);
+ if (next !== e) changed = true;
+ return next;
+ });
+ return { edges: changed ? out : edges, along: { ...along, in: faces.in, out: faces.out } };
+}
+
+/**
+ * Where a tee dragged to `p` may actually go: the nearest point of its run,
+ * and the fraction that puts it there.
+ */
+export function slideAlong(
+ junction: Node, along: Along, p: XYPosition, edges: Edge[], nodesById: Map, endOf: EndLookup,
+): { position: XYPosition; along: Along; dir: Pt } | null {
+ const run = runOf(junction, along, edges, nodesById, endOf);
+ if (!run || run.unmeasured) return null;
+ const near = nearestOnPolyline(run.path, { x: p.x + J_HALF, y: p.y + J_HALF });
+ if (!near) return null;
+ return {
+ position: { x: near.point.x - J_HALF, y: near.point.y - J_HALF },
+ along: { ...along, t: near.t, from: run.from.id, to: run.to.id, ends: { a: { x: run.a.x, y: run.a.y }, b: { x: run.b.x, y: run.b.y } } },
+ dir: near.dir,
+ };
+}
+
+const EPS = 1e-3;
+
+/**
+ * Put every tee back on its run, and re-point its lines.
+ *
+ * A tee whose run has changed ends -- a part went into one of its halves --
+ * keeps its place on the drawing and takes a fresh fraction of the new run.
+ * A tee that has lost a run line stops riding anything and keeps the
+ * position it has. Tees along one pipe depend on each other, so this goes
+ * round until nothing moves.
+ *
+ * Returns the same arrays when nothing needed doing, so callers can compare
+ * by identity.
+ */
+export function reseatJunctions(
+ nodes: Node[], edges: Edge[], endOf: EndLookup,
+): { nodes: Node[]; edges: Edge[] } {
+ const byId = new Map(nodes.map(n => [n.id, n]));
+ const riding = nodes.filter(n => isJunction(n) && !!junctionData(n).along).map(n => n.id);
+ if (riding.length === 0) return { nodes, edges };
+
+ let outNodes = nodes;
+ let outEdges = edges;
+
+ const seat = (id: string): boolean => {
+ const node = byId.get(id)!;
+ const data = junctionData(node);
+ const along = data.along!;
+ const run = runOf(node, along, outEdges, byId, endOf);
+ if (!run) {
+ // Stop riding; keep the spot.
+ const { along: _dropped, ...rest } = data;
+ void _dropped;
+ const next = { ...node, data: rest as unknown as Record };
+ byId.set(id, next);
+ outNodes = outNodes.map(n => (n.id === id ? next : n));
+ return true;
+ }
+ if (run.unmeasured) return false; // not on a guess; see `Run.unmeasured`
+ // Keep the place, or keep the fraction. A different run -- a part went
+ // into one half -- or the same run re-routed with its ends where they
+ // were: the tee stays where it is on the drawing and takes a fresh
+ // fraction. An end moved: the run itself moved, and the tee goes with
+ // it at its fraction.
+ let t = along.t;
+ const ends = { a: { x: run.a.x, y: run.a.y }, b: { x: run.b.x, y: run.b.y } };
+ const centre = centreOfJunction(node);
+ const near = nearestOnPolyline(run.path, centre);
+ const endsMoved = !along.ends
+ || Math.hypot(along.ends.a.x - ends.a.x, along.ends.a.y - ends.a.y) > EPS
+ || Math.hypot(along.ends.b.x - ends.b.x, along.ends.b.y - ends.b.y) > EPS;
+ const runChanged = along.from !== run.from.id || along.to !== run.to.id;
+ // Off its fraction but still on the run: the run was re-routed under it.
+ const atFraction = pointAt(run.path, t);
+ const offFraction = !atFraction
+ || Math.hypot(atFraction.point.x - centre.x, atFraction.point.y - centre.y) > EPS;
+ if (near && (runChanged || (!endsMoved && offFraction && near.dist < 1))) t = near.t;
+ const at = pointAt(run.path, t);
+ if (!at) return false;
+ const position = { x: at.point.x - J_HALF, y: at.point.y - J_HALF };
+ const moved = Math.abs(position.x - node.position.x) > EPS || Math.abs(position.y - node.position.y) > EPS;
+ const seated = moved ? { ...node, position } : node;
+ const re = repointJunction(outEdges, seated, along, at.dir, byId, {
+ upstream: run.path.slice(1, at.segment + 1),
+ downstream: run.path.slice(at.segment + 1, -1),
+ });
+ const nextAlong: Along = { ...re.along, t, from: run.from.id, to: run.to.id, ends };
+ const alongChanged = nextAlong.in !== along.in || nextAlong.out !== along.out
+ || nextAlong.t !== along.t || nextAlong.from !== along.from || nextAlong.to !== along.to || endsMoved;
+ const next = (moved || alongChanged)
+ ? { ...seated, data: { ...(seated.data as Record), along: nextAlong } }
+ : seated;
+ if (next !== node) {
+ byId.set(id, next);
+ outNodes = outNodes.map(n => (n.id === id ? next : n));
+ }
+ outEdges = re.edges;
+ return moved;
+ };
+
+ // Tees along one pipe ride each other's runs, so one may move another;
+ // the fixed point is reached in a few passes and each is cheap.
+ for (let pass = 0; pass < 12; pass++) {
+ let anyMoved = false;
+ for (const id of riding) if (junctionData(byId.get(id)!).along && seat(id)) anyMoved = true;
+ if (!anyMoved) break;
+ }
+
+ return { nodes: outNodes, edges: outEdges };
+}
diff --git a/pid-designer/frontend/src/components/pid/lineHit.ts b/pid-designer/frontend/src/components/pid/lineHit.ts
index a673f254a..94a69a0ce 100644
--- a/pid-designer/frontend/src/components/pid/lineHit.ts
+++ b/pid-designer/frontend/src/components/pid/lineHit.ts
@@ -1,5 +1,6 @@
import type { XYPosition } from '@xyflow/react';
-import { nearestOnPath } from './BranchableEdge';
+import { nearestOnPolyline, pathPoints } from './route';
+import type { Pt } from './route';
/** One line as it is actually drawn: its id, and its path data. */
export interface DrawnLine {
@@ -24,6 +25,19 @@ export function drawnLines(): DrawnLine[] {
return out;
}
+/** Where on a line a point landed. */
+export interface LineHit {
+ id: string;
+ /** The point on the pipe itself, not the pointer. */
+ at: XYPosition;
+ /** Which way the pipe runs there. */
+ dir: Pt;
+ /** How far along the drawn run, as a fraction. */
+ t: number;
+ /** The drawn run's corners. */
+ points: Pt[];
+}
+
/**
* The line under a point, and where on it.
*
@@ -39,13 +53,19 @@ export function lineAt(
lines: DrawnLine[],
at: XYPosition,
tolerance = 14,
-): { id: string; at: XYPosition } | null {
- let best: { id: string; at: XYPosition } | null = null;
+ except?: string,
+): LineHit | null {
+ let best: LineHit | null = null;
let bestDist = tolerance;
for (const line of lines) {
- const q = nearestOnPath(line.d, at);
- const dist = Math.hypot(q.x - at.x, q.y - at.y);
- if (dist < bestDist) { bestDist = dist; best = { id: line.id, at: q }; }
+ if (line.id === except) continue;
+ const points = pathPoints(line.d);
+ const near = nearestOnPolyline(points, at);
+ if (!near) continue;
+ if (near.dist < bestDist) {
+ bestDist = near.dist;
+ best = { id: line.id, at: near.point, dir: near.dir, t: near.t, points };
+ }
}
return best;
}
diff --git a/pid-designer/frontend/src/components/pid/route.ts b/pid-designer/frontend/src/components/pid/route.ts
index 5835e2dfc..8e0079d05 100644
--- a/pid-designer/frontend/src/components/pid/route.ts
+++ b/pid-designer/frontend/src/components/pid/route.ts
@@ -258,3 +258,287 @@ export function turnPlacement(
along: turned === Position.Top || turned === Position.Bottom ? x : y,
};
}
+
+// ── Explicit routing ─────────────────────────────────────────────────────────
+//
+// Everything above decides a shape from two ends. Everything below is for a
+// run somebody has taken hold of: the corners it goes through are stored on
+// the line, any segment can be moved, and the two ends still leave their
+// ports the way the ports face.
+
+export type Pt = { x: number; y: number };
+
+const EPS = 1e-6;
+
+const samePt = (a: Pt, b: Pt) => Math.abs(a.x - b.x) < EPS && Math.abs(a.y - b.y) < EPS;
+
+/** The corners of an M/L path, in order. Arcs (hops) are skipped, which is
+ * right: a hop is drawn on a segment, not a corner in it. */
+export function pathPoints(d: string): Pt[] {
+ return [...d.matchAll(/[ML]\s*(-?[\d.]+),(-?[\d.]+)/g)]
+ .map(m => ({ x: Number(m[1]), y: Number(m[2]) }));
+}
+
+export function pointsToPath(pts: Pt[]): string {
+ return pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x},${p.y}`).join(' ');
+}
+
+/**
+ * Drop repeated points and the middle of any three in a line.
+ *
+ * "In a line" includes a spike -- out along an axis and back along it -- so a
+ * segment dragged until it lies on its neighbour merges into it rather than
+ * leaving a zero-width tooth.
+ */
+export function simplifyPoints(pts: Pt[]): Pt[] {
+ const out: Pt[] = [];
+ for (const p of pts) {
+ if (out.length && samePt(out[out.length - 1], p)) continue;
+ out.push({ x: p.x, y: p.y });
+ }
+ let i = 1;
+ while (i < out.length - 1) {
+ const a = out[i - 1], b = out[i], c = out[i + 1];
+ const sameX = Math.abs(a.x - b.x) < EPS && Math.abs(b.x - c.x) < EPS;
+ const sameY = Math.abs(a.y - b.y) < EPS && Math.abs(b.y - c.y) < EPS;
+ if (sameX || sameY) out.splice(i, 1);
+ else i++;
+ }
+ // A spike can leave two equal neighbours behind; one more pass clears it.
+ for (let k = out.length - 2; k >= 0; k--) if (samePt(out[k], out[k + 1])) out.splice(k + 1, 1);
+ return out;
+}
+
+/** A unit vector from a to b, or null when they coincide. */
+export function direction(a: Pt, b: Pt): Pt | null {
+ const len = Math.hypot(b.x - a.x, b.y - a.y);
+ return len < EPS ? null : { x: (b.x - a.x) / len, y: (b.y - a.y) / len };
+}
+
+/** The point a port's stub ends at: `STUB` out of the port, the way it faces. */
+export function stubOf(e: End): Pt {
+ return isHorizontal(e.side)
+ ? { x: e.x + facing(e.side) * STUB, y: e.y }
+ : { x: e.x, y: e.y + facing(e.side) * STUB };
+}
+
+/**
+ * The corners between two points that are not in line: one.
+ *
+ * It continues the axis the run arrived on when the next point is ahead on
+ * it, and turns across first when it is behind -- which is what keeps the
+ * run from doubling straight back along a port's stub and out through the
+ * symbol it just left. Horizontal-first when there is no arrival.
+ */
+function elbow(p: Pt, q: Pt, arrived: Pt | null, after?: Pt): Pt[] {
+ const dx = Math.abs(p.x - q.x) > EPS;
+ const dy = Math.abs(p.y - q.y) > EPS;
+ if (!dx && !dy) return [q];
+ const horizontalArrival = arrived ? Math.abs(arrived.x) > Math.abs(arrived.y) : true;
+ const ahead = arrived ? (q.x - p.x) * arrived.x + (q.y - p.y) * arrived.y : 1;
+ if (!dx || !dy) {
+ // In line with the arrival. Straight on if it is ahead; if it is
+ // *behind* -- a corner dragged past the port it leaves from -- step
+ // across by a stub first, or the run would turn round and go back
+ // through the symbol. Across toward wherever the run goes next, and
+ // the corner itself is not visited: it sits in the port's own column,
+ // inside the symbol, and what it meant was the level it was dragged to.
+ if (arrived && ahead < -EPS) {
+ let side = 1;
+ if (after) side = horizontalArrival ? (after.y >= p.y ? 1 : -1) : (after.x >= p.x ? 1 : -1);
+ const c1 = horizontalArrival ? { x: p.x, y: p.y + side * STUB } : { x: p.x + side * STUB, y: p.y };
+ const c2 = horizontalArrival ? { x: q.x, y: c1.y } : { x: c1.x, y: q.y };
+ return [c1, c2];
+ }
+ return [q];
+ }
+ const horizontalFirst = arrived ? (horizontalArrival ? ahead > 0 : ahead <= 0) : true;
+ return horizontalFirst ? [{ x: q.x, y: p.y }, q] : [{ x: p.x, y: q.y }, q];
+}
+
+/**
+ * A run through the corners somebody placed.
+ *
+ * The ends are still the router's business -- each leaves its port along the
+ * port's own axis for `STUB` before anything else is allowed to happen --
+ * and every pair of points after that is joined orthogonally, so a waypoint
+ * that is off both axes of its neighbour gets one corner put in on the way.
+ * The corners people set are honoured exactly; only the joins between them
+ * are computed.
+ */
+export function routeThrough(a: End, b: End, waypoints: Pt[]): Route {
+ const raw: Pt[] = [stubOf(a), ...waypoints, stubOf(b)];
+ const out: Pt[] = [{ x: a.x, y: a.y }];
+ let arrived: Pt | null = isHorizontal(a.side) ? { x: facing(a.side), y: 0 } : { x: 0, y: facing(a.side) };
+ for (let i = 0; i < raw.length; i++) {
+ const q = raw[i];
+ const p = out[out.length - 1];
+ for (const r of elbow(p, q, arrived, raw[i + 1])) {
+ const last = out[out.length - 1];
+ if (samePt(last, r)) continue;
+ arrived = direction(last, r);
+ out.push(r);
+ }
+ }
+ // The last piece is the port's own stub, drawn straight whatever came
+ // before it: a run that reached the stub from the far side -- a tee seated
+ // closer to a port than a stub is long -- is a spike the simplifier folds
+ // away, not a corner to step round.
+ out.push({ x: b.x, y: b.y });
+ return { d: pointsToPath(simplifyPoints(out)), grip: null };
+}
+
+export function polylineLength(pts: Pt[]): number {
+ let len = 0;
+ for (let i = 0; i < pts.length - 1; i++) len += Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y);
+ return len;
+}
+
+/** Where a point falls on a polyline: the nearest point, how far along, which
+ * segment, and which way that segment runs. */
+export interface OnPolyline {
+ point: Pt;
+ /** Fraction of the way along, by length, in [0, 1]. */
+ t: number;
+ /** Index of the segment the point is on. */
+ segment: number;
+ dir: Pt;
+ /** Distance from the query point. */
+ dist: number;
+}
+
+export function nearestOnPolyline(pts: Pt[], p: Pt): OnPolyline | null {
+ if (pts.length === 0) return null;
+ if (pts.length === 1) return { point: pts[0], t: 0, segment: 0, dir: { x: 1, y: 0 }, dist: Math.hypot(p.x - pts[0].x, p.y - pts[0].y) };
+ const total = polylineLength(pts) || 1;
+ let best: OnPolyline | null = null;
+ let before = 0;
+ for (let i = 0; i < pts.length - 1; i++) {
+ const a = pts[i], b = pts[i + 1];
+ const dx = b.x - a.x, dy = b.y - a.y;
+ const len2 = dx * dx + dy * dy;
+ const u = len2 < EPS ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2));
+ const q = { x: a.x + u * dx, y: a.y + u * dy };
+ const dist = Math.hypot(p.x - q.x, p.y - q.y);
+ const len = Math.sqrt(len2);
+ if (!best || dist < best.dist) {
+ best = { point: q, t: (before + u * len) / total, segment: i, dir: direction(a, b) ?? { x: 1, y: 0 }, dist };
+ }
+ before += len;
+ }
+ return best;
+}
+
+/** The point a fraction of the way along a polyline, and the segment it is on. */
+export function pointAt(pts: Pt[], t: number): { point: Pt; segment: number; dir: Pt } | null {
+ if (pts.length === 0) return null;
+ if (pts.length === 1) return { point: pts[0], segment: 0, dir: { x: 1, y: 0 } };
+ const target = Math.max(0, Math.min(1, t)) * polylineLength(pts);
+ let before = 0;
+ for (let i = 0; i < pts.length - 1; i++) {
+ const a = pts[i], b = pts[i + 1];
+ const len = Math.hypot(b.x - a.x, b.y - a.y);
+ const dir = direction(a, b);
+ if (!dir) continue;
+ if (before + len >= target - EPS || i === pts.length - 2) {
+ const u = len < EPS ? 0 : Math.max(0, Math.min(1, (target - before) / len));
+ return { point: { x: a.x + u * (b.x - a.x), y: a.y + u * (b.y - a.y) }, segment: i, dir };
+ }
+ before += len;
+ }
+ const last = pts[pts.length - 1];
+ return { point: last, segment: pts.length - 2, dir: direction(pts[pts.length - 2], last) ?? { x: 1, y: 0 } };
+}
+
+/**
+ * Move segment `i` across its own axis and keep both ends of the run where
+ * they are.
+ *
+ * Only the component of `delta` across the segment is used: a horizontal
+ * segment moves up or down, never along. A segment that touches one of the
+ * ends cannot simply shift, because the end is a port -- so the port's stub
+ * stays and a corner goes in after it. Drag the one segment of a straight
+ * run and you get a jog with a stub at each end, which is the only shape
+ * that move can have.
+ */
+export function dragSegment(pts: Pt[], i: number, delta: Pt): Pt[] {
+ if (i < 0 || i >= pts.length - 1) return pts;
+ const p = pts[i], q = pts[i + 1];
+ const dir = direction(p, q);
+ if (!dir) return pts;
+ const horizontal = Math.abs(dir.y) < EPS;
+ const shift = horizontal ? { x: 0, y: delta.y } : { x: delta.x, y: 0 };
+ if (Math.abs(shift.x) < EPS && Math.abs(shift.y) < EPS) return pts;
+
+ const first = i === 0;
+ const last = i === pts.length - 2;
+ const moved: Pt[] = [];
+ if (first) {
+ const s = { x: p.x + dir.x * STUB, y: p.y + dir.y * STUB };
+ moved.push(p, s, { x: s.x + shift.x, y: s.y + shift.y });
+ } else {
+ moved.push({ x: p.x + shift.x, y: p.y + shift.y });
+ }
+ if (last) {
+ const s = { x: q.x - dir.x * STUB, y: q.y - dir.y * STUB };
+ moved.push({ x: s.x + shift.x, y: s.y + shift.y }, s, q);
+ } else {
+ moved.push({ x: q.x + shift.x, y: q.y + shift.y });
+ }
+ const out = [...pts];
+ out.splice(i, 2, ...moved);
+ return simplifyPoints(out);
+}
+
+/**
+ * Put a detour into segment `i`: the `2·STUB` of it centred on `at` moves
+ * across by `delta` and the rest stays. For getting a run round something
+ * that is in its way.
+ */
+export function jogSegment(pts: Pt[], i: number, at: Pt, delta: Pt): Pt[] {
+ if (i < 0 || i >= pts.length - 1) return pts;
+ const p = pts[i], q = pts[i + 1];
+ const dir = direction(p, q);
+ if (!dir) return pts;
+ const horizontal = Math.abs(dir.y) < EPS;
+ const shift = horizontal ? { x: 0, y: delta.y } : { x: delta.x, y: 0 };
+ if (Math.abs(shift.x) < EPS && Math.abs(shift.y) < EPS) return pts;
+ const len = Math.hypot(q.x - p.x, q.y - p.y);
+ const along = Math.max(STUB, Math.min(Math.max(STUB, len - STUB),
+ (at.x - p.x) * dir.x + (at.y - p.y) * dir.y));
+ const g1 = { x: p.x + dir.x * (along - STUB), y: p.y + dir.y * (along - STUB) };
+ const g2 = { x: p.x + dir.x * (along + STUB), y: p.y + dir.y * (along + STUB) };
+ const out = [...pts];
+ out.splice(i + 1, 0, g1, { x: g1.x + shift.x, y: g1.y + shift.y }, { x: g2.x + shift.x, y: g2.y + shift.y }, g2);
+ return simplifyPoints(out);
+}
+
+/** The corners of a run between its two ends: what `routeThrough` stores. */
+export function waypointsOf(pts: Pt[]): Pt[] {
+ return pts.slice(1, -1);
+}
+
+/**
+ * The closest point on a path to `p`.
+ *
+ * Clamped to each segment and the best one kept, so a junction always sits on
+ * the pipe -- including exactly on a corner, which is where people aim when
+ * they want to branch at a bend.
+ */
+export function nearestOnPath(d: string, p: Pt): Pt {
+ return nearestOnPolyline(pathPoints(d), p)?.point ?? p;
+}
+
+/**
+ * The face of a junction that points at (fx, fy).
+ *
+ * A junction is a 10 px dot with four ports, and which one a line attaches to
+ * decides which way it leaves. Choosing by direction is what keeps the two
+ * halves of a split line collinear with the run they replaced.
+ */
+export function faceTowards(fx: number, fy: number, jx: number, jy: number): string {
+ const dx = fx - jx;
+ const dy = fy - jy;
+ if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? 'r' : 'l';
+ return dy >= 0 ? 'b' : 't';
+}
diff --git a/pid-designer/frontend/src/components/pid/routing.test.ts b/pid-designer/frontend/src/components/pid/routing.test.ts
new file mode 100644
index 000000000..d268fc21b
--- /dev/null
+++ b/pid-designer/frontend/src/components/pid/routing.test.ts
@@ -0,0 +1,135 @@
+import { describe, expect, it } from 'vitest';
+import { Position } from '@xyflow/react';
+import {
+ dragSegment, jogSegment, nearestOnPolyline, pathPoints, pointAt, routeThrough, simplifyPoints, stubOf,
+} from './route';
+import type { Pt } from './route';
+
+const L = Position.Left, R = Position.Right, B = Position.Bottom;
+void Position.Top;
+const P = (x: number, y: number): Pt => ({ x, y });
+
+function orthogonal(pts: Pt[]): boolean {
+ for (let i = 0; i < pts.length - 1; i++) {
+ if (Math.abs(pts[i].x - pts[i + 1].x) > 1e-6 && Math.abs(pts[i].y - pts[i + 1].y) > 1e-6) return false;
+ }
+ return true;
+}
+
+describe('a run routed by hand', () => {
+ it('leaves each port the way the port faces, then goes through the corners', () => {
+ const d = routeThrough({ x: 0, y: 0, side: R }, { x: 400, y: 200, side: L }, [P(100, 0), P(100, 200)]).d;
+ const pts = pathPoints(d);
+ expect(orthogonal(pts)).toBe(true);
+ expect(pts[0]).toEqual(P(0, 0));
+ expect(pts[1].y).toBe(0); // leaves rightward
+ expect(pts).toContainEqual(P(100, 0));
+ expect(pts).toContainEqual(P(100, 200));
+ expect(pts[pts.length - 1]).toEqual(P(400, 200));
+ expect(pts[pts.length - 2].y).toBe(200); // arrives leftward
+ });
+
+ it('puts one corner in for a waypoint off both axes of its neighbour', () => {
+ const d = routeThrough({ x: 0, y: 0, side: R }, { x: 300, y: 100, side: L }, [P(150, 60)]).d;
+ expect(orthogonal(pathPoints(d))).toBe(true);
+ });
+
+ it('is a straight line when the two ports face each other in line', () => {
+ const d = routeThrough({ x: 0, y: 0, side: R }, { x: 300, y: 0, side: L }, []).d;
+ expect(pathPoints(d)).toEqual([P(0, 0), P(300, 0)]);
+ });
+
+ it('still leaves a downward port downward when the corner is above it', () => {
+ const d = routeThrough({ x: 0, y: 0, side: B }, { x: 200, y: -100, side: L }, []).d;
+ const pts = pathPoints(d);
+ expect(pts[1]).toEqual(stubOf({ x: 0, y: 0, side: B }));
+ expect(pts[1].y).toBeGreaterThan(0);
+ expect(orthogonal(pts)).toBe(true);
+ });
+});
+
+describe('a corner dragged past the port it leaves from', () => {
+ it('steps across first rather than turning round through the symbol', () => {
+ // An upward port whose first corner has been dragged below it: the run
+ // must still leave upward, step sideways, and only then come down.
+ const d = routeThrough({ x: 0, y: 0, side: Position.Top }, { x: 200, y: 60, side: L }, [P(0, 60)]).d;
+ const pts = pathPoints(d);
+ expect(pts[0]).toEqual(P(0, 0));
+ expect(pts[1]).toEqual(P(0, -16));
+ expect(pts[2].y).toBe(-16); // across, not back down the same line
+ expect(pts[2].x).toBeGreaterThan(0); // toward where the run goes next
+ expect(orthogonal(pts)).toBe(true);
+ for (let i = 0; i < pts.length - 1; i++) {
+ // No segment passes down through the port's column below it.
+ if (pts[i].x === 0 && pts[i + 1].x === 0) expect(Math.max(pts[i].y, pts[i + 1].y)).toBeLessThanOrEqual(0);
+ }
+ });
+});
+
+describe('a port closer than a stub', () => {
+ it('is reached straight, not stepped round', () => {
+ // A tee seated 11 px from the port: the run overshoots the tee's stub
+ // and comes back, which is a spike to fold away, not a corner to turn.
+ const d = routeThrough({ x: 210, y: 323, side: B }, { x: 221, y: 339, side: L }, [P(210, 339)]).d;
+ expect(pathPoints(d)).toEqual([P(210, 323), P(210, 339), P(221, 339)]);
+ });
+});
+
+describe('simplifying corners', () => {
+ it('drops repeats, collinear middles and spikes', () => {
+ expect(simplifyPoints([P(0, 0), P(0, 0), P(50, 0), P(100, 0)])).toEqual([P(0, 0), P(100, 0)]);
+ expect(simplifyPoints([P(0, 0), P(50, 0), P(20, 0), P(20, 40)])).toEqual([P(0, 0), P(20, 0), P(20, 40)]);
+ });
+});
+
+describe('moving a segment', () => {
+ const run = [P(0, 0), P(100, 0), P(100, 80), P(200, 80)];
+
+ it('moves a middle segment across, and only across', () => {
+ const out = dragSegment(run, 1, P(30, 0)); // the vertical one, x 100 -> 130
+ expect(out).toEqual([P(0, 0), P(130, 0), P(130, 80), P(200, 80)]);
+ expect(dragSegment(run, 1, P(0, 30))).toEqual(run); // along it is nothing
+ });
+
+ it('keeps a port where it is by adding a stub and a corner', () => {
+ const out = dragSegment(run, 0, P(0, 20)); // the first, horizontal one, down 20
+ expect(out[0]).toEqual(P(0, 0));
+ expect(out[1]).toEqual(P(16, 0)); // the stub stays
+ expect(out).toContainEqual(P(16, 20));
+ expect(out).toContainEqual(P(100, 20));
+ expect(out[out.length - 1]).toEqual(P(200, 80));
+ expect(orthogonal(out)).toBe(true);
+ });
+
+ it('turns a straight run into a jog with a stub at each end', () => {
+ const out = dragSegment([P(0, 0), P(200, 0)], 0, P(0, 40));
+ expect(out).toEqual([P(0, 0), P(16, 0), P(16, 40), P(184, 40), P(184, 0), P(200, 0)]);
+ });
+
+ it('can put a detour into a segment without moving the rest of it', () => {
+ const out = jogSegment([P(0, 0), P(200, 0)], 0, P(100, 0), P(0, -30));
+ expect(out).toEqual([P(0, 0), P(84, 0), P(84, -30), P(116, -30), P(116, 0), P(200, 0)]);
+ });
+});
+
+describe('a point along a run', () => {
+ const run = [P(0, 0), P(100, 0), P(100, 100)];
+
+ it('is found from the nearest point, with the segment and its direction', () => {
+ const near = nearestOnPolyline(run, P(100, 40))!;
+ expect(near.point).toEqual(P(100, 40));
+ expect(near.segment).toBe(1);
+ expect(near.dir).toEqual(P(0, 1));
+ expect(near.t).toBeCloseTo(0.7);
+ });
+
+ it('round-trips through the fraction', () => {
+ const near = nearestOnPolyline(run, P(60, 12))!;
+ expect(near.point).toEqual(P(60, 0));
+ expect(pointAt(run, near.t)!.point).toEqual(P(60, 0));
+ });
+
+ it('lands exactly on a corner rather than beside it', () => {
+ expect(nearestOnPolyline(run, P(104, -3))!.point).toEqual(P(100, 0));
+ });
+});
diff --git a/pid-designer/frontend/src/components/pid/splitEdge.test.ts b/pid-designer/frontend/src/components/pid/splitEdge.test.ts
index a979a9a41..c578901ca 100644
--- a/pid-designer/frontend/src/components/pid/splitEdge.test.ts
+++ b/pid-designer/frontend/src/components/pid/splitEdge.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { Edge, Node } from '@xyflow/react';
-import { mergedLineData, rejoinAfterDelete, splitEdgeAt } from './splitEdge';
+import { insertInline, mergedLineData, rejoinAfterDelete, rotationAlong, splitEdgeAt } from './splitEdge';
import { faceTowards } from './BranchableEdge';
const node = (id: string, x: number, y: number): Node => ({
@@ -171,10 +171,27 @@ describe('what a delete puts back', () => {
)).toEqual([]);
});
- it('ignores a delete with no junction in it', () => {
+ it('ignores a delete with nothing mid-run in it', () => {
expect(rejoinAfterDelete(
+ [{ id: 'T', type: 'TANK', position: { x: 0, y: 0 }, data: { componentType: 'TANK' } }],
+ [wire('a', 'A', 'T'), wire('b', 'T', 'B')],
+ )).toEqual([]);
+ });
+
+ it('heals the run when a valve is taken out of it', () => {
+ // A valve is a part *in* a run, so taking it out leaves the run, exactly
+ // as putting it in left the run. A tank is a place, and is not rejoined.
+ const [back] = rejoinAfterDelete(
[{ id: 'V', type: 'MAN', position: { x: 0, y: 0 }, data: { componentType: 'MAN' } }],
[wire('a', 'A', 'V'), wire('b', 'V', 'B')],
+ );
+ expect(back).toMatchObject({ source: 'A', target: 'B' });
+ });
+
+ it('does not heal round a valve that vents: one line is not a run', () => {
+ expect(rejoinAfterDelete(
+ [{ id: 'V', type: 'MAN', position: { x: 0, y: 0 }, data: { componentType: 'MAN' } }],
+ [wire('a', 'A', 'V')],
)).toEqual([]);
});
@@ -189,3 +206,107 @@ describe('what a delete puts back', () => {
});
});
});
+
+describe('dropping a part into a line', () => {
+ const part = (id: string) => ({
+ id, type: 'MAN', position: { x: 999, y: 999 }, measured: { width: 60, height: 60 },
+ data: { componentType: 'MAN', label: id },
+ });
+
+ it('breaks the run around it, upstream to the inlet and outlet to downstream', () => {
+ const { nodes, edges } = line();
+ const ins = insertInline(nodes, edges, 'A-B', { x: 200, y: 30 }, part('V'))!;
+ const into = ins.edges.find(e => e.target === 'V')!;
+ const outOf = ins.edges.find(e => e.source === 'V')!;
+ expect(into).toMatchObject({ source: 'A', targetHandle: 'l' });
+ expect(outOf).toMatchObject({ target: 'B', sourceHandle: 'r' });
+ expect(ins.edges.find(e => e.id === 'A-B')).toBeUndefined();
+ });
+
+ it('is centred on the cut, so its ports sit on the pipe', () => {
+ const { nodes, edges } = line();
+ const ins = insertInline(nodes, edges, 'A-B', { x: 200, y: 30 }, part('V'))!;
+ expect(ins.nodes.find(n => n.id === 'V')!.position).toEqual({ x: 170, y: 0 });
+ });
+
+ it('is turned to face the way the run goes', () => {
+ expect(rotationAlong({ x: 1, y: 0 })).toBe(0);
+ expect(rotationAlong({ x: 0, y: 1 })).toBe(90);
+ expect(rotationAlong({ x: -1, y: 0 })).toBe(180);
+ expect(rotationAlong({ x: 0, y: -1 })).toBe(270);
+ const { nodes, edges } = line();
+ const ins = insertInline(nodes, edges, 'A-B', { x: 200, y: 30 }, part('V'))!;
+ expect((ins.nodes.find(n => n.id === 'V')!.data as { rotation: number }).rotation).toBe(0);
+ });
+
+ it('drops a corner its body has swallowed, so the outlet line does not double back', () => {
+ // A run that turns 20 px past where the valve goes in: the corner is
+ // inside the 60 px valve, and a line from the outlet that went back to
+ // it would run through the valve to get there.
+ const nodes = [node('A', 0, 0), node('B', 400, 200)];
+ const edges: Edge[] = [{ id: 'A-B', source: 'A', sourceHandle: 'r', target: 'B', targetHandle: 'l', type: 'smoothstep', data: {} }];
+ const drawn = [{ x: 60, y: 30 }, { x: 220, y: 30 }, { x: 220, y: 230 }, { x: 400, y: 230 }];
+ const ins = insertInline(nodes, edges, 'A-B', { x: 200, y: 30 }, part('V'), { points: drawn })!;
+ const outOf = ins.edges.find(e => e.source === 'V')!.data as { waypoints?: { x: number; y: number }[] };
+ expect(outOf.waypoints).toBeUndefined();
+ });
+
+ it('does not double the pipe either', () => {
+ const { nodes, edges } = line();
+ const ins = insertInline(nodes, edges, 'A-B', { x: 200, y: 30 }, part('V'))!;
+ const outOf = ins.edges.find(e => e.source === 'V')!;
+ expect((outOf.data as { params: Record }).params.length).toBeUndefined();
+ expect((outOf.data as { segments?: unknown }).segments).toBeUndefined();
+ });
+});
+
+describe('a run routed by hand keeps its corners when it is cut', () => {
+ it('gives each half the corners on its side of the cut', () => {
+ const nodes = [node('A', 0, 0), node('B', 400, 200)];
+ const edges: Edge[] = [{
+ id: 'A-B', source: 'A', sourceHandle: 'r', target: 'B', targetHandle: 'l', type: 'smoothstep',
+ data: { waypoints: [{ x: 100, y: 30 }, { x: 100, y: 230 }] },
+ }];
+ const drawn = [{ x: 60, y: 30 }, { x: 100, y: 30 }, { x: 100, y: 230 }, { x: 400, y: 230 }];
+ const split = splitEdgeAt(nodes, edges, 'A-B', { x: 100, y: 130 }, undefined, { points: drawn })!;
+ const up = split.edges.find(e => e.source === 'A')!.data as { waypoints?: unknown[] };
+ const down = split.edges.find(e => e.target === 'B')!.data as { waypoints?: unknown[] };
+ expect(up.waypoints).toEqual([{ x: 100, y: 30 }]);
+ expect(down.waypoints).toEqual([{ x: 100, y: 230 }]);
+ // Rejoined, the corners a person set come back together, in order.
+ const j = split.nodes.find(n => n.id === split.junctionId)!;
+ const [back] = rejoinAfterDelete([j], split.edges);
+ expect((back.data as { waypoints?: unknown }).waypoints).toEqual([{ x: 100, y: 30 }, { x: 100, y: 230 }]);
+ expect((j.data as { along: { t: number } }).along.t).toBeCloseTo((40 + 100) / (40 + 200 + 300));
+ });
+});
+
+describe('cutting a half that carries the run\'s own corners', () => {
+ it('treats it as routed by the run, not by hand', () => {
+ const nodes = [node('A', 0, 0), node('B', 400, 200)];
+ const edges: Edge[] = [{
+ id: 'A-B', source: 'A', sourceHandle: 'r', target: 'B', targetHandle: 'l', type: 'smoothstep',
+ data: { waypoints: [{ x: 230, y: 30 }, { x: 230, y: 230 }], viaRun: true },
+ }];
+ const drawn = [{ x: 60, y: 30 }, { x: 230, y: 30 }, { x: 230, y: 230 }, { x: 400, y: 230 }];
+ const split = splitEdgeAt(nodes, edges, 'A-B', { x: 230, y: 130 }, undefined, { points: drawn })!;
+ const up = split.edges.find(e => e.source === 'A')!.data as { viaRun?: boolean };
+ const down = split.edges.find(e => e.target === 'B')!.data as { viaRun?: boolean };
+ expect(up.viaRun).toBe(true);
+ expect(down.viaRun).toBe(true);
+ });
+});
+
+describe('healing a run drops the corners the part had made', () => {
+ it('keeps a person\'s corners elsewhere and forgets the ones inside the valve', () => {
+ const valve = { id: 'V', type: 'MAN', position: { x: 170, y: 0 }, measured: { width: 60, height: 60 }, data: { componentType: 'MAN' } };
+ const [back] = rejoinAfterDelete([valve], [
+ { id: 'a', source: 'A', sourceHandle: 'r', target: 'V', targetHandle: 'l', data: { waypoints: [{ x: 100, y: 30 }, { x: 100, y: -40 }, { x: 160, y: -40 }, { x: 160, y: 30 }] } },
+ { id: 'b', source: 'V', sourceHandle: 'r', target: 'B', targetHandle: 'l', data: { waypoints: [{ x: 225, y: 30 }, { x: 300, y: 30 }] } },
+ ]);
+ // (160, 30) is ten pixels short of the valve and stays; (225, 30) was
+ // inside it and goes.
+ expect((back.data as { waypoints: { x: number; y: number }[] }).waypoints)
+ .toEqual([{ x: 100, y: 30 }, { x: 100, y: -40 }, { x: 160, y: -40 }, { x: 160, y: 30 }, { x: 300, y: 30 }]);
+ });
+});
diff --git a/pid-designer/frontend/src/components/pid/splitEdge.ts b/pid-designer/frontend/src/components/pid/splitEdge.ts
index fea42da23..ff6d49e74 100644
--- a/pid-designer/frontend/src/components/pid/splitEdge.ts
+++ b/pid-designer/frontend/src/components/pid/splitEdge.ts
@@ -1,21 +1,24 @@
import type { Edge, Node, XYPosition } from '@xyflow/react';
import { nextJunctionId } from './ids';
-import { faceTowards } from './BranchableEdge';
import type { PIDNodeData } from './types';
import type { ParamValue } from './params';
import type { LineSegment } from './segments';
-import { centreOf } from './attach';
+import { INLINE, centreOf, nodeSize } from './attach';
+import { J_HALF, faceOfDir, runFaces } from './junctions';
+import type { Along } from './junctions';
+import { nearestOnPolyline, pathPoints, routeOrthogonal, routeThrough } from './route';
+import type { End, Pt } from './route';
/**
- * Put a junction into a line.
- *
- * The one operation behind two gestures: the Junction tool, and dropping a
- * connection onto a line. Both need identical results -- the same node, the
- * same two halves, the same faces -- so they share this rather than each
- * growing their own version that drifts.
+ * Put something into a line.
*
- * A junction is a point *in* a run, so the two halves are the same pipe told
- * apart at a tee -- but only the *intensive* facts copy to both. See
+ * Two operations, one shape. A tee goes in where a line is branched -- by the
+ * Junction tool, by a connection dropped on the line, or by a line dragged
+ * out of another line. A part goes in where somebody drops a valve, a
+ * regulator or a disconnect on a run: the run breaks around it, upstream
+ * half to the inlet and outlet to the downstream half, and the part is
+ * turned to face the way the run goes there. Both cut a run into two runs
+ * that are the same pipe, and only the *intensive* facts copy to both. See
* `EXTENSIVE_LINE_PARAMS`.
*/
@@ -38,6 +41,7 @@ export const EXTENSIVE_LINE_PARAMS = ['length', 'K_minor', 'end_fitting_K'] as c
function intensiveOnly(data: Record): Record {
const out = { ...data };
delete out.segments;
+ delete out.sketch;
const params = out.params as Record | undefined;
if (params) {
const kept = { ...params };
@@ -47,7 +51,21 @@ function intensiveOnly(data: Record): Record {
return out;
}
-const J_HALF = 5;
+/**
+ * What a caller knows about where the line is drawn, when it knows it.
+ *
+ * The edge itself knows its two handle positions and its corners exactly; a
+ * caller working from graph data alone only has the node boxes. Both pick
+ * the same faces for an ordinary run, so the centres are a fine default --
+ * but a line leaving the top of one part and entering the side of another is
+ * not ordinary, and a run somebody has routed by hand is not a straight line.
+ */
+export interface Drawn {
+ a?: End;
+ b?: End;
+ /** The run's corners as drawn, when the caller read them off the screen. */
+ points?: Pt[];
+}
export interface Split {
nodes: Node[];
@@ -55,73 +73,187 @@ export interface Split {
junctionId: string;
}
+/** Where the cut falls on the run, and what each half keeps of the routing. */
+interface Cut {
+ points: Pt[];
+ t: number;
+ dir: Pt;
+ at: Pt;
+ byHand: boolean;
+ upstream: Pt[];
+ downstream: Pt[];
+}
+
+function routeOf(edge: Edge, a: End, b: End): Pt[] {
+ const data = (edge.data ?? {}) as { waypoints?: Pt[]; offset?: number };
+ const route = data.waypoints?.length
+ ? routeThrough(a, b, data.waypoints)
+ : routeOrthogonal(a, b, data.offset ?? 0);
+ return pathPoints(route.d);
+}
+
+function cutAt(edge: Edge, from: Node, to: Node, at: XYPosition, drawn?: Drawn): Cut | null {
+ const points = drawn?.points
+ ?? (drawn?.a && drawn?.b ? routeOf(edge, drawn.a, drawn.b) : [centreOf(from), centreOf(to)]);
+ const near = nearestOnPolyline(points, at);
+ if (!near) return null;
+ const data = (edge.data ?? {}) as { waypoints?: Pt[]; offset?: number; viaRun?: boolean };
+ // Corners the run put on this line (`viaRun`) are not a person's routing:
+ // cut such a line and the halves route themselves, as the run does.
+ const byHand = (!!data.waypoints?.length && !data.viaRun) || !!data.offset;
+ return {
+ points, t: near.t, dir: near.dir, at: near.point, byHand,
+ // The corners on each side of the cut. A half that keeps corners is a run
+ // somebody routed, and stays routed; one with none routes itself.
+ upstream: points.slice(1, near.segment + 1),
+ downstream: points.slice(near.segment + 1, -1),
+ };
+}
+
+function halves(
+ edge: Edge, cut: Cut, midId: string, inHandle: string, outHandle: string, clear: number,
+): Edge[] {
+ const carried: Record = { ...(edge.data ?? {}), offset: 0 };
+ delete carried.waypoints;
+ delete carried.viaRun;
+ // Each half takes the run's corners on its side of the cut -- less any
+ // corner within the thing that went in, measured along the run: a valve
+ // sixty wide covers thirty of pipe each side of the cut, and a corner in
+ // that span is one a line from the outlet would have to double back
+ // through the valve to reach. Corners a person put on the run stay a
+ // person's; corners the router chose are marked as the run's, so a
+ // re-seat may replace them -- see `withRunCorners` in junctions.ts.
+ const outside = (pts: Pt[]) => pts.filter(c =>
+ Math.abs((c.x - cut.at.x) * cut.dir.x + (c.y - cut.at.y) * cut.dir.y) > clear);
+ const corners = (pts: Pt[]) => (pts.length ? { waypoints: pts, ...(cut.byHand ? {} : { viaRun: true }) } : {});
+ const up = { ...carried, ...corners(outside(cut.upstream)) };
+ const down = { ...intensiveOnly(carried), ...corners(outside(cut.downstream)) };
+ return [
+ {
+ ...edge,
+ id: `${edge.source}-${midId}`,
+ target: midId,
+ targetHandle: inHandle,
+ data: up,
+ },
+ {
+ ...edge,
+ id: `${midId}-${edge.target}`,
+ source: midId,
+ sourceHandle: outHandle,
+ target: edge.target,
+ targetHandle: edge.targetHandle,
+ data: down,
+ },
+ ];
+}
+
+/**
+ * Put a junction into a line.
+ *
+ * The one operation behind every gesture that branches a line. The tee is
+ * placed on the pipe at the nearest point to `at`, turned to the way the run
+ * goes there, and told which run it rides so it can stay on it -- see
+ * `junctions.ts`.
+ */
export function splitEdgeAt(
nodes: Node[],
edges: Edge[],
edgeId: string,
at: XYPosition,
page?: string,
- /**
- * Where the line actually starts and ends, when the caller knows.
- *
- * The edge itself knows its two handle positions exactly; a caller working
- * from graph data alone only has the node boxes. Both pick the same face for
- * an ordinary run, so the centres are a fine default -- but a line leaving
- * the top of one part and entering the side of another is not ordinary.
- */
- ends?: { from: XYPosition; to: XYPosition },
+ drawn?: Drawn,
): Split | null {
const edge = edges.find(e => e.id === edgeId);
if (!edge) return null;
const from = nodes.find(n => n.id === edge.source);
const to = nodes.find(n => n.id === edge.target);
if (!from || !to) return null;
+ const cut = cutAt(edge, from, to, at, drawn);
+ if (!cut) return null;
const junctionId = nextJunctionId();
- const a = ends?.from ?? centreOf(from);
- const b = ends?.to ?? centreOf(to);
+ const faces = runFaces(cut.dir);
+ const along: Along = { t: cut.t, in: faces.in, out: faces.out, from: edge.source, to: edge.target };
const junction: Node = {
id: junctionId,
type: 'JUNCTION',
- position: { x: at.x - J_HALF, y: at.y - J_HALF },
+ position: { x: cut.at.x - J_HALF, y: cut.at.y - J_HALF },
data: {
componentType: 'JUNCTION',
label: junctionId,
// A junction inherits the page of the line it lands on, so one never
// appears on a page its own pipe is not drawn on.
page: page ?? (from.data as unknown as PIDNodeData)?.page,
+ along,
} as unknown as Record,
};
- const carried = { ...(edge.data ?? {}), offset: 0 };
- const downstream = intensiveOnly(carried);
-
return {
nodes: [...nodes, junction],
- edges: [
- ...edges.filter(e => e.id !== edgeId),
- {
- ...edge,
- id: `${edge.source}-${junctionId}`,
- target: junctionId,
- targetHandle: faceTowards(a.x, a.y, at.x, at.y),
- data: carried,
- },
- {
- ...edge,
- id: `${junctionId}-${edge.target}`,
- source: junctionId,
- sourceHandle: faceTowards(b.x, b.y, at.x, at.y),
- target: edge.target,
- targetHandle: edge.targetHandle,
- data: downstream,
- },
- ],
+ edges: [...edges.filter(e => e.id !== edgeId), ...halves(edge, cut, junctionId, faces.in, faces.out, J_HALF)],
junctionId,
};
}
+/** The quarter turn that puts a part's inlet on the upstream side of a run. */
+export function rotationAlong(dir: Pt): number {
+ return { r: 0, b: 90, l: 180, t: 270 }[faceOfDir(dir)];
+}
+
+export interface Inserted {
+ nodes: Node[];
+ edges: Edge[];
+ partId: string;
+}
+
+/**
+ * Put a part into a line.
+ *
+ * Dropping a valve on a run used to leave the valve sitting on top of the
+ * line, unconnected, and the next four gestures were the ones that made it
+ * part of the run. Now the run breaks around it: the upstream half runs to
+ * the part's inlet, its outlet runs on to the downstream half, and the part
+ * is turned so its inlet faces the way the run arrives. Its ports sit on the
+ * pipe because the part is centred on the cut and its ports are on its
+ * centreline -- which is why this is for the two-port hardware in `INLINE`
+ * and nothing else.
+ *
+ * "Inlet" is the `l` port, and "upstream" is the line's drawn source end.
+ * A drawing does not state flow direction, so that is a convention, and the
+ * R key turns a part that is facing the wrong way.
+ */
+export function insertInline(
+ nodes: Node[],
+ edges: Edge[],
+ edgeId: string,
+ at: XYPosition,
+ part: Node,
+ drawn?: Drawn,
+): Inserted | null {
+ const edge = edges.find(e => e.id === edgeId);
+ if (!edge) return null;
+ const from = nodes.find(n => n.id === edge.source);
+ const to = nodes.find(n => n.id === edge.target);
+ if (!from || !to) return null;
+ const cut = cutAt(edge, from, to, at, drawn);
+ if (!cut) return null;
+
+ const { w, h } = nodeSize(part);
+ const placed: Node = {
+ ...part,
+ position: { x: cut.at.x - w / 2, y: cut.at.y - h / 2 },
+ data: { ...(part.data ?? {}), rotation: rotationAlong(cut.dir) },
+ };
+
+ return {
+ nodes: [...nodes, placed],
+ edges: [...edges.filter(e => e.id !== edgeId), ...halves(edge, cut, part.id, 'l', 'r', Math.max(w, h) / 2)],
+ partId: part.id,
+ };
+}
+
/**
* The two halves of a rejoined line, as one line again.
*
@@ -133,7 +265,8 @@ export function splitEdgeAt(
* *is* the run once the tee between them is gone. Extensive params add up when
* both halves state them in the same unit; when the units differ there is no
* conversion at this layer, so the result is left unstated rather than
- * pretending one of the two numbers was the whole run.
+ * pretending one of the two numbers was the whole run. Corners routed by
+ * hand on either half are kept, in order.
*/
export function mergedLineData(
a: Record | undefined,
@@ -147,6 +280,16 @@ export function mergedLineData(
];
if (segments.length) merged.segments = segments;
+ // Corners that were the run's own come back as nothing: the rejoined run
+ // routes itself and lands on the same corners. Corners a person put on
+ // either half are kept, in order.
+ const hand = (d: Record | undefined) =>
+ d?.viaRun ? [] : ((d?.waypoints as Pt[] | undefined) ?? []);
+ const waypoints = [...hand(a), ...hand(b)];
+ delete merged.viaRun;
+ if (waypoints.length) { merged.waypoints = waypoints; merged.offset = 0; }
+ else delete merged.waypoints;
+
const pa = a?.params as Record | undefined;
const pb = b?.params as Record | undefined;
if (pa || pb) {
@@ -165,33 +308,51 @@ export function mergedLineData(
return merged;
}
+/** Something that sits in a run: a tee, or a part in `INLINE`. */
+export function isMidRun(n: Node): boolean {
+ const t = (n.data as unknown as PIDNodeData)?.componentType;
+ return t === 'JUNCTION' || (!!t && INLINE.has(t));
+}
+
/**
- * The lines to put back after a delete took some junctions with them.
+ * The lines to put back after a delete took something out of a run.
*
* Works from what was deleted, not from what is left: React Flow has already
* removed both halves by the time a handler runs, so there is nothing in the
* current edge list to rejoin.
*
- * A run is rejoined only where every junction along it was genuinely mid-line,
- * one edge in and one out. A junction with a third leg on it is not a point in
- * a single run, so there is no run to give back and everything attached goes,
- * which is the ordinary behaviour. Deleting two adjacent junctions still leaves
- * one line, because the walk follows the chain to whatever survives.
+ * A run is rejoined only where everything deleted along it was genuinely
+ * mid-line -- one line in and one out. A tee with a third leg on it is not a
+ * point in a single run, so there is no run to give back and everything
+ * attached goes, which is the ordinary behaviour. A valve venting to
+ * atmosphere has one line, not two, so taking it out takes its line out too.
+ * Deleting two adjacent mid-run things still leaves one line, because the
+ * walk follows the chain to whatever survives.
*/
export function rejoinAfterDelete(deletedNodes: Node[], deletedEdges: Edge[]): Edge[] {
const gone = new Set(deletedNodes.map(n => n.id));
- const junctions = new Set(deletedNodes
- .filter(n => (n.data as unknown as PIDNodeData)?.componentType === 'JUNCTION')
- .map(n => n.id));
- if (junctions.size === 0) return [];
+ const mid = new Set(deletedNodes.filter(isMidRun).map(n => n.id));
+ if (mid.size === 0) return [];
+ const boxes = new Map(deletedNodes.map(n => { const { w, h } = nodeSize(n); return [n.id, { x: n.position.x, y: n.position.y, w, h }]; }));
+ // A corner inside the thing that was taken out was the run turning to
+ // reach its port; without the part there is nothing to turn for.
+ const clearOf = (data: Record | undefined, id: string) => {
+ const box = boxes.get(id);
+ const pts = data?.waypoints as Pt[] | undefined;
+ if (!box || !pts?.length) return data;
+ const kept = pts.filter(c => c.x < box.x || c.x > box.x + box.w || c.y < box.y || c.y > box.y + box.h);
+ const out = { ...data };
+ if (kept.length) out.waypoints = kept; else delete out.waypoints;
+ return out;
+ };
const rejoined: Edge[] = [];
for (const first of deletedEdges) {
// Start only from a line whose upstream end survives.
- if (gone.has(first.source) || !junctions.has(first.target)) continue;
+ if (gone.has(first.source) || !mid.has(first.target)) continue;
let edge = first;
- let data = first.data;
+ let data = clearOf(first.data, first.target);
let ok = true;
for (;;) {
const j = edge.target;
@@ -199,8 +360,8 @@ export function rejoinAfterDelete(deletedNodes: Node[], deletedEdges: Edge[]): E
const outOf = deletedEdges.filter(e => e.source === j);
if (inTo.length !== 1 || outOf.length !== 1) { ok = false; break; }
edge = outOf[0];
- data = mergedLineData(data, edge.data);
- if (!junctions.has(edge.target)) break;
+ data = mergedLineData(data, clearOf(edge.data, j));
+ if (!mid.has(edge.target)) break;
}
if (!ok || gone.has(edge.target)) continue;
From a1ad5b35b7e6dbaba9dba0c511a56dcad2a10b0e Mon Sep 17 00:00:00 2001
From: Carlsaurus
Date: Sat, 19 Sep 2026 21:58:00 -0700
Subject: [PATCH 18/24] The dot moves, the ring connects, and a line can be
pulled out of a line
The interaction layer over the new geometry.
A tee's four ports covered the whole of the visible dot, so pressing on
it drew a line and moving it meant finding an invisible halo. The ports
still anchor lines but take no pointer: the dot drags (and slides along
its run), and a dashed ring on hover pulls a new line out. A tee with one
line on it is an open end, drawn hollow.
Press anywhere on a line and pull, and you are drawing a branch: the dot
riding the pointer is where the tee goes, and letting go on a port, a
symbol, another line, a tee or empty canvas is what it joins. A press
that does not move is a click. Alt-click, or the Junction tool, puts a
bare tee in, and the tool is one-shot. A port drag let go on nothing
leaves an open end.
Every segment has a grip; drag it and the segment moves across, the ends
stay on their ports, Alt-drag puts a detour in, and double-click routes
the line automatically again.
Drop a valve on a line and it goes into the line. Delete it and the line
heals.
Tees are re-seated after every change, once React Flow has measured the
nodes, and never more than thirty times a second: a feedback that will
not settle stops and says so rather than taking the page down.
---
.../src/components/pid/BranchDrag.tsx | 135 +++++++
.../src/components/pid/BranchableEdge.tsx | 326 ++++++++---------
.../src/components/pid/PIDDesigner.tsx | 338 +++++++++++++++---
.../src/components/pid/ToolContext.tsx | 15 +-
.../frontend/src/components/pid/checks.ts | 2 +-
.../src/components/pid/nodes/JunctionNode.tsx | 96 +++--
pid-designer/frontend/src/index.css | 13 +
pid-designer/frontend/src/lib/gating.test.ts | 18 +-
8 files changed, 694 insertions(+), 249 deletions(-)
create mode 100644 pid-designer/frontend/src/components/pid/BranchDrag.tsx
diff --git a/pid-designer/frontend/src/components/pid/BranchDrag.tsx b/pid-designer/frontend/src/components/pid/BranchDrag.tsx
new file mode 100644
index 000000000..2589b2f13
--- /dev/null
+++ b/pid-designer/frontend/src/components/pid/BranchDrag.tsx
@@ -0,0 +1,135 @@
+import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
+import type { ReactNode } from 'react';
+import { ViewportPortal, useReactFlow } from '@xyflow/react';
+import type { Pt } from './route';
+
+/**
+ * Drawing a line out of a line.
+ *
+ * The order of operations used to be backwards: to tee off a run and go
+ * somewhere, you first placed the thing you were going to, then dragged from
+ * it back to the run. You could not start a line *from* a line, and you
+ * could not start one from a tee.
+ *
+ * This is the rubber band for that. Press on a line (or on a tee's ring) and
+ * pull: a dashed preview follows the pointer, and letting go on a port, a
+ * component, another line or empty canvas is what the drop handler in the
+ * designer turns into a tee and a line. React Flow's own connection drag is
+ * kept for ports -- it already lands on lines -- so this only exists for the
+ * two places a drag could not start from before.
+ *
+ * Nothing happens until the pointer has moved a few pixels: a press that
+ * does not move is a click, and a click on a line still selects it.
+ */
+
+export type BranchSource =
+ | { kind: 'line'; edgeId: string; at: Pt; dir: Pt; points: Pt[] }
+ | { kind: 'node'; nodeId: string; at: Pt };
+
+export interface BranchState {
+ source: BranchSource;
+ /** Where the pointer is, in flow space. */
+ cursor: Pt;
+ /** Whether it has moved far enough to count as a drag rather than a click. */
+ moved: boolean;
+}
+
+interface BranchApi {
+ begin: (source: BranchSource, e: { clientX: number; clientY: number }) => void;
+ active: boolean;
+}
+
+const ApiContext = createContext({ begin: () => {}, active: false });
+const StateContext = createContext(null);
+
+export const useBranchDrag = () => useContext(ApiContext);
+
+/** How far the pointer has to travel before a press becomes a pull. */
+const THRESHOLD = 6;
+
+export function BranchDragProvider({ readOnly, onDrop, children }: {
+ readOnly: boolean;
+ /** The pull ended: here, in flow space, and here on screen. */
+ onDrop: (source: BranchSource, at: Pt, client: { x: number; y: number }) => void;
+ children: ReactNode;
+}) {
+ const { screenToFlowPosition } = useReactFlow();
+ const [state, setState] = useState(null);
+ const startRef = useRef<{ x: number; y: number } | null>(null);
+ const stateRef = useRef(null);
+ stateRef.current = state;
+ const onDropRef = useRef(onDrop);
+ onDropRef.current = onDrop;
+
+ const begin = useCallback((source: BranchSource, e: { clientX: number; clientY: number }) => {
+ if (readOnly) return;
+ startRef.current = { x: e.clientX, y: e.clientY };
+ setState({ source, cursor: source.at, moved: false });
+ }, [readOnly]);
+
+ useEffect(() => {
+ if (!state) return;
+ const onMove = (e: PointerEvent) => {
+ const start = startRef.current;
+ if (!start) return;
+ const moved = stateRef.current?.moved || Math.hypot(e.clientX - start.x, e.clientY - start.y) > THRESHOLD;
+ const cursor = screenToFlowPosition({ x: e.clientX, y: e.clientY }, { snapToGrid: false });
+ setState(s => (s ? { ...s, cursor, moved } : s));
+ };
+ const onUp = (e: PointerEvent) => {
+ const s = stateRef.current;
+ startRef.current = null;
+ setState(null);
+ if (!s || !s.moved) return;
+ const at = screenToFlowPosition({ x: e.clientX, y: e.clientY }, { snapToGrid: false });
+ onDropRef.current(s.source, at, { x: e.clientX, y: e.clientY });
+ };
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key !== 'Escape') return;
+ startRef.current = null;
+ setState(null);
+ };
+ window.addEventListener('pointermove', onMove);
+ window.addEventListener('pointerup', onUp);
+ window.addEventListener('keydown', onKey, true);
+ return () => {
+ window.removeEventListener('pointermove', onMove);
+ window.removeEventListener('pointerup', onUp);
+ window.removeEventListener('keydown', onKey, true);
+ };
+ }, [!!state, screenToFlowPosition]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+/**
+ * The dashed line that follows the pointer. Rendered inside the React Flow
+ * canvas, in flow space, so it lands where the real line will.
+ */
+export function BranchPreview() {
+ const state = useContext(StateContext);
+ if (!state?.moved) return null;
+ const { source, cursor } = state;
+ const from = source.at;
+ // Leave a run across it, the way a branch does; leave a tee sideways.
+ const verticalFirst = source.kind === 'line' && Math.abs(source.dir.x) >= Math.abs(source.dir.y);
+ const corner = verticalFirst ? { x: from.x, y: cursor.y } : { x: cursor.x, y: from.y };
+ return (
+
+
+
+ );
+}
diff --git a/pid-designer/frontend/src/components/pid/BranchableEdge.tsx b/pid-designer/frontend/src/components/pid/BranchableEdge.tsx
index 6239c0eff..bf1ddbeb0 100644
--- a/pid-designer/frontend/src/components/pid/BranchableEdge.tsx
+++ b/pid-designer/frontend/src/components/pid/BranchableEdge.tsx
@@ -1,241 +1,233 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { flushSync } from 'react-dom';
-import {
- BaseEdge,
- useReactFlow,
- type EdgeProps,
-} from '@xyflow/react';
+import { BaseEdge, useReactFlow, type EdgeProps } from '@xyflow/react';
import { splitEdgeAt } from './splitEdge';
-import { isHorizontal, routeOrthogonal } from './route';
+import {
+ dragSegment, jogSegment, nearestOnPolyline, pathPoints, routeOrthogonal, routeThrough, waypointsOf,
+} from './route';
+import type { End, Pt } from './route';
+import { crossingsOf, pathWithHops } from './hops';
+import { publishEdge, unpublishEdge, useOtherEdges } from './edgeGeometry';
import { useEdgeFluidColor } from './FluidContext';
import { useReadOnly } from '@stardesign-ui';
-import { useTool } from './ToolContext';
+import { useTool, useToolDone } from './ToolContext';
+import { useBranchDrag } from './BranchDrag';
+
+export { nearestOnPath, faceTowards } from './route';
/**
- * A pipe: orthogonal, with a middle segment you can move, and a junction you
- * can drop anywhere along it.
+ * A pipe: orthogonal, every segment of it movable, a tee wherever you want
+ * one, and a hop wherever it crosses another.
*
* **Orthogonal, not smoothstep.** A P&ID is drawn with square corners, and the
- * rounded ones React Flow supplies by default read as a flow chart. The path is
- * three segments -- out, across, in -- which is also what makes the middle one
- * a thing you can grab.
+ * rounded ones React Flow supplies by default read as a flow chart.
*
- * **The middle segment moves.** Automatic routing puts it halfway, which is
+ * **Any segment moves.** Automatic routing puts a crossbar halfway, which is
* exactly where the next line also wants to be, and a bay with eight lines
* leaving one tank turns into a stack of overlapping runs nobody can follow.
- * Drag the handle and the crossbar moves; the offset is stored on the edge, so
- * the routing somebody chose survives a reload rather than being recomputed
- * into the same mess.
+ * Each segment has a grip; drag it and the segment moves across, the two
+ * ends stay on their ports, and the corners are stored on the line (see
+ * `routeThrough`) so the routing somebody chose survives a reload. Alt-drag
+ * puts a detour into a segment instead of moving the whole of it. Double-
+ * click a grip and the line routes itself again.
+ *
+ * **Press anywhere on a line and pull, and you are drawing a branch.** The
+ * dot riding the pointer along the run is where the tee will go. A press
+ * that does not move is a click, and still selects the line. Alt-click, or
+ * the Junction tool, puts a tee in without drawing anything from it.
*
- * **Lines that cross are not joined.** Nothing here infers a connection from
- * two paths overlapping -- a crossing on a drawing is usually one line passing
- * over another, and guessing wrong either invents a leak path or hides a real
- * one. Click a line to put a junction on it where you do mean them to meet;
- * the checks panel counts crossings that have no junction so the distinction is
- * visible rather than assumed.
+ * **Lines that cross are not joined**, and the drawing says so: the vertical
+ * one hops the horizontal one. Nothing here infers a connection from two
+ * paths overlapping -- a crossing on a drawing is usually one line passing
+ * over another, and guessing wrong either invents a leak path or hides a
+ * real one.
*/
export function BranchableEdge(props: EdgeProps) {
const {
id,
sourceX, sourceY, targetX, targetY,
sourcePosition, targetPosition,
- style, data,
+ style, data, selected,
} = props;
- const { setNodes, setEdges, getNodes, getEdges, getZoom } = useReactFlow();
+ const { setNodes, setEdges, getNodes, getEdges, screenToFlowPosition } = useReactFlow();
const readOnly = useReadOnly();
- // A junction only goes in while the tool is armed. See ToolContext.
- const armed = useTool() === 'junction' && !readOnly;
- const [hoverAt, setHoverAt] = useState<{ x: number; y: number } | null>(null);
- const [dragging, setDragging] = useState(false);
- const dragFrom = useRef<{ pointer: number; offset: number } | null>(null);
+ const tool = useTool();
+ const done = useToolDone();
+ const { begin, active: pulling } = useBranchDrag();
+ const armed = tool === 'junction' && !readOnly;
const strokeColor = useEdgeFluidColor(id, (data as { color?: string })?.color);
- const offset = ((data as { offset?: number })?.offset ?? 0);
-
- // The shape of the run, and whether it has a crossbar to drag. See route.ts:
- // the rule is that every segment touching an end leaves that end the way the
- // end points, which is what stops a line doubling back over its own symbol.
- const { d: edgePath, grip } = routeOrthogonal(
- { x: sourceX, y: sourceY, side: sourcePosition },
- { x: targetX, y: targetY, side: targetPosition },
- offset,
- );
- // Which way a drag on the crossbar moves it: across the run, so along the
- // axis the two ends leave on.
- const horizontal = isHorizontal(sourcePosition);
+ const routing = data as { offset?: number; waypoints?: Pt[] } | undefined;
+
+ // ── The run ────────────────────────────────────────────────────────────────
+ const a: End = { x: sourceX, y: sourceY, side: sourcePosition };
+ const b: End = { x: targetX, y: targetY, side: targetPosition };
+ const waypoints = routing?.waypoints;
+ const offset = routing?.offset ?? 0;
+ const pts = useMemo(() => {
+ const route = waypoints?.length ? routeThrough(a, b, waypoints) : routeOrthogonal(a, b, offset);
+ return pathPoints(route.d);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, waypoints, offset]);
+
+ // Tell the other lines where this one is, and find out where they are.
+ useLayoutEffect(() => { publishEdge(id, pts); }, [id, pts]);
+ useEffect(() => () => unpublishEdge(id), [id]);
+ const others = useOtherEdges(id);
+ const hops = useMemo(() => crossingsOf(pts, others), [pts, others]);
+ const drawn = useMemo(() => pathWithHops(pts, hops), [pts, hops]);
+
+ const toFlow = useCallback((e: { clientX: number; clientY: number }) =>
+ screenToFlowPosition({ x: e.clientX, y: e.clientY }, { snapToGrid: false }), [screenToFlowPosition]);
+
+ // ── Hovering: the dot that rides the run ───────────────────────────────────
+ const [hover, setHover] = useState(null);
+ const [overGrip, setOverGrip] = useState(false);
+ const onMouseMove = useCallback((e: React.MouseEvent) => {
+ if (readOnly || pulling) return;
+ const near = nearestOnPolyline(pts, toFlow(e));
+ setHover(near ? near.point : null);
+ }, [readOnly, pulling, pts, toFlow]);
- // ── Moving the crossbar ────────────────────────────────────────────────────
- const startDrag = useCallback((e: React.PointerEvent) => {
- if (readOnly) return;
+ // ── Moving a segment ───────────────────────────────────────────────────────
+ const [drag, setDrag] = useState<{ segment: number; jog: boolean; start: Pt; at: Pt; base: Pt[] } | null>(null);
+
+ const startSegmentDrag = useCallback((segment: number, e: React.PointerEvent) => {
+ if (readOnly || e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
- dragFrom.current = { pointer: horizontal ? e.clientX : e.clientY, offset };
- setDragging(true);
- }, [readOnly, horizontal, offset]);
+ const start = toFlow(e);
+ setDrag({ segment, jog: e.altKey, start, at: start, base: pts });
+ }, [readOnly, pts, toFlow]);
useEffect(() => {
- if (!dragging) return;
+ if (!drag) return;
const onMove = (e: PointerEvent) => {
- const from = dragFrom.current;
- if (!from) return;
- // Screen pixels to flow units: at 50% zoom the pointer has to travel
- // twice as far for the same move, and without this the crossbar lags.
- const moved = ((horizontal ? e.clientX : e.clientY) - from.pointer) / getZoom();
+ const now = toFlow(e);
+ const delta = { x: now.x - drag.start.x, y: now.y - drag.start.y };
+ const next = drag.jog
+ ? jogSegment(drag.base, drag.segment, drag.at, delta)
+ : dragSegment(drag.base, drag.segment, delta);
setEdges(eds => eds.map(ed =>
- ed.id === id ? { ...ed, data: { ...ed.data, offset: from.offset + moved } } : ed));
+ ed.id === id ? { ...ed, data: { ...ed.data, waypoints: waypointsOf(next), offset: 0 } } : ed));
};
- const onUp = () => { setDragging(false); dragFrom.current = null; };
+ const onUp = () => setDrag(null);
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
return () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
- }, [dragging, horizontal, id, getZoom, setEdges]);
-
- // ── Dropping a junction on the run ─────────────────────────────────────────
- /**
- * Where a junction would go: the nearest point *on the run*, not the pointer.
- *
- * It used to take the pointer position straight, so a junction landed
- * wherever the cursor happened to be within the twelve-pixel hit area -- up
- * to six pixels off the pipe. The two new edges then ran to a node beside
- * the line they replaced, which is the kink that made this look broken. The
- * path is orthogonal, so snapping to it is a clamp per segment.
- */
- const onMouseMove = useCallback((e: React.MouseEvent) => {
- if (!armed || dragging) return;
- const svg = (e.currentTarget as SVGElement).closest('svg');
- if (!svg) return;
- const pt = svg.createSVGPoint();
- pt.x = e.clientX;
- pt.y = e.clientY;
- const p = pt.matrixTransform(svg.getScreenCTM()!.inverse());
- setHoverAt(nearestOnPath(edgePath, p));
- }, [armed, dragging, edgePath]);
-
- const onClickBranch = useCallback((e: React.MouseEvent) => {
- if (!armed || !hoverAt || dragging) return;
- e.stopPropagation();
+ }, [drag, id, setEdges, toFlow]);
+ /** Back to routing itself. */
+ const resetRoute = useCallback((e: React.MouseEvent) => {
+ if (readOnly) return;
+ e.stopPropagation();
+ e.preventDefault();
+ setEdges(eds => eds.map(ed => {
+ if (ed.id !== id) return ed;
+ const rest = { ...ed.data } as Record;
+ delete rest.waypoints;
+ delete rest.offset;
+ return { ...ed, data: rest };
+ }));
+ }, [readOnly, id, setEdges]);
+
+ // ── Putting a tee in, or pulling a line out ────────────────────────────────
+ const placeJunction = useCallback((at: Pt) => {
// The same operation dropping a connection on a line performs -- see
- // splitEdge.ts. This used to be a second copy of it here, and the two had
- // already drifted: one stamped a junction the delete-rejoin could
- // recognise and the other did not.
- // No page argument: a junction belongs on the page its own pipe is drawn
- // on, and `splitEdgeAt` reads that off the line's upstream end. Pages live
- // on components, so asking the edge would be asking the wrong thing.
- const split = splitEdgeAt(
- getNodes(), getEdges(), id, hoverAt, undefined,
- // The exact handle positions, which this edge knows and a caller working
- // from the node boxes does not.
- { from: { x: sourceX, y: sourceY }, to: { x: targetX, y: targetY } },
- );
+ // splitEdge.ts. No page argument: a junction belongs on the page its own
+ // pipe is drawn on, and `splitEdgeAt` reads that off the line's upstream
+ // end. The exact ends and corners go with it, which a caller working from
+ // the node boxes would not have.
+ const split = splitEdgeAt(getNodes(), getEdges(), id, at, undefined, { a, b, points: pts });
if (!split) return;
-
flushSync(() => {
setNodes(split.nodes);
setEdges(split.edges);
});
- }, [armed, hoverAt, dragging, id, getNodes, getEdges, setNodes, setEdges,
- sourceX, sourceY, targetX, targetY]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [id, pts, getNodes, getEdges, setNodes, setEdges, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition]);
+
+ const onPointerDown = useCallback((e: React.PointerEvent) => {
+ if (readOnly || e.button !== 0 || overGrip) return;
+ const near = nearestOnPolyline(pts, toFlow(e));
+ if (!near) return;
+ // Stop React Flow reading this as a pan or a box-select. Clicks and
+ // double-clicks are separate events and still reach it.
+ e.stopPropagation();
+ e.preventDefault();
+ if (armed || e.altKey) {
+ placeJunction(near.point);
+ if (armed) done();
+ return;
+ }
+ begin({ kind: 'line', edgeId: id, at: near.point, dir: near.dir, points: pts }, e);
+ }, [readOnly, overGrip, pts, toFlow, armed, placeJunction, done, begin, id]);
+
+ // ── Grips: one per segment ─────────────────────────────────────────────────
+ const grips = useMemo(() => {
+ const out: { i: number; x: number; y: number; horizontal: boolean }[] = [];
+ for (let i = 0; i < pts.length - 1; i++) {
+ const p = pts[i], q = pts[i + 1];
+ if (Math.hypot(q.x - p.x, q.y - p.y) < 24) continue; // too short to hold
+ out.push({ i, x: (p.x + q.x) / 2, y: (p.y + q.y) / 2, horizontal: Math.abs(p.y - q.y) < 1e-6 });
+ }
+ return out;
+ }, [pts]);
+ const showGrips = !readOnly && !pulling && (hover !== null || selected || drag !== null);
+ const showDot = !readOnly && !pulling && hover !== null && !overGrip && drag === null;
return (
setHoverAt(null)}
- onClick={onClickBranch}
+ onMouseLeave={() => { setHover(null); setOverGrip(false); }}
+ onPointerDown={onPointerDown}
style={{ cursor: armed ? 'crosshair' : 'pointer' }}
>
{/* Invisible fat hit area, so a 2 px line can be clicked at all. */}
-
-
+
+
- {armed && hoverAt && !dragging && (
+ {showDot && (
)}
- {grip && !readOnly && (
+ {showGrips && grips.map(g => (
startSegmentDrag(g.i, e)}
+ onMouseEnter={() => setOverGrip(true)}
+ onMouseLeave={() => setOverGrip(false)}
onMouseMove={e => e.stopPropagation()}
onClick={e => e.stopPropagation()}
+ onDoubleClick={resetRoute}
// React Flow sets `pointer-events: visibleStroke` on an edge, so a
// shape with a fill and no stroke is invisible to the pointer no
// matter how large it is. The grip needs saying explicitly.
- style={{ cursor: horizontal ? 'ew-resize' : 'ns-resize', pointerEvents: 'all' }}
+ style={{ cursor: g.horizontal ? 'ns-resize' : 'ew-resize', pointerEvents: 'all' }}
>
{/* A generous invisible target over a small visible one. */}
- )}
+ ))}
);
}
-
-/** The corners of an orthogonal path, in order. */
-function pointsOf(d: string): { x: number; y: number }[] {
- return [...d.matchAll(/[ML]\s*(-?[\d.]+),(-?[\d.]+)/g)]
- .map(m => ({ x: Number(m[1]), y: Number(m[2]) }));
-}
-
-/**
- * The closest point on a polyline to `p`.
- *
- * Clamped to each segment and the best one kept, so a junction always sits on
- * the pipe -- including exactly on a corner, which is where people aim when
- * they want to branch at a bend.
- */
-export function nearestOnPath(d: string, p: { x: number; y: number }): { x: number; y: number } {
- const pts = pointsOf(d);
- let best = pts[0] ?? p;
- let bestDist = Infinity;
- for (let i = 0; i < pts.length - 1; i++) {
- const a = pts[i];
- const b = pts[i + 1];
- const dx = b.x - a.x;
- const dy = b.y - a.y;
- const len = dx * dx + dy * dy;
- const t = len === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len));
- const q = { x: a.x + t * dx, y: a.y + t * dy };
- const dist = Math.hypot(p.x - q.x, p.y - q.y);
- if (dist < bestDist) { bestDist = dist; best = q; }
- }
- return best;
-}
-
-/**
- * The face of a junction that points at (fx, fy).
- *
- * A junction is a 10 px dot with four ports, and which one a line attaches to
- * decides which way it leaves. Choosing by direction is what keeps the two
- * halves of a split line collinear with the run they replaced.
- */
-export function faceTowards(fx: number, fy: number, jx: number, jy: number): string {
- const dx = fx - jx;
- const dy = fy - jy;
- if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? 'r' : 'l';
- return dy >= 0 ? 'b' : 't';
-}
diff --git a/pid-designer/frontend/src/components/pid/PIDDesigner.tsx b/pid-designer/frontend/src/components/pid/PIDDesigner.tsx
index d35531d4f..9a7addab5 100644
--- a/pid-designer/frontend/src/components/pid/PIDDesigner.tsx
+++ b/pid-designer/frontend/src/components/pid/PIDDesigner.tsx
@@ -16,6 +16,7 @@ import {
SelectionMode,
ConnectionMode,
type Connection,
+ type FinalConnectionState,
type Node,
type NodeChange,
type Edge,
@@ -34,7 +35,7 @@ import { designApi, keyOf, refOf } from '../../api/diagrams';
import type { DiagramMeta, DocRef, MicroVersion, ReleaseVersion, Snapshot } from '../../api/diagrams';
import { nodeTypes } from './nodes';
import { BranchableEdge } from './BranchableEdge';
-import { nextNodeId, seedIdsFrom } from './ids';
+import { nextJunctionId, nextNodeId, seedIdsFrom } from './ids';
import { defFor } from './types';
import type { PIDNodeData } from './types';
import { numberTag } from './tags';
@@ -55,9 +56,17 @@ import { ChecksPanel } from './ChecksPanel';
import { VentLayer } from './VentLayer';
import { PageBar } from './PageBar';
import { DEFAULT_PAGE, applyPage, listPages, moveToPage, pageOf } from './pages';
-import { clearOfHost, dragAttached, isInstrument, isTapped, targetAt } from './attach';
-import { rejoinAfterDelete, splitEdgeAt } from './splitEdge';
+import { clearOfHost, dragAttached, isInline, isInstrument, isTapped, targetAt } from './attach';
+import { insertInline, rejoinAfterDelete, splitEdgeAt } from './splitEdge';
import { drawnLines, lineAt } from './lineHit';
+import {
+ J_HALF, branchFace, isJunction, junctionData, junctionEnd, reseatJunctions, runDirOf, slideAlong,
+} from './junctions';
+import type { EndLookup, Face } from './junctions';
+import { BranchDragProvider, BranchPreview } from './BranchDrag';
+import type { BranchSource } from './BranchDrag';
+import { faceTowards } from './route';
+import type { Pt } from './route';
import { alignmentShift } from './snap';
import type { PortPositions } from './snap';
import { COMPONENT_SPECS } from './spec';
@@ -235,6 +244,7 @@ function PIDCanvas({
paintRef.current = { on: tool === 'paint', colour };
const toolRef = useRef(tool);
toolRef.current = tool;
+ const disarm = useCallback(() => setTool('none'), []);
// Read inside `clearRef`, which is called through a ref from the toolbar and
// would otherwise close over whichever page was current when it was built.
@@ -250,6 +260,42 @@ function PIDCanvas({
const { undo, redo } = useHistory(nodes, edges, setNodes, setEdges);
+ /**
+ * Where a port is and which way it faces, asked with the node's current
+ * position. Read off React Flow's own measured handle bounds rather than a
+ * table of where each symbol keeps its ports: it already knows, it stays
+ * right when a symbol is turned, and a second copy of that geometry is a
+ * second thing to get wrong. A tee's faces are fixed, so they need no
+ * measuring; anything else unmeasured falls back to its centre.
+ */
+ const endOf = useCallback((node, handleId) => {
+ const hb = getInternalNode(node.id)?.internals.handleBounds?.source?.find(h => h.id === handleId);
+ if (hb) {
+ // The handle's *outer* edge in the direction it faces, which is where
+ // React Flow itself anchors a line (`getHandlePosition`). Its centre
+ // is three pixels short of that, and three pixels was a visible kink
+ // in every run a tee was put back on.
+ const { position: side } = hb;
+ return {
+ x: node.position.x + hb.x + (side === 'right' ? hb.width : side === 'left' ? 0 : hb.width / 2),
+ y: node.position.y + hb.y + (side === 'bottom' ? hb.height : side === 'top' ? 0 : hb.height / 2),
+ side,
+ };
+ }
+ if (isJunction(node) && handleId) return junctionEnd(node.position, handleId as Face);
+ return null;
+ }, [getInternalNode]);
+
+ /** The point a line from `nodeId`'s `handleId` would start at. */
+ const portPoint = useCallback((nodeId: string, handleId: string | null | undefined): Pt | null => {
+ const n = snapshot.current.nodes.find(x => x.id === nodeId);
+ if (!n) return null;
+ const end = endOf(n, handleId);
+ if (end) return { x: end.x, y: end.y };
+ const { w, h } = { w: n.measured?.width ?? 60, h: n.measured?.height ?? 60 };
+ return { x: n.position.x + w / 2, y: n.position.y + h / 2 };
+ }, [endOf]);
+
// Which diagram the current nodes/edges belong to. Guards autosave from writing
// the previous diagram's geometry into the newly-selected one before it loads.
const loadedId = useRef(null);
@@ -621,7 +667,22 @@ function PIDCanvas({
if (c.type === 'position' && c.position) {
const from = before.get(c.id);
if (!from) continue;
- const delta = { x: c.position.x - from.x, y: c.position.y - from.y };
+ // A tee dragged by hand slides along its run: the position React
+ // Flow reports is where the pointer put it, and the run is where
+ // it can actually go. See junctions.ts.
+ const moved = next.find(n => n.id === c.id);
+ const along = moved && isJunction(moved) ? junctionData(moved).along : undefined;
+ if (moved && along) {
+ const slid = slideAlong(
+ moved, along, c.position, snapshot.current.edges, new Map(next.map(n => [n.id, n])), endOf);
+ if (slid) {
+ next = next.map(n => n.id === c.id
+ ? { ...n, position: slid.position, data: { ...n.data, along: slid.along } }
+ : n);
+ }
+ }
+ const now = next.find(n => n.id === c.id)?.position ?? c.position;
+ const delta = { x: now.x - from.x, y: now.y - from.y };
if (delta.x || delta.y) next = dragAttached(next, c.id, delta);
continue;
}
@@ -688,44 +749,170 @@ function PIDCanvas({
connectingFrom.current = params.nodeId ? { nodeId: params.nodeId, handleId: params.handleId } : null;
}, []);
- const onConnectEnd = useCallback((event: MouseEvent | TouchEvent) => {
- const from = connectingFrom.current;
+ /** A line id nothing else on the drawing has. */
+ const freshEdgeId = useCallback((source: string, target: string, edges: Edge[]) => {
+ const base = `${source}-${target}`;
+ if (!edges.some(e => e.id === base)) return base;
+ let n = 2;
+ while (edges.some(e => e.id === `${base}-${n}`)) n++;
+ return `${base}-${n}`;
+ }, []);
+
+ /**
+ * A port drag that ended on a line branches the line; one that ended on
+ * nothing leaves an open end.
+ *
+ * The open end is a tee with one line on it -- see JunctionNode -- drawn
+ * hollow, to be picked up later. It is only made when the drag went
+ * somewhere: a port let go of next to itself is a change of mind, not a
+ * stub. A drag that ended on a port is React Flow's, through `onConnect`.
+ */
+ const onConnectEnd = useCallback((event: MouseEvent | TouchEvent, state: FinalConnectionState) => {
+ const from = state.fromNode && state.fromHandle
+ ? { nodeId: state.fromNode.id, handleId: state.fromHandle.id ?? null }
+ : connectingFrom.current;
connectingFrom.current = null;
if (!from || readOnlyRef.current) return;
+ if (state.toHandle) return;
const point = 'clientX' in event
? { x: event.clientX, y: event.clientY }
: { x: event.changedTouches[0]?.clientX ?? 0, y: event.changedTouches[0]?.clientY ?? 0 };
- const flow = screenToFlowPosition(point);
+ const flow = screenToFlowPosition(point, { snapToGrid: false });
+ const origin = portPoint(from.nodeId, from.handleId) ?? flow;
const { nodes: ns, edges: es } = snapshot.current;
- // Only when it landed on a line. On a component ReactFlow has already made
- // the connection, and `lineAt` will not claim it.
const hit = lineAt(drawnLines(), flow);
- if (!hit) return;
-
- // Not onto a line this component is already an end of. That would be two
- // lines from the same port to the same junction, which is a parallel path
- // and not what anybody dragging there meant.
- const line = es.find(e => e.id === hit.id);
- if (!line || line.source === from.nodeId || line.target === from.nodeId) return;
-
- const split = splitEdgeAt(ns, es, hit.id, hit.at, pageRef.current);
- if (!split) return;
-
- commitGraph(split.nodes, [
- ...split.edges,
- {
- id: `${from.nodeId}-${split.junctionId}`,
- source: from.nodeId,
- sourceHandle: from.handleId ?? undefined,
- target: split.junctionId,
- targetHandle: undefined,
- type: 'smoothstep',
- data: {},
- },
- ]);
- }, [screenToFlowPosition, commitGraph]);
+ if (hit) {
+ // Not onto a line this component is already an end of. That would be
+ // two lines from the same port to the same junction, which is a
+ // parallel path and not what anybody dragging there meant.
+ const line = es.find(e => e.id === hit.id);
+ if (!line || line.source === from.nodeId || line.target === from.nodeId) return;
+ const split = splitEdgeAt(ns, es, hit.id, hit.at, pageRef.current, { points: hit.points });
+ if (!split) return;
+ commitGraph(split.nodes, [
+ ...split.edges,
+ {
+ id: freshEdgeId(from.nodeId, split.junctionId, split.edges),
+ source: from.nodeId,
+ sourceHandle: from.handleId ?? undefined,
+ target: split.junctionId,
+ targetHandle: branchFace(hit.dir, origin, hit.at),
+ type: 'smoothstep',
+ data: {},
+ },
+ ]);
+ return;
+ }
+
+ if (Math.hypot(flow.x - origin.x, flow.y - origin.y) < 40) return;
+ const at = { x: Math.round(flow.x / SNAP[0]) * SNAP[0], y: Math.round(flow.y / SNAP[1]) * SNAP[1] };
+ const junctionId = nextJunctionId();
+ const open: Node = {
+ id: junctionId, type: 'JUNCTION',
+ position: { x: at.x - J_HALF, y: at.y - J_HALF },
+ data: { componentType: 'JUNCTION', label: junctionId, page: pageRef.current } as unknown as Record,
+ };
+ commitGraph([...ns, open], [...es, {
+ id: freshEdgeId(from.nodeId, junctionId, es),
+ source: from.nodeId,
+ sourceHandle: from.handleId ?? undefined,
+ target: junctionId,
+ targetHandle: faceTowards(origin.x, origin.y, at.x, at.y),
+ type: 'smoothstep',
+ data: {},
+ }]);
+ }, [screenToFlowPosition, commitGraph, portPoint, freshEdgeId]);
+
+ /**
+ * A line pulled out of a line, or out of a tee, let go somewhere.
+ *
+ * What it landed on decides what it joins: a port takes it as drawn; a
+ * component takes it on whichever of its ports is nearest; another line is
+ * teed where it was hit; a tee takes it on the face across its run; and
+ * empty canvas leaves an open end to be picked up later. The line it was
+ * pulled from is teed where the pull began. Every face is chosen from the
+ * geometry, never from which handle a drop happened to land on.
+ */
+ const onBranchDrop = useCallback((source: BranchSource, at: Pt, client: { x: number; y: number }) => {
+ if (readOnlyRef.current) return;
+ let { nodes: ns, edges: es } = snapshot.current;
+ const page = pageRef.current;
+
+ // What is under the pointer, by DOM: a handle, a symbol, or nothing.
+ const el = document.elementFromPoint(client.x, client.y) as HTMLElement | null;
+ const handleEl = el?.closest('.react-flow__handle');
+ const nodeEl = el?.closest('.react-flow__node');
+ const hitId = handleEl?.dataset.nodeid ?? nodeEl?.dataset.id;
+ const sourceNodeId = source.kind === 'node' ? source.nodeId : null;
+ const sourceEdgeId = source.kind === 'line' ? source.edgeId : null;
+
+ let target: { id: string; handle: string } | null = null;
+ if (hitId && hitId !== sourceNodeId) {
+ const n = ns.find(x => x.id === hitId);
+ if (n && isJunction(n)) {
+ const along = junctionData(n).along;
+ const c = { x: n.position.x + J_HALF, y: n.position.y + J_HALF };
+ target = { id: n.id, handle: along ? branchFace(runDirOf(along), source.at, c) : faceTowards(source.at.x, source.at.y, c.x, c.y) };
+ } else if (n && handleEl?.dataset.handleid) {
+ target = { id: n.id, handle: handleEl.dataset.handleid };
+ } else if (n) {
+ // The nearest of its ports to where the pointer let go.
+ const handles = getInternalNode(n.id)?.internals.handleBounds?.source ?? [];
+ let best: { id: string; d: number } | null = null;
+ for (const h of handles) {
+ const hx = n.position.x + h.x + h.width / 2, hy = n.position.y + h.y + h.height / 2;
+ const d = Math.hypot(hx - at.x, hy - at.y);
+ if (!best || d < best.d) best = { id: h.id ?? '', d };
+ }
+ if (best) target = { id: n.id, handle: best.id };
+ }
+ }
+ if (!target) {
+ const hit = lineAt(drawnLines(), at, 14, sourceEdgeId ?? undefined);
+ if (hit) {
+ const split = splitEdgeAt(ns, es, hit.id, hit.at, page, { points: hit.points });
+ if (!split) return;
+ ns = split.nodes; es = split.edges;
+ target = { id: split.junctionId, handle: branchFace(hit.dir, source.at, hit.at) };
+ }
+ }
+ if (!target) {
+ if (Math.hypot(at.x - source.at.x, at.y - source.at.y) < 30) return;
+ const p = { x: Math.round(at.x / SNAP[0]) * SNAP[0], y: Math.round(at.y / SNAP[1]) * SNAP[1] };
+ const junctionId = nextJunctionId();
+ ns = [...ns, {
+ id: junctionId, type: 'JUNCTION',
+ position: { x: p.x - J_HALF, y: p.y - J_HALF },
+ data: { componentType: 'JUNCTION', label: junctionId, page } as unknown as Record,
+ }];
+ target = { id: junctionId, handle: faceTowards(source.at.x, source.at.y, p.x, p.y) };
+ }
+
+ // Now the end the pull began at.
+ let from: { id: string; handle: string };
+ const targetPoint = portPoint(target.id, target.handle) ?? at;
+ if (source.kind === 'line') {
+ const split = splitEdgeAt(ns, es, source.edgeId, source.at, page, { points: source.points });
+ if (!split) return;
+ ns = split.nodes; es = split.edges;
+ from = { id: split.junctionId, handle: branchFace(source.dir, targetPoint, source.at) };
+ } else {
+ const n = ns.find(x => x.id === source.nodeId);
+ const along = n && isJunction(n) ? junctionData(n).along : undefined;
+ from = { id: source.nodeId, handle: along ? branchFace(runDirOf(along), targetPoint, source.at) : faceTowards(targetPoint.x, targetPoint.y, source.at.x, source.at.y) };
+ }
+ if (from.id === target.id) return;
+
+ commitGraph(ns, [...es, {
+ id: freshEdgeId(from.id, target.id, es),
+ source: from.id, sourceHandle: from.handle,
+ target: target.id, targetHandle: target.handle,
+ type: 'smoothstep',
+ data: {},
+ }]);
+ }, [commitGraph, getInternalNode, portPoint, freshEdgeId]);
const onDragOver = (e: React.DragEvent) => {
e.preventDefault();
@@ -801,28 +988,54 @@ function PIDCanvas({
* junction, then draw the line, then remember which of four ports to use
* is three steps for one intention.
*/
+ /**
+ * A valve, a regulator or a disconnect dropped on a line goes *into* it.
+ *
+ * It used to land on top of the line, unconnected, and the next four
+ * gestures were the ones that made it part of the run. See
+ * `insertInline`: the run breaks around it and the part is turned to
+ * face the way the run goes.
+ */
+ if (isInline(type)) {
+ const hit = lineAt(drawnLines(), flowPos);
+ if (hit) {
+ const part: Node = { id, type, position: flowPos, data: nodeData as unknown as Record };
+ const ins = insertInline(
+ snapshot.current.nodes, snapshot.current.edges, hit.id, hit.at, part, { points: hit.points });
+ if (ins) { commitGraph(ins.nodes, ins.edges); return; }
+ }
+ }
+
if (isTapped(type)) {
const hit = lineAt(drawnLines(), flowPos);
if (hit) {
const at = hit.at;
const split = splitEdgeAt(
- snapshot.current.nodes, snapshot.current.edges, hit.id, at, pageRef.current);
+ snapshot.current.nodes, snapshot.current.edges, hit.id, at, pageRef.current, { points: hit.points });
if (split) {
// Standing off the pipe, on the side the pointer was, so the symbol
- // does not sit on top of the line it is reading. Below the line it
- // is turned over, because its one tapping is on its underside and a
- // tap has to point at the pipe -- the lettering stays upright.
- const above = flowPos.y <= at.y;
+ // does not sit on top of the line it is reading, and turned so its
+ // one tapping points at the pipe -- the lettering stays upright.
+ // Which side is "off the pipe" depends on which way the pipe runs.
+ const face = branchFace(hit.dir, flowPos, at);
+ const placement: Record = {
+ t: { x: at.x - 30, y: at.y - 90 },
+ b: { x: at.x - 30, y: at.y + 30, rotation: 180 },
+ l: { x: at.x - 90, y: at.y - 30, rotation: 270 },
+ r: { x: at.x + 30, y: at.y - 30, rotation: 90 },
+ };
+ const { rotation, ...position } = placement[face];
commitGraph(
[...split.nodes, {
id, type,
- position: { x: at.x - 30, y: above ? at.y - 90 : at.y + 30 },
- data: { ...nodeData, ...(above ? {} : { rotation: 180 }) } as unknown as Record,
+ position,
+ data: { ...nodeData, ...(rotation ? { rotation } : {}) } as unknown as Record,
}],
[...split.edges, {
id: `${id}-${split.junctionId}`,
source: id, sourceHandle: 'b',
target: split.junctionId,
+ targetHandle: face,
type: 'smoothstep',
data: {},
}]);
@@ -947,6 +1160,40 @@ function PIDCanvas({
});
}, [getInternalNode, setNodes]);
+ /**
+ * Every tee stays on its run.
+ *
+ * After any change to the drawing, each tee that rides a run is put back at
+ * its fraction of the way along it and its lines re-pointed at the faces
+ * the run now uses there. Done after the render rather than inside the
+ * node-change handler because it needs both the nodes and the lines, and
+ * the handler only has one of them in hand. `reseatJunctions` hands back
+ * the very same arrays when there is nothing to do, which is what stops
+ * this running itself again.
+ */
+ const reseatBurst = useRef([]);
+ useEffect(() => {
+ // Not until React Flow has measured every node: before that a port's
+ // position is a guess, and a tee is never seated on a guess. See
+ // `Run.unmeasured` for what happens otherwise.
+ if (!nodesReady) return;
+ // And never as a runaway. Every seat returns the same arrays once the
+ // drawing is settled, so this effect runs itself to a stop within a few
+ // renders; if some future feedback keeps it going, stop and say so rather
+ // than take the page down with "maximum update depth exceeded".
+ const now = performance.now();
+ const burst = reseatBurst.current.filter(t => now - t < 1000);
+ burst.push(now);
+ reseatBurst.current = burst;
+ if (burst.length > 30) {
+ if (burst.length === 31) console.warn('pid-designer: tees would not settle; leaving them where they are');
+ return;
+ }
+ const re = reseatJunctions(nodes, edges, endOf);
+ if (re.nodes !== nodes) setNodes(re.nodes);
+ if (re.edges !== edges) setEdges(re.edges);
+ }, [nodes, edges, endOf, setNodes, setEdges, nodesReady]);
+
const onNodeClick = useCallback((e: React.MouseEvent, node: Node) => {
if (paintIfArmed('node', node.id)) { e.stopPropagation(); e.preventDefault(); }
}, [paintIfArmed]);
@@ -1041,8 +1288,9 @@ function PIDCanvas({
onClick={() => setColorMenu(null)}
>
-
+
+
+
{/* One line, and only the gestures nothing else on screen mentions.
@@ -1091,10 +1340,11 @@ function PIDCanvas({
it stopped taking clicks meant for the canvas underneath. */}
- Double-click to configure · R rotates · ⌘C ⌘V ⌘D copy, paste, duplicate · Right-click colours
+ Pull from a port or a line to draw · Drop a valve on a line to put it in · Double-click to configure · R rotates
+
@@ -1150,8 +1400,8 @@ function PIDCanvas({
disabled={readOnly}
onClick={() => setTool(t => (t === 'junction' ? 'none' : 'junction'))}
title={tool === 'junction'
- ? 'Junction on — click a line to branch it, or press Escape'
- : 'Junction: click a line to put a branch point on it'}
+ ? 'Click a line to put a tee on it, or press Escape'
+ : 'Tee: click a line to put a branch point on it (or Alt-click the line)'}
className={`flex items-center gap-1.5 rounded px-2 py-1 text-xs transition-colors ${
tool === 'junction'
? 'bg-[var(--color-accent)] text-white'
diff --git a/pid-designer/frontend/src/components/pid/ToolContext.tsx b/pid-designer/frontend/src/components/pid/ToolContext.tsx
index 763e78f4b..cedd50fea 100644
--- a/pid-designer/frontend/src/components/pid/ToolContext.tsx
+++ b/pid-designer/frontend/src/components/pid/ToolContext.tsx
@@ -15,10 +15,17 @@ import type { ReactNode } from 'react';
*/
export type Tool = 'none' | 'paint' | 'junction';
-const ToolContext = createContext('none');
+const ToolContext = createContext<{ tool: Tool; done: () => void }>({ tool: 'none', done: () => {} });
-export function ToolProvider({ tool, children }: { tool: Tool; children: ReactNode }) {
- return {children};
+/**
+ * `done` is how a one-shot tool puts itself down. The Junction tool used to
+ * stay armed until Escape, so the click after placing one -- meant to select
+ * something -- put in another. Placing one is the job; the tool disarms when
+ * the job is done.
+ */
+export function ToolProvider({ tool, onDone, children }: { tool: Tool; onDone: () => void; children: ReactNode }) {
+ return {children};
}
-export const useTool = () => useContext(ToolContext);
+export const useTool = () => useContext(ToolContext).tool;
+export const useToolDone = () => useContext(ToolContext).done;
diff --git a/pid-designer/frontend/src/components/pid/checks.ts b/pid-designer/frontend/src/components/pid/checks.ts
index afa8a09fe..777b6e28d 100644
--- a/pid-designer/frontend/src/components/pid/checks.ts
+++ b/pid-designer/frontend/src/components/pid/checks.ts
@@ -406,7 +406,7 @@ export function runChecks(nodes: Node[], edges: Edge[]): Finding[] {
id: 'lines-crossing',
severity: 'info',
title: `${crossings} place${crossings === 1 ? '' : 's'} where lines cross`,
- detail: 'Crossing is not joining. To join them, drag one onto the other; to keep them apart, drag a line’s middle segment.',
+ detail: 'Crossing is not joining, and the drawing says so: the vertical line hops the horizontal one. To join them, pull from one line onto the other; to keep them apart, drag a segment out of the way.',
});
}
diff --git a/pid-designer/frontend/src/components/pid/nodes/JunctionNode.tsx b/pid-designer/frontend/src/components/pid/nodes/JunctionNode.tsx
index c18058684..37b581d9c 100644
--- a/pid-designer/frontend/src/components/pid/nodes/JunctionNode.tsx
+++ b/pid-designer/frontend/src/components/pid/nodes/JunctionNode.tsx
@@ -1,16 +1,30 @@
-import { Position, type NodeProps } from '@xyflow/react';
+import { useState } from 'react';
+import { Position, useReactFlow, useStore, type NodeProps } from '@xyflow/react';
import { Port } from './Port';
import { useNodeFluid } from '../FluidContext';
import { colorForSpecies } from '../fluids';
+import { useBranchDrag } from '../BranchDrag';
+import { useReadOnly } from '@stardesign-ui';
+import { J_HALF } from '../junctions';
/**
* A branch point: the tee, as the drawing says it.
*
* Small on purpose -- it is a point in a run, not a component -- but it is
* still a thing people select, move and delete, so it has to behave like one.
- * It carried `nodrag`, which in ReactFlow turns off the pointer handling that
- * *selects* a node as well as the part that moves it: the dot could not be
- * picked at all, and pressing Delete over it did nothing.
+ *
+ * **The dot moves; the ring connects.** A tee is ten pixels across and its
+ * four ports were ten pixels each, on its four faces, so their union covered
+ * the whole of what you could see. Pressing on the dot started a new line;
+ * moving the tee meant finding an invisible halo around it. That inversion
+ * was most of what made tees feel broken. The ports are still there -- lines
+ * have to end on something -- but they no longer take the pointer. Press the
+ * dot and you drag it, which slides it along its run (see junctions.ts);
+ * pull the ring that appears around it and you draw a new line out of it.
+ *
+ * A tee with one line on it is an **open end**: a run somebody started and
+ * has not finished, drawn hollow so it reads as unfinished. Pull its ring to
+ * carry the run on.
*/
export function JunctionNode({ id, selected }: NodeProps) {
// A junction is a point *in* a run, so it is drawn in the run's own colour.
@@ -18,39 +32,67 @@ export function JunctionNode({ id, selected }: NodeProps) {
// pipe rather than a tee in it.
const fluid = useNodeFluid(id);
const tint = fluid?.species ? colorForSpecies(fluid.species) : 'var(--color-text-secondary)';
- const handleStyle = {
- width: 10,
- height: 10,
- background: 'transparent',
- border: 'none',
+ const readOnly = useReadOnly();
+ const { begin } = useBranchDrag();
+ const { getInternalNode } = useReactFlow();
+ const [hover, setHover] = useState(false);
+ const degree = useStore(s => {
+ let n = 0;
+ for (const e of s.edges) if (e.source === id || e.target === id) n++;
+ return n;
+ });
+ const open = degree <= 1;
+
+ // Faces, not handles: they anchor the lines and take no pointer.
+ const faceStyle = {
+ width: 10, height: 10, background: 'transparent', border: 'none',
+ pointerEvents: 'none' as const, boxShadow: 'none',
};
+ const ink = selected ? 'var(--color-text-primary)' : tint;
return (
setHover(true)}
+ onMouseLeave={() => setHover(false)}
style={{
- width: 10,
- height: 10,
- borderRadius: '50%',
- background: selected ? 'var(--color-text-primary)' : tint,
- border: '2px solid var(--color-bg-secondary)',
- boxShadow: `0 0 0 2px ${selected ? 'var(--color-text-primary)' : tint}`,
- position: 'relative',
- cursor: 'grab',
+ width: 10, height: 10, borderRadius: '50%', position: 'relative',
+ cursor: readOnly ? 'default' : 'grab',
+ background: open ? 'var(--color-bg-primary)' : ink,
+ border: `2px solid ${open ? ink : 'var(--color-bg-secondary)'}`,
+ boxShadow: open ? 'none' : `0 0 0 2px ${ink}`,
}}
>
{/* Ten pixels is a hard thing to hit. This reaches past the dot without
drawing anything, so aiming at a junction is aiming at a target the
- size of a symbol -- and it sits under the handles, so a drag that
- starts on one still draws a line. */}
-
+ size of a symbol. */}
+
+
+ {/* The ring: pull it to draw a line out of the tee. `nodrag` keeps a
+ press on it from moving the tee instead. */}
+ {!readOnly && (hover || selected) && (
+
);
}
diff --git a/pid-designer/frontend/src/index.css b/pid-designer/frontend/src/index.css
index 6effa7633..20ca53b5f 100644
--- a/pid-designer/frontend/src/index.css
+++ b/pid-designer/frontend/src/index.css
@@ -61,3 +61,16 @@ body {
font-family: system-ui, -apple-system, sans-serif;
transition: background-color 0.15s ease, color 0.15s ease;
}
+
+/* ── Ports and tees ─────────────────────────────────────────────────────────
+ A port shows itself as something you pull a line from the moment the
+ pointer is over its symbol, so "move the symbol" and "draw from its port"
+ stop being the same pixel. A tee's faces anchor lines and are never shown:
+ the tee is dragged by its dot and drawn from by its ring. */
+.react-flow__node:hover .react-flow__handle:not(.pid-junction-face) {
+ box-shadow: 0 0 0 2px var(--color-bg-primary), 0 0 0 3.5px var(--color-text-secondary);
+}
+.react-flow__handle:not(.pid-junction-face):hover {
+ box-shadow: 0 0 0 2px var(--color-bg-primary), 0 0 0 4px var(--color-text-primary);
+ transform-origin: center;
+}
diff --git a/pid-designer/frontend/src/lib/gating.test.ts b/pid-designer/frontend/src/lib/gating.test.ts
index 88abb7906..36ee24a83 100644
--- a/pid-designer/frontend/src/lib/gating.test.ts
+++ b/pid-designer/frontend/src/lib/gating.test.ts
@@ -207,12 +207,18 @@ describe('every diagram-editing control is gated on the checkout', () => {
const src = Object.entries(files).find(([p]) => p.endsWith('/BranchableEdge.tsx'))?.[1]
expect(src, 'BranchableEdge.tsx not found').toBeTruthy()
- const handler = src!.slice(src!.indexOf('const onClickBranch'))
- const guard = handler.slice(0, handler.indexOf('\n }'))
- expect(
- /if \(!armed/.test(guard),
- 'onClickBranch must return early unless the junction tool is armed',
- ).toBe(true)
+ //
+ // The press on a line now does two things, and only one of them rewrites
+ // the graph: with the tool armed, or Alt held, it puts a tee in; otherwise
+ // it starts a pull, which does nothing until the pointer has moved. So the
+ // tee goes in behind exactly one guard, and nowhere else in the handler.
+ const handler = src!.slice(src!.indexOf('const onPointerDown'))
+ const body = handler.slice(0, handler.indexOf('\n }, ['))
+ const guardAt = body.indexOf('if (armed || e.altKey)')
+ expect(guardAt, 'onPointerDown must guard the tee on the tool being armed or Alt held').toBeGreaterThan(-1)
+ const placeAt = body.indexOf('placeJunction(')
+ expect(placeAt, 'the tee must go in inside that guard').toBeGreaterThan(guardAt)
+ expect(body.indexOf('placeJunction(', placeAt + 1), 'and only there').toBe(-1)
})
it('keeps every exemption pointing at a real file', () => {
From 8386d8847caa6ba534e09ac81cb3dc50fda5de5a Mon Sep 17 00:00:00 2001
From: Carlsaurus
Date: Sat, 19 Sep 2026 21:58:00 -0700
Subject: [PATCH 19/24] Phase 6 notes, and a dev proxy that follows the API
port override
The plan document gains a Phase 6 section describing the connection
model and what is deliberately left out. The Vite proxy target follows
PID_DESIGNER_API_PORT, the same override dev.sh already honours for the
API, so a second checkout can run its own pair of servers.
---
.../integration/pid-designer-overhaul-plan.md | 49 +++++++++++++++++++
pid-designer/frontend/vite.config.ts | 5 +-
2 files changed, 53 insertions(+), 1 deletion(-)
diff --git a/docs/integration/pid-designer-overhaul-plan.md b/docs/integration/pid-designer-overhaul-plan.md
index 5a3d7a871..0f17f92ed 100644
--- a/docs/integration/pid-designer-overhaul-plan.md
+++ b/docs/integration/pid-designer-overhaul-plan.md
@@ -289,3 +289,52 @@ tables, every ID in the size chart, the Cd→Cv derivation. Each lands with
a reference string, and anything seeded before it is checked carries
`verified: false` and shows up in the checks panel, the way the NPT
engagements do today.
+
+## Phase 6 — Connections (experimental, branch `pid/connections`)
+
+The connection tool was the part users called unintuitive. The data model was
+right — a graph of symbols and lines, a tee as a node because a tee is a mass
+balance — and the interaction layer was wrong. What changed, and why:
+
+**The dot moves; the ring connects.** A tee's four ports covered the whole of
+the visible dot, so pressing on it drew a line and moving it meant finding an
+invisible halo. The ports still anchor lines but take no pointer; the dot
+drags, and a dashed ring on hover pulls a new line out. A tee with one line is
+an *open end*, drawn hollow.
+
+**A tee rides its run** (`junctions.ts`). It sits a fixed fraction of the way
+between whatever is on the far end of each of its two run lines, found from
+adjacency every time. Move an end and it is put back at its fraction; re-route
+a half by hand and it keeps its place; put a valve into a half and its run
+simply got shorter. Its lines are re-pointed at the right faces from where the
+run goes there, and each half is handed the run's corners on its side (marked
+`viaRun`, so a half somebody routed by hand is left alone). Chains along one
+pipe ride each other and settle in a few passes. Ports anchor where React Flow
+anchors them — the handle's outer edge, not its centre.
+
+**Pull a line out of a line** (`BranchDrag.tsx`). Press anywhere on a line and
+pull: the dot riding the pointer is where the tee goes; release on a port, a
+symbol (nearest port), another line (a second tee), a tee (the face across its
+run) or empty canvas (an open end). A press that does not move is a click.
+Alt-click, or the Junction tool, puts a bare tee in; the tool is one-shot.
+Dragging from a port and letting go on nothing also leaves an open end.
+
+**Drop a part into a line** (`insertInline`). Anything in `INLINE` dropped on a
+run breaks the run around it, turned to face the way the run goes, upstream
+half to `l` and `r` to the downstream half; corners inside its body are
+dropped. Deleting it heals the run, exactly as deleting a mid-line tee does.
+
+**Any segment moves** (`routeThrough`, `dragSegment`, `jogSegment`). Grips on
+every segment; a segment touching a port gains a stub and a corner so the
+port still leaves the way it faces; Alt-drag puts a detour in; double-click a
+grip to route the line automatically again. Corners are stored on the line as
+`waypoints`; `offset` is read for old drawings and no longer written.
+
+**Crossings hop** (`hops.ts`, `edgeGeometry.ts`). Every line publishes its
+corners; the vertical line at each crossing draws a semicircle in its own
+path, so it exports with the line. Nothing infers a join from an overlap.
+
+What is deliberately not done: a tee still needs a run of exactly two run
+lines to ride (a cross with four legs keeps its position); the old
+`SegmentPanel` fittings path is untouched; an open end is a tee with one line
+and feed-twin reads it as the dead end it already handled.
diff --git a/pid-designer/frontend/vite.config.ts b/pid-designer/frontend/vite.config.ts
index ee0c1bae0..f9a4c32e2 100644
--- a/pid-designer/frontend/vite.config.ts
+++ b/pid-designer/frontend/vite.config.ts
@@ -22,7 +22,10 @@ export default defineConfig({
port: 5174,
proxy: {
'/api': {
- target: 'http://localhost:8001',
+ // Follows the same override dev.sh honours for the API itself, so a
+ // second checkout can run its own pair of servers on other ports
+ // rather than proxying into the first one's backend.
+ target: `http://localhost:${process.env.PID_DESIGNER_API_PORT ?? 8001}`,
changeOrigin: true,
},
},
From 52c89f10ff50db2f91af3632a191e4eb7bbbdac0 Mon Sep 17 00:00:00 2001
From: Carlsaurus
Date: Sat, 19 Sep 2026 23:21:26 -0700
Subject: [PATCH 20/24] A line's faces are chosen by the route they make, and a
tee routes as a tee
Pulling from a tee onto a nearby run still knotted. Two causes, one
underneath the other.
A branch's face was picked by which side of the tee the other end's
centre was on. For two tees on runs at nearly the same height that put
`t` on one and `b` on the other, and the router can only join those with
a five-segment S over one run and under the other. Both `t` is a
three-segment hook. So `pointLines` now chooses the faces of every line
touching a tee together, by trying each combination -- the two across
the run, all four of an open end, a symbol's port as drawn -- and
keeping the shortest route with the fewest corners, the current faces
winning a tie. It runs after every seat. A pull released on a symbol's
body picks the port by the same cost, so a port that faces away is not
chosen for being nearest.
Underneath that, the router treated every end as a symbol: a sixteen-
pixel stub and a forty-four-pixel clearance. Two tees thirty pixels
apart had no room between two stubs for the one crossbar that joins
them, and went round both. A tee end now says what it is (`J_END`: six
and fourteen), and -- the part that mattered -- the edge renderer applies
it too, via `useNodesData`, so the route a face was chosen for is the
route that gets drawn. Before that the chooser picked a Z and the
renderer drew a loop.
Also: a tee's pull ring is an SVG stroke with `pointer-events: stroke`.
As a div it covered the dot, so after a hover, pressing the dot started
a pull instead of a drag. Hand-placed corners now move with a box
selection when both ends of their line move together. A tee refuses a
pull back onto its own run. Hops keep two radii clear of a corner.
---
.../integration/pid-designer-overhaul-plan.md | 22 ++++
.../src/components/pid/BranchableEdge.tsx | 20 +++-
.../src/components/pid/PIDDesigner.tsx | 71 ++++++++---
.../frontend/src/components/pid/hops.test.ts | 2 +-
.../frontend/src/components/pid/hops.ts | 4 +-
.../src/components/pid/junctions.test.ts | 91 ++++++++++++++
.../frontend/src/components/pid/junctions.ts | 113 ++++++++++++++++--
.../src/components/pid/nodes/JunctionNode.tsx | 48 +++++---
.../frontend/src/components/pid/route.ts | 58 ++++++---
.../src/components/pid/routing.test.ts | 13 ++
10 files changed, 376 insertions(+), 66 deletions(-)
diff --git a/docs/integration/pid-designer-overhaul-plan.md b/docs/integration/pid-designer-overhaul-plan.md
index 0f17f92ed..e4133c756 100644
--- a/docs/integration/pid-designer-overhaul-plan.md
+++ b/docs/integration/pid-designer-overhaul-plan.md
@@ -312,6 +312,20 @@ run goes there, and each half is handed the run's corners on its side (marked
pipe ride each other and settle in a few passes. Ports anchor where React Flow
anchors them — the handle's outer edge, not its centre.
+**A line's faces are chosen by the route they make** (`pointLines`). A
+branch's face used to be picked by which side of the tee the other end's
+centre was on, and for two tees on runs at nearly the same height that put
+`t` on one and `b` on the other -- which the router can only join with a
+five-segment S over one run and under the other. That was the knot. Every
+line touching a tee now tries each combination of its candidate faces (the
+two across the run; all four for an open end; a symbol's port is fixed) and
+keeps the shortest route with the fewest corners, current faces winning a
+tie. A tee end also tells the router what it is: a six-pixel stub and a
+fourteen-pixel clearance instead of a symbol's sixteen and forty-four, so
+two tees thirty pixels apart get one crossbar rather than a detour round
+both. A pull released on a symbol's body picks the port by the same cost,
+so a port that faces away is never chosen just for being nearest.
+
**Pull a line out of a line** (`BranchDrag.tsx`). Press anywhere on a line and
pull: the dot riding the pointer is where the tee goes; release on a port, a
symbol (nearest port), another line (a second tee), a tee (the face across its
@@ -324,6 +338,14 @@ run breaks the run around it, turned to face the way the run goes, upstream
half to `l` and `r` to the downstream half; corners inside its body are
dropped. Deleting it heals the run, exactly as deleting a mid-line tee does.
+**The ring is a ring.** It was a 24 px square div over the dot, so once
+somebody had hovered, pressing the dot itself started a pull. It is an SVG
+stroke with `pointer-events: stroke`; the dot underneath still drags.
+
+**Corners go with a group.** Hand-placed corners are absolute; when both
+ends of a line move together in a box selection, the corners between them
+move too, so a routed bay survives being picked up.
+
**Any segment moves** (`routeThrough`, `dragSegment`, `jogSegment`). Grips on
every segment; a segment touching a port gains a stub and a corner so the
port still leaves the way it faces; Alt-drag puts a detour in; double-click a
diff --git a/pid-designer/frontend/src/components/pid/BranchableEdge.tsx b/pid-designer/frontend/src/components/pid/BranchableEdge.tsx
index bf1ddbeb0..31b8008d5 100644
--- a/pid-designer/frontend/src/components/pid/BranchableEdge.tsx
+++ b/pid-designer/frontend/src/components/pid/BranchableEdge.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { flushSync } from 'react-dom';
-import { BaseEdge, useReactFlow, type EdgeProps } from '@xyflow/react';
+import { BaseEdge, useNodesData, useReactFlow, type EdgeProps } from '@xyflow/react';
+import { J_END } from './junctions';
import { splitEdgeAt } from './splitEdge';
import {
dragSegment, jogSegment, nearestOnPolyline, pathPoints, routeOrthogonal, routeThrough, waypointsOf,
@@ -44,12 +45,21 @@ export { nearestOnPath, faceTowards } from './route';
*/
export function BranchableEdge(props: EdgeProps) {
const {
- id,
+ id, source, target,
sourceX, sourceY, targetX, targetY,
sourcePosition, targetPosition,
style, data, selected,
} = props;
+ // Whether each end is on a tee. The router routes a tee differently -- a
+ // six-pixel stub and a fourteen-pixel clearance, not a symbol's sixteen
+ // and forty-four -- and the faces a line is given were chosen on that
+ // basis (see `pointLines`). Drawing it as if both ends were symbols is
+ // what turned a chosen three-segment Z into a six-segment loop.
+ const endNodes = useNodesData([source, target]);
+ const isTee = (i: number) => (endNodes[i]?.data as { componentType?: string } | undefined)?.componentType === 'JUNCTION';
+ const teeA = isTee(0), teeB = isTee(1);
+
const { setNodes, setEdges, getNodes, getEdges, screenToFlowPosition } = useReactFlow();
const readOnly = useReadOnly();
const tool = useTool();
@@ -61,15 +71,15 @@ export function BranchableEdge(props: EdgeProps) {
const routing = data as { offset?: number; waypoints?: Pt[] } | undefined;
// ── The run ────────────────────────────────────────────────────────────────
- const a: End = { x: sourceX, y: sourceY, side: sourcePosition };
- const b: End = { x: targetX, y: targetY, side: targetPosition };
+ const a: End = { x: sourceX, y: sourceY, side: sourcePosition, ...(teeA ? J_END : {}) };
+ const b: End = { x: targetX, y: targetY, side: targetPosition, ...(teeB ? J_END : {}) };
const waypoints = routing?.waypoints;
const offset = routing?.offset ?? 0;
const pts = useMemo(() => {
const route = waypoints?.length ? routeThrough(a, b, waypoints) : routeOrthogonal(a, b, offset);
return pathPoints(route.d);
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, waypoints, offset]);
+ }, [sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, waypoints, offset, teeA, teeB]);
// Tell the other lines where this one is, and find out where they are.
useLayoutEffect(() => { publishEdge(id, pts); }, [id, pts]);
diff --git a/pid-designer/frontend/src/components/pid/PIDDesigner.tsx b/pid-designer/frontend/src/components/pid/PIDDesigner.tsx
index 9a7addab5..29a47bf0e 100644
--- a/pid-designer/frontend/src/components/pid/PIDDesigner.tsx
+++ b/pid-designer/frontend/src/components/pid/PIDDesigner.tsx
@@ -60,13 +60,14 @@ import { clearOfHost, dragAttached, isInline, isInstrument, isTapped, targetAt }
import { insertInline, rejoinAfterDelete, splitEdgeAt } from './splitEdge';
import { drawnLines, lineAt } from './lineHit';
import {
- J_HALF, branchFace, isJunction, junctionData, junctionEnd, reseatJunctions, runDirOf, slideAlong,
+ J_ANCHOR, J_END, J_HALF, branchFace, isJunction, junctionData, junctionEnd, reseatJunctions, runDirOf, slideAlong,
} from './junctions';
import type { EndLookup, Face } from './junctions';
import { BranchDragProvider, BranchPreview } from './BranchDrag';
import type { BranchSource } from './BranchDrag';
-import { faceTowards } from './route';
-import type { Pt } from './route';
+import { faceTowards, pathPoints as pathPointsOf, polylineLength, routeOrthogonal } from './route';
+import type { End, Pt } from './route';
+import { Position } from '@xyflow/react';
import { alignmentShift } from './snap';
import type { PortPositions } from './snap';
import { COMPONENT_SPECS } from './spec';
@@ -285,6 +286,11 @@ function PIDCanvas({
if (isJunction(node) && handleId) return junctionEnd(node.position, handleId as Face);
return null;
}, [getInternalNode]);
+ // A tee's measured handle is still a tee: routes need only clear the dot.
+ const endOfClear = useCallback((node, handleId) => {
+ const e = endOf(node, handleId);
+ return e && isJunction(node) && e.clear === undefined ? { ...e, ...J_END } : e;
+ }, [endOf]);
/** The point a line from `nodeId`'s `handleId` would start at. */
const portPoint = useCallback((nodeId: string, handleId: string | null | undefined): Pt | null => {
@@ -660,6 +666,15 @@ function PIDCanvas({
// React Flow emits position changes faster than React re-renders during a
// drag, so several arrive against the same stale base and the instruments
// clipped to a component lag behind it and then jump.
+ // Corners go with a group. A line routed by hand keeps its corners where
+ // they were put, in absolute coordinates, which is right when one end
+ // moves -- the corner is a decision about where the pipe runs. But when
+ // *both* ends move together, as a box-selected bay does, the corners
+ // between them are part of what was picked up, and leaving them behind
+ // turns every hand-routed line in the selection into a zigzag.
+ const movedIds = new Set();
+ for (const c of changes) if (c.type === 'position' && c.position) movedIds.add(c.id);
+ const shifts = new Map();
setNodes(current => {
const before = new Map(current.map(n => [n.id, n.position]));
let next = applyNodeChanges(changes, current);
@@ -674,7 +689,7 @@ function PIDCanvas({
const along = moved && isJunction(moved) ? junctionData(moved).along : undefined;
if (moved && along) {
const slid = slideAlong(
- moved, along, c.position, snapshot.current.edges, new Map(next.map(n => [n.id, n])), endOf);
+ moved, along, c.position, snapshot.current.edges, new Map(next.map(n => [n.id, n])), endOfClear);
if (slid) {
next = next.map(n => n.id === c.id
? { ...n, position: slid.position, data: { ...n.data, along: slid.along } }
@@ -683,7 +698,7 @@ function PIDCanvas({
}
const now = next.find(n => n.id === c.id)?.position ?? c.position;
const delta = { x: now.x - from.x, y: now.y - from.y };
- if (delta.x || delta.y) next = dragAttached(next, c.id, delta);
+ if (delta.x || delta.y) { next = dragAttached(next, c.id, delta); shifts.set(c.id, delta); }
continue;
}
// A resize arrives as a `dimensions` change, and React Flow records it
@@ -719,7 +734,22 @@ function PIDCanvas({
}
return next;
});
- }, [setNodes]);
+ if (movedIds.size > 1) {
+ setEdges(eds => {
+ let changed = false;
+ const out = eds.map(e => {
+ const a = shifts.get(e.source), b = shifts.get(e.target);
+ const pts = (e.data as { waypoints?: Pt[] } | undefined)?.waypoints;
+ if (!a || !b || !pts?.length) return e;
+ // The same shift on both ends, or the corners cannot follow.
+ if (Math.abs(a.x - b.x) > 1e-6 || Math.abs(a.y - b.y) > 1e-6) return e;
+ changed = true;
+ return { ...e, data: { ...e.data, waypoints: pts.map(p => ({ x: p.x + a.x, y: p.y + a.y })) } };
+ });
+ return changed ? out : eds;
+ });
+ }
+ }, [setNodes, setEdges, endOfClear]);
/** Bring one component into view without changing the zoom people chose. */
const fitViewTo = useCallback(async (node: Node) => {
@@ -858,20 +888,33 @@ function PIDCanvas({
} else if (n && handleEl?.dataset.handleid) {
target = { id: n.id, handle: handleEl.dataset.handleid };
} else if (n) {
- // The nearest of its ports to where the pointer let go.
+ // The port of it that the line reaches best: by the route, not by
+ // distance to the pointer. A port that faces away from the tee is
+ // near and wrong -- the line has to go round the symbol to enter
+ // it -- and the port on the far side that faces the tee is right.
const handles = getInternalNode(n.id)?.internals.handleBounds?.source ?? [];
- let best: { id: string; d: number } | null = null;
+ let best: { id: string; c: number } | null = null;
+ const from: End = source.kind === 'line'
+ ? (Math.abs(source.dir.x) >= Math.abs(source.dir.y)
+ ? { x: source.at.x, y: source.at.y + (at.y < source.at.y ? -J_ANCHOR : J_ANCHOR), side: at.y < source.at.y ? Position.Top : Position.Bottom, ...J_END }
+ : { x: source.at.x + (at.x < source.at.x ? -J_ANCHOR : J_ANCHOR), y: source.at.y, side: at.x < source.at.x ? Position.Left : Position.Right, ...J_END })
+ : { x: source.at.x, y: source.at.y, side: Position.Top, ...J_END };
for (const h of handles) {
- const hx = n.position.x + h.x + h.width / 2, hy = n.position.y + h.y + h.height / 2;
- const d = Math.hypot(hx - at.x, hy - at.y);
- if (!best || d < best.d) best = { id: h.id ?? '', d };
+ const to = endOf(n, h.id);
+ if (!to) continue;
+ const pts = pathPointsOf(routeOrthogonal(from, to).d);
+ const c = polylineLength(pts) + 12 * Math.max(0, pts.length - 2);
+ if (!best || c < best.c) best = { id: h.id ?? '', c };
}
if (best) target = { id: n.id, handle: best.id };
}
}
if (!target) {
const hit = lineAt(drawnLines(), at, 14, sourceEdgeId ?? undefined);
- if (hit) {
+ // Not onto a line the tee itself is on: a branch from a tee back into
+ // its own run is a loop with nothing in it.
+ const own = hit && sourceNodeId && es.some(e => e.id === hit.id && (e.source === sourceNodeId || e.target === sourceNodeId));
+ if (hit && !own) {
const split = splitEdgeAt(ns, es, hit.id, hit.at, page, { points: hit.points });
if (!split) return;
ns = split.nodes; es = split.edges;
@@ -1189,10 +1232,10 @@ function PIDCanvas({
if (burst.length === 31) console.warn('pid-designer: tees would not settle; leaving them where they are');
return;
}
- const re = reseatJunctions(nodes, edges, endOf);
+ const re = reseatJunctions(nodes, edges, endOfClear);
if (re.nodes !== nodes) setNodes(re.nodes);
if (re.edges !== edges) setEdges(re.edges);
- }, [nodes, edges, endOf, setNodes, setEdges, nodesReady]);
+ }, [nodes, edges, endOfClear, setNodes, setEdges, nodesReady]);
const onNodeClick = useCallback((e: React.MouseEvent, node: Node) => {
if (paintIfArmed('node', node.id)) { e.stopPropagation(); e.preventDefault(); }
diff --git a/pid-designer/frontend/src/components/pid/hops.test.ts b/pid-designer/frontend/src/components/pid/hops.test.ts
index 26b06b865..fdaacaa39 100644
--- a/pid-designer/frontend/src/components/pid/hops.test.ts
+++ b/pid-designer/frontend/src/components/pid/hops.test.ts
@@ -16,7 +16,7 @@ describe('where lines cross', () => {
it('is not a line ending on another, or one running alongside', () => {
expect(crossingsOf([P(100, 0), P(100, 100)], [horizontal])).toEqual([]); // ends on it: a tee's business
expect(crossingsOf([P(100, 0), P(100, 200)], [[P(100, 50), P(100, 150)]])).toEqual([]);
- expect(crossingsOf([P(100, 0), P(100, 200)], [[P(97, 100), P(200, 100)]])).toEqual([]); // too near its end for a hop
+ expect(crossingsOf([P(100, 0), P(100, 200)], [[P(93, 100), P(200, 100)]])).toEqual([]); // too near its end for a hop
});
it('is drawn as a semicircle in the line, bulging the same way both ways up', () => {
diff --git a/pid-designer/frontend/src/components/pid/hops.ts b/pid-designer/frontend/src/components/pid/hops.ts
index 0194618f9..9e5610655 100644
--- a/pid-designer/frontend/src/components/pid/hops.ts
+++ b/pid-designer/frontend/src/components/pid/hops.ts
@@ -41,7 +41,9 @@ export function crossingsOf(mine: Pt[], others: Pt[][], r = HOP_R): Pt[] {
if (Math.abs(a.y - b.y) > EPS) continue; // over horizontal ones
const y = a.y;
const x1 = Math.min(a.x, b.x), x2 = Math.max(a.x, b.x);
- if (x > x1 + r && x < x2 - r && y > y1 + r && y < y2 - r) out.push({ x, y });
+ // Two radii from any corner or end: a hop that touches a corner
+ // reads as the line failing to turn.
+ if (x > x1 + 2 * r && x < x2 - 2 * r && y > y1 + 2 * r && y < y2 - 2 * r) out.push({ x, y });
}
}
}
diff --git a/pid-designer/frontend/src/components/pid/junctions.test.ts b/pid-designer/frontend/src/components/pid/junctions.test.ts
index 7b1920f79..535b63cd9 100644
--- a/pid-designer/frontend/src/components/pid/junctions.test.ts
+++ b/pid-designer/frontend/src/components/pid/junctions.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { Position } from '@xyflow/react';
import type { Edge, Node } from '@xyflow/react';
import { branchFace, junctionEnd, reseatJunctions, runFaces, slideAlong } from './junctions';
+import { pathPoints, routeOrthogonal } from './route';
import type { Along, EndLookup } from './junctions';
import { insertInline, splitEdgeAt } from './splitEdge';
import type { Pt } from './route';
@@ -195,3 +196,93 @@ describe('a tee that rides its run', () => {
expect(slid.along.t).toBeCloseTo((300 - 60) / 340);
});
});
+
+describe('the faces a branch takes', () => {
+ /** Two horizontal runs, one tee on each, joined by a branch. */
+ function twoRuns(dy: number, dx = 200) {
+ const nodes: Node[] = [
+ part('A', 0, 0), part('B', 600, 0),
+ part('C', 0, dy), part('D', 600, dy),
+ ];
+ const edges: Edge[] = [
+ { id: 'A-B', source: 'A', sourceHandle: 'r', target: 'B', targetHandle: 'l', type: 'smoothstep', data: {} },
+ { id: 'C-D', source: 'C', sourceHandle: 'r', target: 'D', targetHandle: 'l', type: 'smoothstep', data: {} },
+ ];
+ const s1 = splitEdgeAt(nodes, edges, 'A-B', P(200, 30), undefined, { a: endOf(nodes[0], 'r')!, b: endOf(nodes[1], 'l')! })!;
+ const s2 = splitEdgeAt(s1.nodes, s1.edges, 'C-D', P(200 + dx, 30 + dy), undefined, { a: endOf(nodes[2], 'r')!, b: endOf(nodes[3], 'l')! })!;
+ const branch: Edge = { id: 'br', source: s1.junctionId, sourceHandle: 'b', target: s2.junctionId, targetHandle: 't', type: 'smoothstep', data: {} };
+ return { nodes: s2.nodes, edges: [...s2.edges, branch], j1: s1.junctionId, j2: s2.junctionId };
+ }
+ const faces = (edges: Edge[]) => { const b = edges.find(e => e.id === 'br')!; return [b.sourceHandle, b.targetHandle]; };
+
+ it('takes the same face on both tees when their runs are nearly level, not opposite ones', () => {
+ // The knot: t on one and b on the other joins by an S over one run and
+ // under the other. Both up (or both down) is a hook.
+ const { nodes, edges } = twoRuns(-5);
+ const re = reseatJunctions(nodes, edges, endOf);
+ const [fs, ft] = faces(re.edges);
+ expect(fs).toBe(ft);
+ expect(['t', 'b']).toContain(fs);
+ });
+
+ it('joins two tees thirty pixels apart with one crossbar, not a detour round both', () => {
+ // A symbol's stub is sixteen; two of them left no room in thirty, and
+ // the router went round. A tee's stub is six.
+ const { nodes, edges } = twoRuns(-30);
+ const re = reseatJunctions(nodes, edges, endOf);
+ expect(faces(re.edges)).toEqual(['t', 'b']);
+ const b = re.edges.find(e => e.id === 'br')!;
+ const j1 = re.nodes.find(n => n.id === b.source)!, j2 = re.nodes.find(n => n.id === b.target)!;
+ const d = routeOrthogonal(junctionEnd(j1.position, 't'), junctionEnd(j2.position, 'b')).d;
+ expect(pathPoints(d)).toHaveLength(4); // out, across, in
+ });
+
+ it('takes opposite faces when one run is well above the other', () => {
+ const { nodes, edges } = twoRuns(-200);
+ const re = reseatJunctions(nodes, edges, endOf);
+ expect(faces(re.edges)).toEqual(['t', 'b']);
+ });
+
+ it('never takes a face the run itself uses', () => {
+ const { nodes, edges } = twoRuns(-200);
+ const wrong = edges.map(e => (e.id === 'br' ? { ...e, sourceHandle: 'l', targetHandle: 'r' } : e));
+ const re = reseatJunctions(nodes, wrong, endOf);
+ const [fs, ft] = faces(re.edges);
+ expect(['t', 'b']).toContain(fs);
+ expect(['t', 'b']).toContain(ft);
+ });
+
+ it('keeps the faces it has when nothing has changed', () => {
+ const { nodes, edges } = twoRuns(-200);
+ const once = reseatJunctions(nodes, edges, endOf);
+ const twice = reseatJunctions(once.nodes, once.edges, endOf);
+ expect(twice.edges).toBe(once.edges);
+ });
+
+ it('chooses an open end\'s face from all four, by the route', () => {
+ const nodes: Node[] = [part('A', 0, 0), { id: 'o', type: 'JUNCTION', position: { x: 295, y: 25 }, measured: { width: 10, height: 10 }, data: { componentType: 'JUNCTION', label: 'o' } }];
+ const edges: Edge[] = [{ id: 'A-o', source: 'A', sourceHandle: 'r', target: 'o', targetHandle: 'b', type: 'smoothstep', data: {} }];
+ const re = reseatJunctions(nodes, edges, endOf);
+ // Level with A's right-hand port and to its right: enter by the left face.
+ expect(re.edges[0].targetHandle).toBe('l');
+ });
+});
+
+describe('a branch between two tees on runs at right angles', () => {
+ it('is the two-corner route, not a four-corner one', () => {
+ // A tee on a horizontal run at (200, 30); a vertical run 40 px to the
+ // right with a tee at (240, -10). Up and right is 64 px; the other
+ // three combinations go round.
+ const nodes: Node[] = [part('A', 0, 0), part('B', 400, 0), part('C', 210, -300), part('D', 210, 300)];
+ const edges: Edge[] = [
+ { id: 'A-B', source: 'A', sourceHandle: 'r', target: 'B', targetHandle: 'l', type: 'smoothstep', data: {} },
+ { id: 'C-D', source: 'C', sourceHandle: 'b', target: 'D', targetHandle: 't', type: 'smoothstep', data: {} },
+ ];
+ const s1 = splitEdgeAt(nodes, edges, 'A-B', P(200, 30), undefined, { a: endOf(nodes[0], 'r')!, b: endOf(nodes[1], 'l')! })!;
+ const s2 = splitEdgeAt(s1.nodes, s1.edges, 'C-D', P(240, -10), undefined, { a: endOf(nodes[2], 'b')!, b: endOf(nodes[3], 't')! })!;
+ const branch: Edge = { id: 'br', source: s1.junctionId, sourceHandle: 'b', target: s2.junctionId, targetHandle: 'r', type: 'smoothstep', data: {} };
+ const re = reseatJunctions(s2.nodes, [...s2.edges, branch], endOf);
+ const b = re.edges.find(e => e.id === 'br')!;
+ expect([b.sourceHandle, b.targetHandle]).toEqual(['t', 'l']);
+ });
+});
diff --git a/pid-designer/frontend/src/components/pid/junctions.ts b/pid-designer/frontend/src/components/pid/junctions.ts
index f93310224..2384b680e 100644
--- a/pid-designer/frontend/src/components/pid/junctions.ts
+++ b/pid-designer/frontend/src/components/pid/junctions.ts
@@ -1,7 +1,7 @@
import type { Edge, Node, XYPosition } from '@xyflow/react';
import { Position } from '@xyflow/react';
import {
- faceTowards, nearestOnPolyline, pathPoints, pointAt, routeOrthogonal, routeThrough,
+ faceTowards, nearestOnPolyline, pathPoints, pointAt, polylineLength, routeOrthogonal, routeThrough,
} from './route';
import type { End, Pt } from './route';
import type { PIDNodeData } from './types';
@@ -99,10 +99,17 @@ const SIDE_OF: Record = {
*/
export const J_ANCHOR = J_HALF + 3;
+/** What a route has to clear to get round a tee: the dot and a little. */
+export const J_CLEAR = 14;
+/** How far a line runs straight out of a tee before it may turn. */
+export const J_STUB = 6;
+/** The routing an end on a tee carries, measured or not. */
+export const J_END = { clear: J_CLEAR, stub: J_STUB } as const;
+
export function junctionEnd(position: XYPosition, face: Face): End {
const c = { x: position.x + J_HALF, y: position.y + J_HALF };
const off: Record = { t: { x: 0, y: -J_ANCHOR }, b: { x: 0, y: J_ANCHOR }, l: { x: -J_ANCHOR, y: 0 }, r: { x: J_ANCHOR, y: 0 } };
- return { x: c.x + off[face].x, y: c.y + off[face].y, side: SIDE_OF[face] };
+ return { x: c.x + off[face].x, y: c.y + off[face].y, side: SIDE_OF[face], ...J_END };
}
export const centreOfJunction = (n: Node): Pt => ({ x: n.position.x + J_HALF, y: n.position.y + J_HALF });
@@ -261,11 +268,13 @@ export function repointJunction(
face = faces.out;
if (corners) { const c = withRunCorners(e, corners.downstream); if (c !== e) { changed = true; e = c; } }
} else {
- const otherId = at.end === 'source' ? e.target : e.source;
- const other = nodesById.get(otherId);
- const { w, h } = other ? nodeSize(other) : { w: 0, h: 0 };
- const otherC = other ? { x: other.position.x + w / 2, y: other.position.y + h / 2 } : centre;
- face = branchFace(dir, otherC, centre);
+ // A branch. It only has to be off the run's two faces here; which of
+ // the other two it takes is `pointLines`' decision, made from the
+ // route each would produce rather than from where a centre is.
+ const cur = at.handle as Face | null | undefined;
+ const across = ACROSS[faces.in];
+ face = cur && across.includes(cur) ? cur : across[0];
+ void nodesById; void centre;
}
const next = withHandle(e, at.end, face);
if (next !== e) changed = true;
@@ -274,6 +283,89 @@ export function repointJunction(
return { edges: changed ? out : edges, along: { ...along, in: faces.in, out: faces.out } };
}
+/** The two faces across a run, given the face it enters by. */
+const ACROSS: Record = { l: ['t', 'b'], r: ['t', 'b'], t: ['l', 'r'], b: ['l', 'r'] };
+
+/**
+ * The faces a line may take at a tee: the two across its run, or all four
+ * of an open end. Null for anything that is not a tee -- a symbol's port is
+ * drawn where it is drawn, and is not a choice.
+ */
+function candidateFaces(n: Node | undefined): Face[] | null {
+ if (!n || !isJunction(n)) return null;
+ const along = junctionData(n).along;
+ return along ? ACROSS[along.in] : ['t', 'b', 'l', 'r'];
+}
+
+/** The cost of drawing a line: its length, and a little for every corner. */
+function cost(a: End, b: End, corners: Pt[]): number {
+ const route = corners.length ? routeThrough(a, b, corners) : routeOrthogonal(a, b);
+ const pts = pathPoints(route.d);
+ return polylineLength(pts) + 12 * Math.max(0, pts.length - 2);
+}
+
+/**
+ * Point every line that touches a tee at the faces that draw it best.
+ *
+ * This is what stops the knots. A branch's face used to be picked by
+ * which side of the tee the other end's centre was on -- and for two tees
+ * on runs at nearly the same height that put `t` on one and `b` on the
+ * other, which the router can only join with a five-segment S over one
+ * run and under the other. Both `t` is a three-segment hook. So the
+ * faces of a line are chosen together, by trying each combination and
+ * keeping the shortest route with the fewest corners. The current faces
+ * win a tie, so nothing flips between two equal answers.
+ *
+ * Run lines are not touched: they are the run's, and `repointJunction`
+ * has already set them.
+ */
+export function pointLines(edges: Edge[], nodesById: Map, endOf: EndLookup): Edge[] {
+ let changed = false;
+ const out = edges.map(e => {
+ const s = nodesById.get(e.source), t = nodesById.get(e.target);
+ const sc = candidateFaces(s), tc = candidateFaces(t);
+ if (!sc && !tc) return e;
+ // A run line: the tee's in or out face. Not a choice.
+ const isRun = (n: Node | undefined, handle: string | null | undefined) => {
+ const along = n && isJunction(n) ? junctionData(n).along : undefined;
+ return !!along && (handle === along.in || handle === along.out);
+ };
+ if (isRun(s, e.sourceHandle) || isRun(t, e.targetHandle)) return e;
+
+ const endFor = (n: Node | undefined, handle: string | null | undefined): End | null => {
+ if (!n) return null;
+ if (isJunction(n)) return handle ? junctionEnd(n.position, handle as Face) : null;
+ const m = endOf(n, handle);
+ if (m) return m;
+ return null; // an unmeasured symbol port: no basis for a choice
+ };
+ const corners = handCornersOf(e);
+ const sOpts = sc ?? [e.sourceHandle as Face];
+ const tOpts = tc ?? [e.targetHandle as Face];
+ let best: { s: Face; t: Face; c: number } | null = null;
+ for (const fs of sOpts) {
+ const a = endFor(s, fs);
+ if (!a) return e;
+ for (const ft of tOpts) {
+ const b = endFor(t, ft);
+ if (!b) return e;
+ let c = cost(a, b, corners);
+ if (fs === e.sourceHandle && ft === e.targetHandle) c -= 1e-6;
+ if (!best || c < best.c) best = { s: fs, t: ft, c };
+ }
+ }
+ if (!best || (best.s === e.sourceHandle && best.t === e.targetHandle)) return e;
+ changed = true;
+ return { ...e, sourceHandle: best.s, targetHandle: best.t };
+ });
+ return changed ? out : edges;
+}
+
+const handCornersOf = (e: Edge): Pt[] => {
+ const d = (e.data ?? {}) as { waypoints?: Pt[]; viaRun?: boolean };
+ return d.viaRun ? [] : (d.waypoints ?? []);
+};
+
/**
* Where a tee dragged to `p` may actually go: the nearest point of its run,
* and the fraction that puts it there.
@@ -311,7 +403,11 @@ export function reseatJunctions(
): { nodes: Node[]; edges: Edge[] } {
const byId = new Map(nodes.map(n => [n.id, n]));
const riding = nodes.filter(n => isJunction(n) && !!junctionData(n).along).map(n => n.id);
- if (riding.length === 0) return { nodes, edges };
+ if (riding.length === 0) {
+ // No tee rides a run, but an open end still has a line to point.
+ const pointed = nodes.some(isJunction) ? pointLines(edges, byId, endOf) : edges;
+ return { nodes, edges: pointed };
+ }
let outNodes = nodes;
let outEdges = edges;
@@ -379,6 +475,7 @@ export function reseatJunctions(
for (const id of riding) if (junctionData(byId.get(id)!).along && seat(id)) anyMoved = true;
if (!anyMoved) break;
}
+ outEdges = pointLines(outEdges, byId, endOf);
return { nodes: outNodes, edges: outEdges };
}
diff --git a/pid-designer/frontend/src/components/pid/nodes/JunctionNode.tsx b/pid-designer/frontend/src/components/pid/nodes/JunctionNode.tsx
index 37b581d9c..648a9e80f 100644
--- a/pid-designer/frontend/src/components/pid/nodes/JunctionNode.tsx
+++ b/pid-designer/frontend/src/components/pid/nodes/JunctionNode.tsx
@@ -68,25 +68,37 @@ export function JunctionNode({ id, selected }: NodeProps) {
size of a symbol. */}
- {/* The ring: pull it to draw a line out of the tee. `nodrag` keeps a
- press on it from moving the tee instead. */}
+ {/* The ring: pull it to draw a line out of the tee.
+
+ An SVG stroke, not a box. It was a 24 px div over the dot, and a
+ div takes the pointer over its whole square -- so once somebody
+ had hovered, pressing on the dot itself started a pull instead of
+ a drag, which is the inversion this ring exists to remove. With
+ `pointer-events: stroke` only the ring itself is pressable; the
+ dot underneath still drags. `nodrag` keeps the press from moving
+ the tee as well. */}
{!readOnly && (hover || selected) && (
-
{
- if (e.button !== 0) return;
- e.stopPropagation();
- e.preventDefault();
- const pos = getInternalNode(id)?.internals.positionAbsolute;
- if (!pos) return;
- begin({ kind: 'node', nodeId: id, at: { x: pos.x + J_HALF, y: pos.y + J_HALF } }, e);
- }}
- style={{
- position: 'absolute', left: -9, top: -9, width: 24, height: 24, borderRadius: '50%',
- border: `1.5px dashed ${ink}`, cursor: 'crosshair', boxSizing: 'border-box',
- }}
- />
+
)}
diff --git a/pid-designer/frontend/src/components/pid/route.ts b/pid-designer/frontend/src/components/pid/route.ts
index 8e0079d05..317bfbba2 100644
--- a/pid-designer/frontend/src/components/pid/route.ts
+++ b/pid-designer/frontend/src/components/pid/route.ts
@@ -20,6 +20,21 @@ export interface End {
x: number;
y: number;
side: Position;
+ /**
+ * How far to the side a detour has to go to clear whatever this end is
+ * on. Unset means a symbol (see `CLEAR`). A tee is a ten-pixel dot and
+ * says so, or every branch between two tees that had to go round went
+ * round by a symbol's width.
+ */
+ clear?: number;
+ /**
+ * How far a line runs straight out of this end before it may turn.
+ * Unset means a symbol's port (see `STUB`). A tee's is shorter: two tees
+ * on runs thirty pixels apart otherwise had no room between their two
+ * sixteen-pixel stubs for the one crossbar that joins them, and the
+ * router went round both instead.
+ */
+ stub?: number;
}
export interface Route {
@@ -74,6 +89,8 @@ export function routeOrthogonal(a: End, b: End, offset = 0): Route {
const bH = isHorizontal(b.side);
const as = facing(a.side);
const bs = facing(b.side);
+ const sa = a.stub ?? STUB;
+ const sb = b.stub ?? STUB;
const dx = Math.abs(a.x - b.x);
const dy = Math.abs(a.y - b.y);
@@ -111,8 +128,8 @@ export function routeOrthogonal(a: End, b: End, offset = 0): Route {
}
// Otherwise stub out of both ends first and join the stubs. Four segments,
// and every one of them leaves an end the way that end points.
- const hx = h.x + hs * STUB;
- const vy = v.y + vs * STUB;
+ const hx = h.x + hs * (aH ? sa : sb);
+ const vy = v.y + vs * (aH ? sb : sa);
const d = aH
? `M ${a.x},${a.y} L ${hx},${a.y} L ${hx},${vy} L ${b.x},${vy} L ${b.x},${b.y}`
: `M ${a.x},${a.y} L ${a.x},${vy} L ${hx},${vy} L ${hx},${b.y} L ${b.x},${b.y}`;
@@ -129,34 +146,35 @@ export function routeOrthogonal(a: End, b: End, offset = 0): Route {
// Two ends pointing the same way and nearly in line have to double back,
// and doing that at the same coordinate draws the return leg through the
// symbol. Send those round the side instead.
+ const clear = Math.max(a.clear ?? CLEAR, b.clear ?? CLEAR);
const perp = aH ? Math.abs(a.y - b.y) : Math.abs(a.x - b.x);
- const doublesBack = as === bs && perp < CLEAR;
+ const doublesBack = as === bs && perp < clear;
if (aH) {
- const mid = doublesBack ? null : crossbar(a.x, as, b.x, bs, offset);
+ const mid = doublesBack ? null : crossbar(a.x, as, b.x, bs, offset, sa, sb);
if (mid) {
const d = `M ${a.x},${a.y} L ${mid.at},${a.y} L ${mid.at},${b.y} L ${b.x},${b.y}`;
return { d, grip: mid.free ? { x: mid.at, y: (a.y + b.y) / 2 } : null };
}
// Facing apart, with nothing between them: out of both ends, and round.
- const ax = a.x + as * STUB;
- const bx = b.x + bs * STUB;
- const my = aside(a.y, b.y);
+ const ax = a.x + as * sa;
+ const bx = b.x + bs * sb;
+ const my = aside(a.y, b.y, clear);
return {
d: `M ${a.x},${a.y} L ${ax},${a.y} L ${ax},${my} L ${bx},${my} L ${bx},${b.y} L ${b.x},${b.y}`,
grip: null,
};
}
- const mid = doublesBack ? null : crossbar(a.y, as, b.y, bs, offset);
+ const mid = doublesBack ? null : crossbar(a.y, as, b.y, bs, offset, sa, sb);
if (mid) {
return {
d: `M ${a.x},${a.y} L ${a.x},${mid.at} L ${b.x},${mid.at} L ${b.x},${b.y}`,
grip: mid.free ? { x: (a.x + b.x) / 2, y: mid.at } : null,
};
}
- const ay = a.y + as * STUB;
- const by = b.y + bs * STUB;
- const mx = aside(a.x, b.x);
+ const ay = a.y + as * sa;
+ const by = b.y + bs * sb;
+ const mx = aside(a.x, b.x, clear);
return {
d: `M ${a.x},${a.y} L ${a.x},${ay} L ${mx},${ay} L ${mx},${by} L ${b.x},${by} L ${b.x},${b.y}`,
grip: null,
@@ -169,10 +187,10 @@ export function routeOrthogonal(a: End, b: End, offset = 0): Route {
* Their midpoint, unless they share it -- two symbols stacked exactly would
* otherwise get a "detour" that retraces the line it just drew.
*/
-function aside(a: number, b: number): number {
+function aside(a: number, b: number, clear = CLEAR): number {
// Far apart, the midpoint is between the two symbols and clear of both.
// Close together, it is *inside* them, so go round instead.
- return Math.abs(a - b) > 2 * CLEAR ? (a + b) / 2 : Math.max(a, b) + CLEAR;
+ return Math.abs(a - b) > 2 * clear ? (a + b) / 2 : Math.max(a, b) + clear;
}
/**
@@ -185,19 +203,20 @@ function aside(a: number, b: number): number {
* and the crossbar is pinned just past the further of them.
*/
function crossbar(
- a: number, as: number, b: number, bs: number, offset: number,
+ a: number, as: number, b: number, bs: number, offset: number, sa = STUB, sb = STUB,
): { at: number; free: boolean } | null {
// Pointing at each other with room between: anywhere in the gap works, so
// the midpoint is the default and the reader may slide it.
- if (as > 0 && bs < 0 && b - a > 2 * STUB) {
+ if (as > 0 && bs < 0 && b - a > sa + sb) {
return { at: (a + b) / 2 + offset, free: true };
}
- if (as < 0 && bs > 0 && a - b > 2 * STUB) {
+ if (as < 0 && bs > 0 && a - b > sa + sb) {
return { at: (a + b) / 2 + offset, free: true };
}
// Pointing the same way: one side of both ends works. Out past the further.
if (as === bs) {
- return { at: as > 0 ? Math.max(a, b) + STUB : Math.min(a, b) - STUB, free: false };
+ const s = Math.max(sa, sb);
+ return { at: as > 0 ? Math.max(a, b) + s : Math.min(a, b) - s, free: false };
}
// Pointing apart, or at each other with no room. No single crossbar can be
// ahead of both, and pretending otherwise is what drew a line back through
@@ -317,9 +336,10 @@ export function direction(a: Pt, b: Pt): Pt | null {
/** The point a port's stub ends at: `STUB` out of the port, the way it faces. */
export function stubOf(e: End): Pt {
+ const s = e.stub ?? STUB;
return isHorizontal(e.side)
- ? { x: e.x + facing(e.side) * STUB, y: e.y }
- : { x: e.x, y: e.y + facing(e.side) * STUB };
+ ? { x: e.x + facing(e.side) * s, y: e.y }
+ : { x: e.x, y: e.y + facing(e.side) * s };
}
/**
diff --git a/pid-designer/frontend/src/components/pid/routing.test.ts b/pid-designer/frontend/src/components/pid/routing.test.ts
index d268fc21b..763f20b07 100644
--- a/pid-designer/frontend/src/components/pid/routing.test.ts
+++ b/pid-designer/frontend/src/components/pid/routing.test.ts
@@ -133,3 +133,16 @@ describe('a point along a run', () => {
expect(nearestOnPolyline(run, P(104, -3))!.point).toEqual(P(100, 0));
});
});
+
+describe('what a route has to clear', () => {
+ it('goes round a tee by the tee, not by a symbol', () => {
+ // Two upward ends nearly in line, both on tees: the return leg steps
+ // aside by a tee's clearance, not a symbol's forty-four.
+ const tee = (x: number, y: number): import('./route').End => ({ x, y, side: Position.Top, clear: 14 });
+ const d = routeThrough(tee(100, 100), tee(110, 100), []).d;
+ const xs = pathPoints(d).map(p => p.x);
+ expect(Math.max(...xs)).toBeLessThan(100 + 44);
+ const dSymbol = routeThrough({ x: 100, y: 100, side: Position.Top }, { x: 110, y: 100, side: Position.Top }, []).d;
+ void dSymbol;
+ });
+});
From 6cfcd55bb67017da5eebd63dc05b54fc12fb5569 Mon Sep 17 00:00:00 2001
From: Carlsaurus
Date: Sun, 20 Sep 2026 16:45:55 -0700
Subject: [PATCH 21/24] Stability reports the rate-limiting stream, and Forward
Mode forgets the last engine
The working state left after the 180 lb re-cut, committed as it stood.
Three reporting defects sat downstream of the physics and survived the
earlier pass, because each is correct on methalox and only wrong on a
propellant whose fuel is the slower vaporizer: the vaporization card,
radar and SMD slider were oxidizer-only and now follow the rate-limiting
stream; `fallbacks_used` accumulated across runs and propellants and is
now scoped per report; and Forward Mode kept the previous propellant's
stability panel on screen, so results and sensitivity overrides are
cleared when the engine identity changes (`lib/engineIdentity.ts`).
Section 4b of the chug note records all three.
Alongside: the optimizer router and Layer 1 carry the design-requirement
merge and a reproducible hybrid seed, the time-varying solver takes an
ambient, and two 6500 N ethalox configs join the shipped set. Each has a
test.
---
EngineDesign/backend/routers/evaluate.py | 4 +-
EngineDesign/backend/routers/optimizer.py | 61 +-
EngineDesign/configs/default.yaml | 11 +-
EngineDesign/configs/ethalox_180lb_8to1.yaml | 6 +-
EngineDesign/configs/ethalox_6500N.yaml | 682 ++++++++++++++++++
.../configs/ethalox_6500N_375psi.yaml | 669 +++++++++++++++++
.../docs/stability/chug-double-time-lag.md | 57 +-
EngineDesign/engine/core/runner.py | 6 +-
.../layers/layer1_static_optimization.py | 52 +-
EngineDesign/engine/pipeline/assumptions.py | 56 +-
.../engine/pipeline/config_schemas.py | 10 +-
.../engine/pipeline/stability/analysis.py | 17 +-
.../engine/pipeline/stability/report.py | 157 ++--
.../engine/pipeline/time_varying_solver.py | 29 +-
.../frontend/src/components/ForwardMode.tsx | 28 +
.../src/components/Layer1Optimization.tsx | 43 +-
.../components/stability/StabilityPanel.tsx | 40 +-
.../stability/VaporizationProfile.tsx | 84 ++-
.../src/components/stability/types.ts | 20 +
.../frontend/src/lib/engineIdentity.test.ts | 63 ++
.../frontend/src/lib/engineIdentity.ts | 26 +
.../tests/test_design_requirements_merge.py | 55 ++
.../tests/test_hybrid_seed_reproducible.py | 63 ++
.../tests/test_time_varying_ambient.py | 76 ++
24 files changed, 2205 insertions(+), 110 deletions(-)
create mode 100644 EngineDesign/configs/ethalox_6500N.yaml
create mode 100644 EngineDesign/configs/ethalox_6500N_375psi.yaml
create mode 100644 EngineDesign/frontend/src/lib/engineIdentity.test.ts
create mode 100644 EngineDesign/frontend/src/lib/engineIdentity.ts
create mode 100644 EngineDesign/tests/test_design_requirements_merge.py
create mode 100644 EngineDesign/tests/test_hybrid_seed_reproducible.py
create mode 100644 EngineDesign/tests/test_time_varying_ambient.py
diff --git a/EngineDesign/backend/routers/evaluate.py b/EngineDesign/backend/routers/evaluate.py
index c3e82ac89..1f0237afd 100644
--- a/EngineDesign/backend/routers/evaluate.py
+++ b/EngineDesign/backend/routers/evaluate.py
@@ -20,7 +20,9 @@
class StabilityOverrides(BaseModel):
"""Optional forward-mode knobs for rich stability re-evaluation."""
eta_inj_O: float | None = Field(default=None, gt=0, le=0.6, description="Oxidizer ΔP_inj/Pc")
- smd_um: float | None = Field(default=None, gt=0, le=200, description="Oxidizer spray SMD [µm]")
+ smd_um: float | None = Field(default=None, gt=0, le=400, description="Oxidizer spray SMD [µm]")
+ smd_F_um: float | None = Field(default=None, gt=0, le=400, description="Fuel spray SMD [µm]")
+ eta_inj_F: float | None = Field(default=None, gt=0, le=0.6, description="Fuel ΔP_inj/Pc")
n_interaction: float | None = Field(default=None, gt=0, le=2, description="Combustion interaction index n")
chi_acoustic: float | None = Field(default=None, gt=0, le=1, description="Acoustic sensitive-fraction χ")
time_lag_model: Literal["leonardi_dtl", "d2_law"] | None = Field(
diff --git a/EngineDesign/backend/routers/optimizer.py b/EngineDesign/backend/routers/optimizer.py
index e7862d7d7..c4a61d3b7 100644
--- a/EngineDesign/backend/routers/optimizer.py
+++ b/EngineDesign/backend/routers/optimizer.py
@@ -42,6 +42,43 @@ def layer1_design_is_valid(results: Dict[str, Any]) -> tuple[bool, list]:
if (key.endswith("_check_passed") or key.endswith("_gate_passed")) and passed is False:
reasons.append(f"{key} = False")
return (not reasons), reasons
+
+
+def merge_design_requirements(
+ old: Optional[Dict[str, Any]], incoming: Dict[str, Any]
+) -> Dict[str, Any]:
+ """Lay a (possibly partial) requirements payload over the requirements already loaded.
+
+ A key ABSENT from ``incoming`` keeps its current value. A key sent as an explicit
+ ``None`` is cleared. Those are different intents -- a form that only knows a dozen
+ fields must not, by not mentioning them, reset the ~100 ``layer1_*`` knobs, the injector
+ face limits and the pinned seed to schema defaults. It did: the save route rebuilt
+ ``design_requirements`` from the payload alone, and the next run optimised a different
+ problem than the one the file described. ``frozen_parameters`` merges key by key with the
+ same null-clears rule, as it already did.
+ """
+ merged: Dict[str, Any] = dict(old or {})
+ prev_fp = merged.get("frozen_parameters")
+ old_fp: Dict[str, Any] = (
+ {k: v for k, v in prev_fp.items() if v is not None} if isinstance(prev_fp, dict) else {}
+ )
+ for key, value in incoming.items():
+ if key != "frozen_parameters":
+ merged[key] = value
+ if "frozen_parameters" in incoming:
+ new_fp = incoming.get("frozen_parameters")
+ fp = dict(old_fp)
+ for k, v in (new_fp.items() if isinstance(new_fp, dict) else ()):
+ if v is None:
+ fp.pop(k, None)
+ else:
+ fp[k] = v
+ merged["frozen_parameters"] = fp if fp else None
+ elif old_fp:
+ merged["frozen_parameters"] = old_fp
+ elif "frozen_parameters" in merged:
+ merged["frozen_parameters"] = None
+ return merged
from engine.pipeline.config_schemas import DesignRequirementsConfig
from engine.optimizer.layers.layer1_static_optimization import run_layer1_optimization
from engine.optimizer.layers.layer2_pressure import run_layer2_pressure
@@ -132,26 +169,12 @@ async def save_design_requirements(
)
try:
- # Merge frozen_parameters so a partial UI payload cannot silently drop YAML pins.
- req_in = dict(request.requirements)
+ # Overlay the payload on what is loaded; a partial payload must not reset the rest.
old_dr = session.app_state.config.design_requirements
- old_fp: dict = {}
- if old_dr is not None and old_dr.frozen_parameters is not None:
- old_fp = old_dr.frozen_parameters.model_dump(exclude_none=True)
- if "frozen_parameters" not in req_in:
- if old_fp:
- req_in["frozen_parameters"] = old_fp
- else:
- new_fp = req_in.get("frozen_parameters")
- if not isinstance(new_fp, dict):
- new_fp = {}
- merged_fp = dict(old_fp)
- for k, v in new_fp.items():
- if v is None:
- merged_fp.pop(k, None)
- else:
- merged_fp[k] = v
- req_in["frozen_parameters"] = merged_fp if merged_fp else None
+ req_in = merge_design_requirements(
+ old_dr.model_dump() if old_dr is not None else None,
+ dict(request.requirements),
+ )
# Validate requirements using Pydantic
requirements = DesignRequirementsConfig(**req_in)
diff --git a/EngineDesign/configs/default.yaml b/EngineDesign/configs/default.yaml
index ad6b8f8a0..7ee049b53 100644
--- a/EngineDesign/configs/default.yaml
+++ b/EngineDesign/configs/default.yaml
@@ -22,9 +22,14 @@ fluids:
specific_heat: 2300.0
thermal_conductivity: 0.15
temperature: 90.0
- latent_heat: null
- boiling_point: null
- molecular_weight: null
+ # Stability-only inputs (droplet vaporization lag); the forward performance path does not read
+ # them, so filling them in cannot move thrust/Isp or the golden anchors. Left null, the chug
+ # model silently substituted these same handbook numbers on every evaluation of the default
+ # config and reported three fallbacks it should never have needed.
+ latent_heat: 213000.0 # J/kg, NIST oxygen at 1 atm
+ boiling_point: 90.19 # K, NIST oxygen at 1 atm
+ molecular_weight: 32.0 # g/mol
+ bulk_modulus_pa: 1500000000.0 # order-of-magnitude LOX; refine via water-hammer test T5
injector:
type: impinging
geometry:
diff --git a/EngineDesign/configs/ethalox_180lb_8to1.yaml b/EngineDesign/configs/ethalox_180lb_8to1.yaml
index b89381be6..24e966d97 100644
--- a/EngineDesign/configs/ethalox_180lb_8to1.yaml
+++ b/EngineDesign/configs/ethalox_180lb_8to1.yaml
@@ -1,7 +1,11 @@
# CalSTAR ethalox -- 180 lb, 8:1, O/F 1.50, 24 doublets, MSA G1 45 scf COPV. 2026-09-15.
#
-# Reproduce: python3 scripts/layer1_run.py --config configs/ethalox_180lb_8to1.yaml
# Audit: python3 scripts/design_audit.py configs/ethalox_180lb_8to1.yaml
+# Re-run: python3 scripts/layer1_run.py --config configs/ethalox_180lb_8to1.yaml
+# THIS FILE is the design. A re-run is a new candidate, not a reproduction:
+# the hybrid search ignored layer1_random_seed until 2026-09-18, and the
+# objective is flat across the injectors it lands on (99.99 % of it is the
+# chamber-mass shaping term; every requirement term is ~0).
#
# HARD CONSTRAINTS, ALL EXACT
# wet mass 81.6466 kg = 180.0000 lb
diff --git a/EngineDesign/configs/ethalox_6500N.yaml b/EngineDesign/configs/ethalox_6500N.yaml
new file mode 100644
index 000000000..9f00cec8d
--- /dev/null
+++ b/EngineDesign/configs/ethalox_6500N.yaml
@@ -0,0 +1,682 @@
+# CalSTAR ethalox -- 180 lb wet, 6.500 kN, O/F 1.50, 24 doublets, MSA G1 COPV. 2026-09-16.
+#
+# Audit: python3 scripts/design_audit.py configs/ethalox_6500N.yaml
+# Re-run: python3 scripts/layer1_run.py --config configs/ethalox_6500N.yaml
+# THIS FILE is the design. A re-run is a new candidate, not a reproduction:
+# the hybrid search ignored layer1_random_seed until 2026-09-18 (three runs at
+# seed 37 gave 89 / 87 / 83 deg injectors), and the objective is flat across
+# those -- 99.99 % of it is the chamber-mass shaping term, every requirement
+# term is ~0 -- so which one a run lands on is not a quality difference.
+#
+# WHAT CHANGED FROM ethalox_180lb_8to1.yaml
+# Thrust 6405.5 -> 6500.0 N exactly, for margin. Nothing else was re-optimised: the
+# SMD blend, the 43/46 deg angles, 24 doublets on a 15.000 deg pitch, the 5.000 in
+# bore, eps 5.5985, L* 1.000 m and dP/Pc all carry over unchanged.
+# With Pc held, F = zeta_n*Cf_vac*Pc*At - Pa*Ae is linear in At, so the engine scales
+# by ONE factor k = 1.015457435, solved (not assumed) because Cf_vac moves with eps:
+# A_throat, A_exit, chamber volume x k -> eps and L* land back on their old values
+# d_jet (both streams) x sqrt(k) -> same injection velocity, same SMD, same dP
+# mdot follows At, so Pc = mdot*c*/At never moves and the injector keeps its schedule.
+# D_throat 43.85 -> 44.19 mm D_exit 103.76 -> 104.56 mm
+# d_jet 1.536 -> 1.548 mm (O) 1.411 -> 1.421 mm (F)
+# engine mass +142 g, taken out of the airframe to hold 180.000 lb wet.
+#
+# THE 16 L RULE, WITH THE COPV COUNTED INSIDE IT
+# COPV water volume 4.6190 L MSA G1 45 scf, specsheet, air backed out
+# liquid propellant 11.3810 L = 16.0000 - 4.6190
+# -> LOX 5.7995 L = 6.6114 kg ethanol 5.5815 L = 4.4038 kg at the DELIVERED O/F
+# The split is set from the delivered 1.5013, not the 1.5000 target, so both tanks
+# run dry in the same instant: residual 0.0 g on each side.
+#
+# TANKS AT 10 % ULLAGE (seamlesstanks 6.625 in OD; the length model is fit to their own
+# 24 in / 2.89 gal point: barrel 30.450 in^2, 2.076 in of end)
+# mass kg mass lb liquid L liquid gal TANK L TANK gal LENGTH in
+# LOX 6.6114 14.5757 5.7995 1.5321 6.4439 1.7023 14.99
+# ethanol 4.4038 9.7087 5.5815 1.4745 6.2017 1.6383 14.50
+# Buy 15 in of LOX tank and 14.5 in of fuel tank. The shells hold 12.6456 L between
+# them but only 11.3810 L of that is propellant at T-0, which is what the rule counts.
+#
+# BURN, on a FLAT dome-regulated tank curve at 584.27 psi through the time-varying solver
+# thrust 6500.0 -> 6601.9 N T/W 8.1181 -> 8.2454, mean 8.1848
+# Pc 433.65 -> 427.41 psia (-1.44 %)
+# mdot 2.7976 -> 2.8550 kg/s (+2.05 %)
+# The graphite throat recedes ~0.4 mm radially over the burn; with the regulator holding
+# tank pressure flat that RAISES dP across the injector, so mdot climbs and Pc sags.
+# A steady-state point at t=0 does not see this and under-loads the tanks by ~1 %.
+# BURN TIME 3.8978 s impulse 25544 N.s -- burn time is an OUTPUT of the integration
+# here, not m_prop/mdot at one operating point.
+#
+# APOGEE -- ceiling is 15000 ft
+# 12760 ft (3889.3 m) at the modelled eta_c* 0.9499, 2240 ft of margin.
+# Verified envelope-insensitive: tank fill 0.80 / 0.75 / 0.70 all return 3889.3 m.
+# Lower eta_c* only lowers it (~2240 ft per 0.10 of eta_c*), so the ceiling is not at
+# risk in any direction a real engine can go.
+# The 6.5 kN bump COSTS ~440 ft against the 6405 N build at identical propellant: a
+# shorter, harder burn spends more of its velocity low in the atmosphere. That is the
+# price of the thrust margin, and there is room for it.
+#
+# WHAT BINDS, AND THE ONLY LEVER LEFT
+# Propellant is capped by the 16 L rule, NOT by apogee -- there are 2240 unused feet.
+# Every litre taken out of the COPV is a litre of propellant, worth roughly 2000 ft.
+# The bottle currently runs 2.08x on deliverable gas (needs 0.528 kg, delivers ~1.098),
+# so a smaller bottle is arguable -- but it is a real part and its own analysis.
+#
+# PRESSURANT
+# Ground pre-pressurisation charges the initial ullage; the flight COPV only replaces
+# expelled liquid: 11.3810 L at 46.4 kg/m3 = 0.5281 kg. Independent of tank size.
+#
+# STILL REQUIRES HARDWARE
+# FLOW-TEST the injector. Cd 0.80 is a correlation, not a measurement.
+# NOTE: engine/pipeline/time_varying_solver.py hardcodes Pa = 101325 while this config
+# declares elevation 626.67 m (94070 Pa). The time-series numbers above were corrected
+# by hand for that (+61.4 N per step). Fix the solver before trusting its raw output.
+#
+propellant_preset: ethalox
+fluids:
+ fuel:
+ name: Ethanol
+ density: 789.0
+ viscosity: 0.0012
+ surface_tension: 0.0223
+ vapor_pressure: 5800.0
+ specific_heat: 2440.0
+ thermal_conductivity: 0.17
+ temperature: 293.0
+ latent_heat: 838000.0
+ boiling_point: 351.4
+ molecular_weight: 46.07
+ bulk_modulus_pa: 1060000000.0
+ critical_temperature: 514.71
+ injection_phase: null
+ oxidizer:
+ name: LOX
+ density: 1140.0
+ viscosity: 0.00018
+ surface_tension: 0.013
+ vapor_pressure: 101325.0
+ specific_heat: 2300.0
+ thermal_conductivity: 0.15
+ temperature: 90.0
+ latent_heat: 213000.0
+ boiling_point: 90.2
+ molecular_weight: 32.0
+ bulk_modulus_pa: 1500000000.0
+ critical_temperature: 154.6
+ injection_phase: null
+injector:
+ type: impinging
+ geometry:
+ oxidizer:
+ n_elements: 24
+ d_jet: 0.0015479747499138553
+ impingement_angle: 43.0
+ spacing: 0.009010387728636004
+ fuel:
+ n_elements: 24
+ d_jet: 0.0014213970733035063
+ impingement_angle: 46.0
+ spacing: 0.012046846972657375
+feed_system:
+ fuel:
+ line_size: 1/2_TUBE_035
+ d_inlet: 0.010922
+ A_hydraulic: 9.369021288512731e-05
+ K0: 2.019
+ K1: 0.0
+ phi_type: none
+ length: 0.9144
+ oxidizer:
+ line_size: 1/2_TUBE_035
+ d_inlet: 0.010922
+ A_hydraulic: 9.369021288512731e-05
+ K0: 0.643
+ K1: 0.0
+ phi_type: none
+ length: 0.1016
+regen_cooling:
+ enabled: false
+ d_inlet: 0.009525
+ L_inlet: 0.5
+ n_channels: 100
+ channel_width: 0.0009
+ channel_height: 0.001
+ channel_length: 0.18162
+ d_outlet: null
+ L_outlet: 0.1
+ roughness: 0.0
+ K_manifold_split: 0.5
+ K_manifold_merge: 0.3
+ Cd_entrance_inf: 0.8
+ a_Re_entrance: 0.1
+ Cd_entrance_min: 0.6
+ Cd_exit_inf: 0.9
+ a_Re_exit: 0.1
+ Cd_exit_min: 0.7
+ use_heat_transfer: true
+ wall_thickness: 0.002
+ wall_thermal_conductivity: 320.0
+ chamber_inner_diameter: 0.08491
+ hot_gas_prandtl: 0.7
+ hot_gas_viscosity: 4.0e-05
+ hot_gas_thermal_conductivity: 0.12
+ radiation_emissivity_hot: 0.85
+ radiation_view_factor: 1.0
+ n_segments: 20
+ gas_turbulence_intensity: 0.1
+ coolant_turbulence_intensity: 0.05
+ recovery_factor: null
+film_cooling:
+ enabled: false
+ mass_fraction: 0.05
+ injection_temperature: null
+ effectiveness_ref: 0.45
+ decay_length: 0.05
+ apply_to_fraction_of_length: 0.6
+ slot_height: 0.00035
+ reference_blowing_ratio: 0.6
+ blowing_exponent: 0.62
+ turbulence_reference_intensity: 0.08
+ turbulence_sensitivity: 1.0
+ turbulence_exponent: 1.0
+ turbulence_min_multiplier: 0.5
+ reference_wall_temperature: 1100.0
+ density_override: null
+ cp_override: null
+ablative_cooling:
+ enabled: true
+ material_density: 1600.0
+ heat_of_ablation: 2500000.0
+ thermal_conductivity: 0.35
+ specific_heat: 1500.0
+ initial_thickness: 0.0127
+ surface_temperature_limit: 1200.0
+ coverage_fraction: 0.9
+ pyrolysis_temperature: 950.0
+ blowing_efficiency: 0.75
+ use_physics_based_blowing: true
+ blowing_coefficient: 0.5
+ blowing_min_reduction_factor: 0.1
+ turbulence_reference_intensity: 0.08
+ turbulence_sensitivity: 1.5
+ turbulence_exponent: 1.0
+ turbulence_max_multiplier: 3.0
+ throat_recession_multiplier: null
+ char_layer_conductivity: 0.2
+ char_layer_thickness: 0.001
+ surface_emissivity: 0.85
+ ambient_temperature: 300.0
+ radiative_sink_minimum_threshold: 400.0
+ radiative_sink_fallback_temperature: 600.0
+ track_geometry_evolution: true
+ nozzle_ablative: false
+graphite_insert:
+ enabled: true
+ material_density: 2260.0
+ heat_of_ablation: 15000000.0
+ thermal_conductivity: 100.0
+ specific_heat: 710.0
+ initial_thickness: 0.006
+ surface_temperature_limit: 2500.0
+ oxidation_temperature: 800.0
+ oxidation_rate: 1.0e-06
+ activation_energy: 190000.0
+ oxidation_reference_temperature: 973.0
+ oxidation_reference_pressure: 21000.0
+ recession_multiplier: null
+ sizing_only_mode: false
+ simplified_graphite_oxidation: false
+ simplified_oxidation_rate: 1.0e-05
+ sizing_recession_rate: 1.0e-08
+ axial_half_length_ratio: 0.75
+ axial_half_length: null
+ char_layer_conductivity: 5.0
+ char_layer_thickness: 0.0005
+ coverage_fraction: 1.0
+ emissivity: 0.8
+ ambient_temperature: 300.0
+ feedback_fraction_min: 0.0
+ feedback_fraction_max: 0.2
+ oxidation_enthalpy: 32800000.0
+ ablation_surface_temperature: 3000.0
+ ablation_transition_width: 200.0
+ oxidation_pressure_exponent: 0.5
+ oxidation_pre_exponential: null
+ mixture_mw: 0.024
+ oxidation_stoichiometry_ratio: 1.0
+ oxygen_mass_fraction: 0.05
+ oxygen_mole_fraction: null
+ friction_coefficient_override: null
+ reference_diffusivity: null
+ reference_diffusivity_temperature: 1500.0
+ reference_diffusivity_pressure: 1000000.0
+stainless_steel_case: null
+discharge:
+ fuel:
+ Cd_inf: 0.6
+ a_Re: 0.18
+ Cd_min: 0.35
+ use_geometry_cd: true
+ d_ref_m: 0.002
+ cd_small_hole_exponent: 0.2
+ cd_large_hole_log_gain: 0.015
+ cd_inf_max: 0.62
+ cd_inf_min_geom: 0.48
+ inlet_geometry: sharp
+ inlet_radius_ratio: null
+ orifice_l_over_d: 4.0
+ d_min_m: 0.0004
+ use_pressure_correction: false
+ P_ref: 5000000.0
+ a_P: 0.0
+ use_temperature_correction: false
+ T_ref: 300.0
+ a_T: 0.0
+ oxidizer:
+ Cd_inf: 0.6
+ a_Re: 0.18
+ Cd_min: 0.35
+ use_geometry_cd: true
+ d_ref_m: 0.002
+ cd_small_hole_exponent: 0.2
+ cd_large_hole_log_gain: 0.015
+ cd_inf_max: 0.62
+ cd_inf_min_geom: 0.48
+ inlet_geometry: sharp
+ inlet_radius_ratio: null
+ orifice_l_over_d: 4.0
+ d_min_m: 0.0004
+ use_pressure_correction: false
+ P_ref: 5000000.0
+ a_P: 0.0
+ use_temperature_correction: false
+ T_ref: 90.0
+ a_T: 0.0
+spray:
+ momentum_flux_ratio: true
+ spray_angle:
+ model: TMR
+ k: 0.5
+ n: 0.5
+ weber:
+ We_min: 15
+ smd:
+ model: ingebo
+ C: 0.5
+ m: 0.6
+ p: 0.0
+ C_ingebo: 3.9
+ chamber_gas_R: 389.0
+ chamber_gas_T: 3094.0
+ we_corr_max: null
+ pintle:
+ C: 15.0
+ B: 2.0
+ n: 0.5
+ p: 0.2
+ evaporation:
+ model: derived
+ C_evap: 1.562
+ cp_gas: 2200.0
+ apply_tau_res_correction: false
+ K: 300000.0
+ x_star_limit: 0.05
+ use_constraint: true
+ use_turbulence_corrections: false
+ turbulence_breakup_gain: 1.0
+ turbulence_penetration_gain: 0.5
+combustion:
+ cea:
+ use_parallel_cea_build: false
+ cea_parallel_workers: null
+ ox_name: LOX
+ fuel_name: Ethanol
+ expansion_ratio: 5.598521540485944
+ cache_file: output/cache/cea_cache_LOX_Ethanol_3D.npz
+ Pc_range:
+ - 1000000.0
+ - 9000000.0
+ MR_range:
+ - 1.0
+ - 2.5
+ eps_range:
+ - 4.0
+ - 15.0
+ n_points: 34
+ efficiency:
+ model: exponential
+ C: 0.3
+ K: 0.15
+ use_spray_correction: false
+ spray_penalty_factor: 0.8
+ use_mixture_coupling: false
+ use_cooling_coupling: true
+ use_turbulence_coupling: true
+ Em_peak: 0.96
+ mixing_sigma: 1.5
+ R_opt: null
+ mixture_efficiency_floor: 0.25
+ cooling_efficiency_floor: 0.25
+ turbulence_efficiency_floor: 0.3
+ target_turbulence_intensity: null
+ turbulence_penalty_exponent: null
+ target_smd_microns: null
+ xstar_limit_mm: null
+ xstar_penalty_exponent: null
+ we_reference: null
+ we_penalty_exponent: null
+ smd_penalty_exponent: null
+ use_advanced_model: true
+ Pc_gate: 1000000.0
+ use_finite_rate_chemistry: true
+ use_shifting_equilibrium: true
+ tau_ref: 1.0e-05
+ tau_ref_P: 4000000.0
+ tau_ref_T: 3500.0
+ n_pressure: 0.8
+ tau_Tc_floor_K: null
+ T_star_fuel_cap_K: 500.0
+ A0_hydrocarbon: 10000000.0
+ Ea_hydrocarbon: 80000.0
+ n_pre_hydrocarbon: 0.3
+ A0_ethanol: 50000000.0
+ Ea_ethanol: 140000.0
+ n_pre_ethanol: 0.25
+ A0_hydrogen: 1000000000.0
+ Ea_hydrogen: 40000.0
+ n_pre_hydrogen: 0.2
+chamber_geometry:
+ design_pressure: 2992123.1854115925
+ design_thrust: 6500.0
+ design_MR: 1.4992994717934465
+ chamber_diameter: 0.127
+ Lstar: 1.0000002573548417
+ exit_diameter: 0.10455893825523037
+ expansion_ratio: 5.598521540485944
+ nozzle_efficiency: 0.95
+ A_throat: 0.001533694488707181
+ A_exit: 0.00858642163155173
+ volume: 0.0015336948834108832
+ length: 0.13096731883297194
+ length_cylindrical: 0.09853286886728646
+ length_contraction: 0.03243444996568549
+ Cf: 1.4174100000015877
+chamber: null
+nozzle: null
+solver:
+ method: brentq
+ Pc_bounds:
+ - 100000.0
+ - 8000000.0
+ tolerance: 1.0e-06
+ max_iterations: 100
+ closure:
+ max_iterations: 6
+ Cd_reduction_factor: 1.0
+ tolerance: 0.0001
+stability:
+ n_interaction: 0.5
+ chi_acoustic: 0.15
+ mach_nozzle_entrance: null
+ damping_injector_frac: 0.02
+ damping_twophase_frac: 0.03
+ droplet_loading: 1.0
+ acoustic_gate_alpha_offset: 350.0
+ time_lag_model: leonardi_dtl
+ convection_model: none
+ mixing_lag_fraction: 0.5
+ regulator_enabled: true
+ regulator_corner_hz: 3.0
+ regulator_Z_hf: 0.0
+ regulator_max_excursion_psi: 0.0
+optimizer:
+ mode: hybrid_cma_blocks
+ hybrid:
+ elite_k: 50
+ block_method: corr_greedy
+ num_blocks: 3
+ overlap_fraction: 0.0
+ cycles: 3
+ lambda0: 0.001
+ lambda_mult: 10.0
+ lambda_max: 1.0
+ lambda_normalize: true
+ per_block_budget_fraction: 0.5
+ refresh_every_pass: true
+ refresh_budget_fraction: 0.1
+ refresh_sigma_scale: 0.2
+ num_tracks: 1
+lox_tank:
+ lox_h: 0.42040357648186094
+ lox_radius: 0.06985
+ ox_tank_pos: 0.8
+ mass: 6.6114460194537275
+ initial_pressure_psi: 584.2669657943025
+ tank_volume_m3: 0.006443904502391548
+fuel_tank:
+ rp1_h: 0.33997541365866535
+ rp1_radius: 0.0762
+ fuel_tank_pos: 3.0
+ mass: 4.403792412851763
+ initial_pressure_psi: 584.2669657943025
+ tank_volume_m3: 0.006201651053164008
+press_tank:
+ press_h: 0.31334079903733364
+ press_radius: 0.0685
+ pres_tank_pos: 3.6
+ dry_mass: 3.188
+ initial_gas_mass: 1.312
+ mass: null
+ free_volume_L: 4.619
+rocket:
+ airframe_mass: 43.427417763392924
+ engine_mass: 14.54017748713
+ lox_tank_structure_mass: 4.082331330000001
+ fuel_tank_structure_mass: 4.082331330000001
+ engine_cm_offset: 0.15
+ propulsion_dry_mass: 21.0
+ propulsion_cm_offset: 0.4
+ copv_dry_mass: 3.188
+ inertia:
+ - 8.0
+ - 8.0
+ - 0.5
+ radius: 0.078359
+ rocket_length: 6.432614614439114
+ motor_position: 0.0
+ fins:
+ no_fins: 4
+ root_chord: 0.626872
+ tip_chord: 0.20066
+ fin_span: 0.20066
+ fin_position: 1.054535
+ nose_kind: vonKarman
+ nose_fineness_ratio: 4.5
+ nose_length: null
+ avionics_payload_length_m: 4.0
+ mass: null
+ cm_wo_motor: 3.861725449
+ dry_mass: null
+ motor_inertia: null
+ motor: null
+environment:
+ date:
+ - 2026
+ - 1
+ - 30
+ - 18
+ latitude: 35.34722
+ longitude: -117.8099547
+ elevation: 626.67
+ atmosphere_model: standard_atmosphere
+thrust:
+ burn_time: 3.994
+ design_thrust: 6500.0
+design_requirements:
+ target_thrust: 6500.0
+ target_chamber_pressure_psi: 430.0
+ target_apogee: 3890.7
+ optimal_of_ratio: 1.5
+ target_burn_time: 3.994
+ max_lox_tank_pressure_psi: 600.0
+ max_fuel_tank_pressure_psi: 600.0
+ max_P_tank_O: null
+ max_P_tank_F: null
+ max_engine_length: 0.4
+ max_chamber_outer_diameter: 0.1651
+ metal_wall_thickness_per_side_m: 0.00635
+ max_nozzle_exit_diameter: 0.2032
+ min_Lstar: 1.0
+ max_Lstar: 1.0
+ min_stability_score: 0.58
+ require_stable_state: false
+ stability_margin_handicap: 0.0
+ min_stability_margin: 1.05
+ chugging_margin_min: 0.2
+ acoustic_margin_min: 0.1
+ feed_stability_min: 0.15
+ lox_tank_capacity_kg: 6.6114460194537275
+ fuel_tank_capacity_kg: 4.403792412851763
+ propellant_tank_fill_factor: 0.9
+ copv_free_volume_L: 4.619
+ copv_free_volume_m3: null
+ injector_dp_ratio_O_min: 0.2
+ injector_dp_ratio_O_max: 0.4
+ injector_dp_ratio_F_min: 0.2
+ injector_dp_ratio_F_max: 0.4
+ feed_pressure_model: dome_regulated
+ W_geom_ao_af_momentum: 3500.0
+ W_MOM: 75.0
+ impinging_momentum_R_min: 0.95
+ impinging_momentum_R_max: 1.05
+ layer1_momentum_log_deadband_rel: null
+ layer1_impinging_angle_deg_min: 80.0
+ layer1_impinging_jet_angle_min_deg: 40.0
+ layer1_impinging_angle_deg_max: 90.0
+ W_IMPINGING_ANGLE: 400.0
+ W_IMPINGING_JET_ASYM: 180.0
+ layer1_impinging_jet_angle_max_asym_deg: 10.0
+ W_SMD: 0.0
+ target_smd_microns: 50.0
+ layer1_smd_rel_tol: 0.2
+ W_TANK_EQUAL: 800.0
+ layer1_tank_equal_scale_psi: 100.0
+ layer1_chamber_od_increment_in: 0.5
+ layer1_lock_tank_pressures: null
+ layer1_thrust_deadband_rel: null
+ layer1_derive_tank_from_dp_ratio: null
+ layer1_dp_ratio_target: null
+ layer1_derive_fuel_jet_from_of: null
+ layer1_tank_equal_inband_frac: null
+ layer1_chamber_od_snap_target: null
+ layer1_Lstar_from_smd: null
+ layer1_Lstar_smd_ref_um: null
+ layer1_Lstar_ref_m: null
+ layer1_Lstar_smd_exponent: null
+ layer1_Lstar_deadband_m: null
+ layer1_impingement_Ld_target: 4.0
+ layer1_resultant_tilt_max_deg: null
+ layer1_resultant_tilt_gate_tol_deg: 0.5
+ layer1_resultant_tilt_scale_deg: null
+ layer1_momentum_wall_side_multiplier: null
+ layer1_momentum_scale: null
+ layer1_momentum_gate_safe_slack: null
+ layer1_derive_impingement_spacing: null
+ layer1_impingement_Ld_tol: 1.0
+ layer1_ring_order_fuel_outboard: null
+ layer1_integer_jet_angles: null
+ layer1_derive_expansion_ratio: null
+ layer1_derive_throat_from_thrust: null
+ layer1_derive_max_iters: null
+ layer1_derive_thrust_tol_rel: null
+ layer1_tank_equal_tol_psi: null
+ layer1_of_deadband_rel: null
+ layer1_exit_pressure_deadband_rel: null
+ layer1_W_LSTAR: null
+ layer1_Lstar_target_m: null
+ layer1_W_MASS: 3000.0
+ layer1_contraction_half_angle_deg: null
+ layer1_min_Lcyl_over_D: null
+ layer1_max_element_pitch_m: 0.0225
+ layer1_chamber_wall_density_kg_m3: 3400.0
+ layer1_chamber_mass_ref_kg: 5.0
+ layer1_W_EXIT: null
+ W_IMP_GEOM: 1500.0
+ layer1_exit_pressure_inside_quad_scale: 0.38
+ layer1_impinging_n_doublets_max: 30
+ layer1_random_seed: 37
+ layer1_cma_warmstart_trials: 16
+ layer1_cma_warmstart_sigma_frac: 0.04
+ layer1_cma_restart0_sigma_scale: 0.48
+ layer1_lbfgs_gtol: 1.0e-09
+ layer1_lbfgs_second_pass: true
+ W_DP: 800.0
+ W_DP_O: 12000.0
+ W_DP_F: 175000.0
+ W_DP_HIGH: 25000.0
+ W_DP_CENTER: null
+ W_DP_O_FLOOR: null
+ injector_dp_ratio_O_soft_floor: null
+ layer1_A_throat_mm2_min: null
+ layer1_A_throat_mm2_max: null
+ layer1_cf_upper_bound_for_throat_floor: null
+ layer1_pc_fraction_for_throat_floor: null
+ layer1_enforce_ring_geometry: true
+ layer1_injector_spray_radius_frac: 0.7071
+ layer1_injector_spray_radius_tol: 0.08
+ layer1_injector_plate_thickness_m: 0.0127
+ layer1_injector_min_face_incidence_deg: 40.0
+ layer1_injector_counterbore_dia_m: 0.004
+ layer1_injector_center_clear_dia_m: 0.0381
+ layer1_injector_min_web_m: 0.002
+ layer1_injector_wall_clearance_m: 0.008
+ layer1_resultant_tilt_from_reach: true
+ layer1_resultant_tilt_reach_margin: 1.5
+ layer1_impingement_Ld_min: 3.0
+ layer1_impingement_Ld_max: 5.0
+ layer1_momentum_band_width: null
+ layer1_momentum_low_side_multiplier: null
+ layer1_generations_per_restart: null
+ max_chamber_length_m: null
+ objective_cache_rel: null
+ report_every_n: null
+ layer1_infeasibility_gate_eps: 0.002
+ layer1_W_THRUST: 60000.0
+ layer1_W_PC: null
+ layer1_W_OF: 20000.0
+ layer1_W_OF_low_MR_scale: 1.0
+ layer1_W_OF_high_MR_scale: 1.0
+ layer1_of_validation_tol: null
+ layer1_thrust_validation_rel_tol: null
+ W_CHAMBER_SHAPE: 2500.0
+ layer1_chamber_dt_ratio_min: 2.2
+ layer1_chamber_dt_ratio_max: 3.2
+ layer1_chamber_ld_ratio_min: 1.0
+ layer1_chamber_ld_ratio_max: 3.2
+ layer1_stagnation_pressure_frac_min: 0.35
+ layer1_stagnation_pressure_frac_max: 1.0
+ layer1_expansion_ratio_min: 3.0
+ layer1_expansion_ratio_max: 14.0
+ layer1_P_O_start_psi_min: null
+ layer1_P_O_start_psi_max: null
+ layer1_P_F_start_psi_min: null
+ layer1_P_F_start_psi_max: null
+ frozen_parameters:
+ A_throat_mm2: null
+ Lstar_mm: null
+ expansion_ratio: null
+ D_chamber_outer_mm: 165.1
+ d_pintle_tip_mm: null
+ h_gap_mm: null
+ n_orifices: null
+ d_orifice_mm: null
+ n_doublets: 24
+ d_jet_O_mm: null
+ d_jet_F_mm: null
+ impingement_angle_O_deg: null
+ impingement_angle_F_deg: null
+ spacing_O_mm: null
+ spacing_F_mm: null
+ P_O_start_psi: null
+ P_F_start_psi: null
+pressure_curves: null
+design_valid_for: null
diff --git a/EngineDesign/configs/ethalox_6500N_375psi.yaml b/EngineDesign/configs/ethalox_6500N_375psi.yaml
new file mode 100644
index 000000000..3da5765f1
--- /dev/null
+++ b/EngineDesign/configs/ethalox_6500N_375psi.yaml
@@ -0,0 +1,669 @@
+# CalSTAR ethalox -- 180 lb wet, 6.500 kN, Pc 375 psia / tanks 505 psi. 2026-09-16.
+#
+# Audit: python3 scripts/design_audit.py configs/ethalox_6500N_375psi.yaml -> CLEAN
+#
+# WHY THIS FILE EXISTS
+# A lower-chamber-pressure cut of ethalox_6500N.yaml, to bring tank pressure down from
+# 584.27 to 505.12 psi. Thrust, O/F, the 5.000 in bore, L* 1.000 m, 24 doublets and
+# dP/Pc 0.3470 are all held; the nozzle is RE-MATCHED (eps 5.5985 -> 5.0238) so the
+# comparison is not a rigged one against a nozzle left at the wrong area ratio.
+#
+# Pc 374.998 psia P_tank 505.125 psi F 6499.97 N O/F 1.50000 Pe/Pa 0.9992
+# D_throat 47.9145 mm (was 44.1900) D_exit 107.3947 mm (was 104.5589)
+# chamber length 153.974 mm (was 130.967) -- L* 1.0 m at a bigger throat needs more volume
+# contraction ratio 7.025 (was 8.26)
+#
+# THE INJECTOR HAD TO BE RE-LAID-OUT -- 43/46 DOES NOT SURVIVE HERE
+# The 17.6 % longer chamber drops the reach-based tilt allowance to 5.497 deg, while the
+# larger orifices raise the resultant tilt. At the inherited 43/46 the tilt is +6.4385 deg
+# -- design_audit.py FAILED it. Re-split to 40/49 (included 89, still under the SP-8089
+# 90 deg face-heating threshold) with element spacing +5 %:
+# tilt +3.4385 deg against a 5.4967 deg allowance, 63 % used, face terms exactly 0.
+# Spacing had to move in BOTH the face-layout check and the tilt-allowance check; a first
+# pass that scaled it in only one of the two produced a layout that was not actually feasible.
+#
+# WHAT THE PRESSURE REDUCTION ACTUALLY BUYS, MEASURED
+# 433.65 psia 375.00 psia delta
+# tank pressure psi 584.3 505.1 -79.1 <- the point
+# throat heat flux rel 1.000 0.878 -12.2 % <- the other point
+# COPV fill required psi 3278 2795 -483
+# chug gain margin 1.9767 1.8919 -0.0847 (gate is 1.0)
+# Isp s 236.92 232.36 -1.92 %
+# impulse N.s 25544 25060 -485
+# apogee ft 12760 12462 -298 (ceiling 15000)
+# SMD effective um 60.92 69.47 +14.0 %
+# peak Mach 0.8470 0.8365 subsonic either way
+#
+# THE MASS LEDGER -- AND WHY IT DOES NOT PAY
+# tanks, IF custom-built to pressure +2.44 lb
+# tanks, off-the-shelf 18 in Seamless +0.00 lb <- fixed MAWP, no credit
+# nitrogen +0.31 lb
+# engine (longer chamber, bigger throat) -2.65 lb
+# NET with custom tanks +0.09 lb
+# NET with off-the-shelf tanks -2.35 lb <- HEAVIER
+# At fixed thrust, dropping Pc grows the engine faster than it shrinks the tanks. Engine
+# mass is built up component by component: sleeve and ablative liner follow chamber LENGTH,
+# nozzle and graphite insert follow throat AREA, injector plate and bosses do not grow.
+# ENGINE 10.5433 kg = 23.244 lb (was 9.198 kg / 20.28 lb)
+#
+# PROPELLANT AND TANKS -- UNCHANGED, the 16 L rule still binds
+# COPV 4.6190 L + liquid 11.3810 L = 16.0000 L
+# LOX 6.6086 kg 5.7970 L tank 1.7016 gal ~15.0 in
+# ethanol 4.4057 kg 5.5840 L tank 1.6390 gal ~14.5 in
+# Burn 3.8312 s on the flat dome-regulated curve (down from 3.8978 s).
+#
+# THE HONEST READ
+# This is not a mass save. It is 79 psi of pressure-vessel margin and 12 % less throat
+# heat flux, bought for 298 ft of apogee out of a 2538 ft cushion. Worth it only because
+# nobody has produced a MAWP for the Seamless tanks yet. Get that number and this file
+# may become unnecessary.
+#
+propellant_preset: ethalox
+fluids:
+ fuel:
+ name: Ethanol
+ density: 789.0
+ viscosity: 0.0012
+ surface_tension: 0.0223
+ vapor_pressure: 5800.0
+ specific_heat: 2440.0
+ thermal_conductivity: 0.17
+ temperature: 293.0
+ latent_heat: 838000.0
+ boiling_point: 351.4
+ molecular_weight: 46.07
+ bulk_modulus_pa: 1060000000.0
+ critical_temperature: 514.71
+ injection_phase: null
+ oxidizer:
+ name: LOX
+ density: 1140.0
+ viscosity: 0.00018
+ surface_tension: 0.013
+ vapor_pressure: 101325.0
+ specific_heat: 2300.0
+ thermal_conductivity: 0.15
+ temperature: 90.0
+ latent_heat: 213000.0
+ boiling_point: 90.2
+ molecular_weight: 32.0
+ bulk_modulus_pa: 1500000000.0
+ critical_temperature: 154.6
+ injection_phase: null
+injector:
+ type: impinging
+ geometry:
+ oxidizer:
+ n_elements: 24
+ d_jet: 0.0016289408441572382
+ impingement_angle: 40.0
+ spacing: 0.009460907115067805
+ fuel:
+ n_elements: 24
+ d_jet: 0.0015057912485489935
+ impingement_angle: 49.0
+ spacing: 0.012649189321290244
+feed_system:
+ fuel:
+ line_size: 1/2_TUBE_035
+ d_inlet: 0.010922
+ A_hydraulic: 9.369021288512731e-05
+ K0: 2.019
+ K1: 0.0
+ phi_type: none
+ length: 0.9144
+ oxidizer:
+ line_size: 1/2_TUBE_035
+ d_inlet: 0.010922
+ A_hydraulic: 9.369021288512731e-05
+ K0: 0.643
+ K1: 0.0
+ phi_type: none
+ length: 0.1016
+regen_cooling:
+ enabled: false
+ d_inlet: 0.009525
+ L_inlet: 0.5
+ n_channels: 100
+ channel_width: 0.0009
+ channel_height: 0.001
+ channel_length: 0.18162
+ d_outlet: null
+ L_outlet: 0.1
+ roughness: 0.0
+ K_manifold_split: 0.5
+ K_manifold_merge: 0.3
+ Cd_entrance_inf: 0.8
+ a_Re_entrance: 0.1
+ Cd_entrance_min: 0.6
+ Cd_exit_inf: 0.9
+ a_Re_exit: 0.1
+ Cd_exit_min: 0.7
+ use_heat_transfer: true
+ wall_thickness: 0.002
+ wall_thermal_conductivity: 320.0
+ chamber_inner_diameter: 0.08491
+ hot_gas_prandtl: 0.7
+ hot_gas_viscosity: 4.0e-05
+ hot_gas_thermal_conductivity: 0.12
+ radiation_emissivity_hot: 0.85
+ radiation_view_factor: 1.0
+ n_segments: 20
+ gas_turbulence_intensity: 0.1
+ coolant_turbulence_intensity: 0.05
+ recovery_factor: null
+film_cooling:
+ enabled: false
+ mass_fraction: 0.05
+ injection_temperature: null
+ effectiveness_ref: 0.45
+ decay_length: 0.05
+ apply_to_fraction_of_length: 0.6
+ slot_height: 0.00035
+ reference_blowing_ratio: 0.6
+ blowing_exponent: 0.62
+ turbulence_reference_intensity: 0.08
+ turbulence_sensitivity: 1.0
+ turbulence_exponent: 1.0
+ turbulence_min_multiplier: 0.5
+ reference_wall_temperature: 1100.0
+ density_override: null
+ cp_override: null
+ablative_cooling:
+ enabled: true
+ material_density: 1600.0
+ heat_of_ablation: 2500000.0
+ thermal_conductivity: 0.35
+ specific_heat: 1500.0
+ initial_thickness: 0.0127
+ surface_temperature_limit: 1200.0
+ coverage_fraction: 0.9
+ pyrolysis_temperature: 950.0
+ blowing_efficiency: 0.75
+ use_physics_based_blowing: true
+ blowing_coefficient: 0.5
+ blowing_min_reduction_factor: 0.1
+ turbulence_reference_intensity: 0.08
+ turbulence_sensitivity: 1.5
+ turbulence_exponent: 1.0
+ turbulence_max_multiplier: 3.0
+ throat_recession_multiplier: null
+ char_layer_conductivity: 0.2
+ char_layer_thickness: 0.001
+ surface_emissivity: 0.85
+ ambient_temperature: 300.0
+ radiative_sink_minimum_threshold: 400.0
+ radiative_sink_fallback_temperature: 600.0
+ track_geometry_evolution: true
+ nozzle_ablative: false
+graphite_insert:
+ enabled: true
+ material_density: 2260.0
+ heat_of_ablation: 15000000.0
+ thermal_conductivity: 100.0
+ specific_heat: 710.0
+ initial_thickness: 0.006
+ surface_temperature_limit: 2500.0
+ oxidation_temperature: 800.0
+ oxidation_rate: 1.0e-06
+ activation_energy: 190000.0
+ oxidation_reference_temperature: 973.0
+ oxidation_reference_pressure: 21000.0
+ recession_multiplier: null
+ sizing_only_mode: false
+ simplified_graphite_oxidation: false
+ simplified_oxidation_rate: 1.0e-05
+ sizing_recession_rate: 1.0e-08
+ axial_half_length_ratio: 0.75
+ axial_half_length: null
+ char_layer_conductivity: 5.0
+ char_layer_thickness: 0.0005
+ coverage_fraction: 1.0
+ emissivity: 0.8
+ ambient_temperature: 300.0
+ feedback_fraction_min: 0.0
+ feedback_fraction_max: 0.2
+ oxidation_enthalpy: 32800000.0
+ ablation_surface_temperature: 3000.0
+ ablation_transition_width: 200.0
+ oxidation_pressure_exponent: 0.5
+ oxidation_pre_exponential: null
+ mixture_mw: 0.024
+ oxidation_stoichiometry_ratio: 1.0
+ oxygen_mass_fraction: 0.05
+ oxygen_mole_fraction: null
+ friction_coefficient_override: null
+ reference_diffusivity: null
+ reference_diffusivity_temperature: 1500.0
+ reference_diffusivity_pressure: 1000000.0
+stainless_steel_case: null
+discharge:
+ fuel:
+ Cd_inf: 0.6
+ a_Re: 0.18
+ Cd_min: 0.35
+ use_geometry_cd: true
+ d_ref_m: 0.002
+ cd_small_hole_exponent: 0.2
+ cd_large_hole_log_gain: 0.015
+ cd_inf_max: 0.62
+ cd_inf_min_geom: 0.48
+ inlet_geometry: sharp
+ inlet_radius_ratio: null
+ orifice_l_over_d: 4.0
+ d_min_m: 0.0004
+ use_pressure_correction: false
+ P_ref: 5000000.0
+ a_P: 0.0
+ use_temperature_correction: false
+ T_ref: 300.0
+ a_T: 0.0
+ oxidizer:
+ Cd_inf: 0.6
+ a_Re: 0.18
+ Cd_min: 0.35
+ use_geometry_cd: true
+ d_ref_m: 0.002
+ cd_small_hole_exponent: 0.2
+ cd_large_hole_log_gain: 0.015
+ cd_inf_max: 0.62
+ cd_inf_min_geom: 0.48
+ inlet_geometry: sharp
+ inlet_radius_ratio: null
+ orifice_l_over_d: 4.0
+ d_min_m: 0.0004
+ use_pressure_correction: false
+ P_ref: 5000000.0
+ a_P: 0.0
+ use_temperature_correction: false
+ T_ref: 90.0
+ a_T: 0.0
+spray:
+ momentum_flux_ratio: true
+ spray_angle:
+ model: TMR
+ k: 0.5
+ n: 0.5
+ weber:
+ We_min: 15
+ smd:
+ model: ingebo
+ C: 0.5
+ m: 0.6
+ p: 0.0
+ C_ingebo: 3.9
+ chamber_gas_R: 389.0
+ chamber_gas_T: 3094.0
+ we_corr_max: null
+ pintle:
+ C: 15.0
+ B: 2.0
+ n: 0.5
+ p: 0.2
+ evaporation:
+ model: derived
+ C_evap: 1.562
+ cp_gas: 2200.0
+ apply_tau_res_correction: false
+ K: 300000.0
+ x_star_limit: 0.05
+ use_constraint: true
+ use_turbulence_corrections: false
+ turbulence_breakup_gain: 1.0
+ turbulence_penetration_gain: 0.5
+combustion:
+ cea:
+ use_parallel_cea_build: false
+ cea_parallel_workers: null
+ ox_name: LOX
+ fuel_name: Ethanol
+ expansion_ratio: 5.598521540485944
+ cache_file: output/cache/cea_cache_LOX_Ethanol_3D.npz
+ Pc_range:
+ - 1000000.0
+ - 9000000.0
+ MR_range:
+ - 1.0
+ - 2.5
+ eps_range:
+ - 4.0
+ - 15.0
+ n_points: 34
+ efficiency:
+ model: exponential
+ C: 0.3
+ K: 0.15
+ use_spray_correction: false
+ spray_penalty_factor: 0.8
+ use_mixture_coupling: false
+ use_cooling_coupling: true
+ use_turbulence_coupling: true
+ Em_peak: 0.96
+ mixing_sigma: 1.5
+ R_opt: null
+ mixture_efficiency_floor: 0.25
+ cooling_efficiency_floor: 0.25
+ turbulence_efficiency_floor: 0.3
+ target_turbulence_intensity: null
+ turbulence_penalty_exponent: null
+ target_smd_microns: null
+ xstar_limit_mm: null
+ xstar_penalty_exponent: null
+ we_reference: null
+ we_penalty_exponent: null
+ smd_penalty_exponent: null
+ use_advanced_model: true
+ Pc_gate: 1000000.0
+ use_finite_rate_chemistry: true
+ use_shifting_equilibrium: true
+ tau_ref: 1.0e-05
+ tau_ref_P: 4000000.0
+ tau_ref_T: 3500.0
+ n_pressure: 0.8
+ tau_Tc_floor_K: null
+ T_star_fuel_cap_K: 500.0
+ A0_hydrocarbon: 10000000.0
+ Ea_hydrocarbon: 80000.0
+ n_pre_hydrocarbon: 0.3
+ A0_ethanol: 50000000.0
+ Ea_ethanol: 140000.0
+ n_pre_ethanol: 0.25
+ A0_hydrogen: 1000000000.0
+ Ea_hydrogen: 40000.0
+ n_pre_hydrogen: 0.2
+chamber_geometry:
+ design_pressure: 2585522.0881709303
+ design_thrust: 6500.0
+ design_MR: 1.5000001346180996
+ chamber_diameter: 0.127
+ Lstar: 1.0000002573548417
+ exit_diameter: 0.10739474706886201
+ expansion_ratio: 5.023789746571696
+ nozzle_efficiency: 0.95
+ A_throat: 0.0018031194794888722
+ A_exit: 0.00905849315289989
+ volume: 0.0018031194794888722
+ length: 0.15397442287428612
+ length_cylindrical: 0.11584219447400496
+ length_contraction: 0.03813222840028117
+ Cf: 1.394242235139658
+chamber: null
+nozzle: null
+solver:
+ method: brentq
+ Pc_bounds:
+ - 100000.0
+ - 8000000.0
+ tolerance: 1.0e-06
+ max_iterations: 100
+ closure:
+ max_iterations: 6
+ Cd_reduction_factor: 1.0
+ tolerance: 0.0001
+stability:
+ n_interaction: 0.5
+ chi_acoustic: 0.15
+ mach_nozzle_entrance: null
+ damping_injector_frac: 0.02
+ damping_twophase_frac: 0.03
+ droplet_loading: 1.0
+ acoustic_gate_alpha_offset: 350.0
+ time_lag_model: leonardi_dtl
+ convection_model: none
+ mixing_lag_fraction: 0.5
+ regulator_enabled: true
+ regulator_corner_hz: 3.0
+ regulator_Z_hf: 0.0
+ regulator_max_excursion_psi: 0.0
+optimizer:
+ mode: hybrid_cma_blocks
+ hybrid:
+ elite_k: 50
+ block_method: corr_greedy
+ num_blocks: 3
+ overlap_fraction: 0.0
+ cycles: 3
+ lambda0: 0.001
+ lambda_mult: 10.0
+ lambda_max: 1.0
+ lambda_normalize: true
+ per_block_budget_fraction: 0.5
+ refresh_every_pass: true
+ refresh_budget_fraction: 0.1
+ refresh_sigma_scale: 0.2
+ num_tracks: 1
+lox_tank:
+ lox_h: 0.42022397339521833
+ lox_radius: 0.06985
+ ox_tank_pos: 0.8
+ mass: 6.608621504681038
+ initial_pressure_psi: 505.125
+ tank_volume_m3: 0.006441151564016606
+fuel_tank:
+ rp1_h: 0.3401263301411914
+ rp1_radius: 0.0762
+ fuel_tank_pos: 3.0
+ mass: 4.405747274391809
+ initial_pressure_psi: 505.125
+ tank_volume_m3: 0.006204403991538949
+press_tank:
+ press_h: 0.31334079903733364
+ press_radius: 0.0685
+ pres_tank_pos: 3.6
+ dry_mass: 3.188
+ initial_gas_mass: 1.312
+ mass: null
+ free_volume_L: 4.619
+rocket:
+ airframe_mass: 41.81159478496249
+ engine_mass: 15.743286591410694
+ lox_tank_structure_mass: 4.082331330000001
+ fuel_tank_structure_mass: 4.082331330000001
+ engine_cm_offset: 0.15
+ propulsion_dry_mass: 21.0
+ propulsion_cm_offset: 0.4
+ copv_dry_mass: 3.188
+ inertia:
+ - 8.0
+ - 8.0
+ - 0.5
+ radius: 0.078359
+ rocket_length: 6.432614614439114
+ motor_position: 0.0
+ fins:
+ no_fins: 4
+ root_chord: 0.626872
+ tip_chord: 0.20066
+ fin_span: 0.20066
+ fin_position: 1.054535
+ nose_kind: vonKarman
+ nose_fineness_ratio: 4.5
+ nose_length: null
+ avionics_payload_length_m: 4.0
+ mass: null
+ cm_wo_motor: 3.861725449
+ dry_mass: null
+ motor_inertia: null
+ motor: null
+environment:
+ date:
+ - 2026
+ - 1
+ - 30
+ - 18
+ latitude: 35.34722
+ longitude: -117.8099547
+ elevation: 626.67
+ atmosphere_model: standard_atmosphere
+thrust:
+ burn_time: 3.994
+ design_thrust: 6500.0
+design_requirements:
+ target_thrust: 6500.0
+ target_chamber_pressure_psi: 430.0
+ target_apogee: 3890.7
+ optimal_of_ratio: 1.5
+ target_burn_time: 3.994
+ max_lox_tank_pressure_psi: 600.0
+ max_fuel_tank_pressure_psi: 600.0
+ max_P_tank_O: null
+ max_P_tank_F: null
+ max_engine_length: 0.4
+ max_chamber_outer_diameter: 0.1651
+ metal_wall_thickness_per_side_m: 0.00635
+ max_nozzle_exit_diameter: 0.2032
+ min_Lstar: 1.0
+ max_Lstar: 1.0
+ min_stability_score: 0.58
+ require_stable_state: false
+ stability_margin_handicap: 0.0
+ min_stability_margin: 1.05
+ chugging_margin_min: 0.2
+ acoustic_margin_min: 0.1
+ feed_stability_min: 0.15
+ lox_tank_capacity_kg: 6.608621504681038
+ fuel_tank_capacity_kg: 4.405747274391809
+ propellant_tank_fill_factor: 0.9
+ copv_free_volume_L: 4.619
+ copv_free_volume_m3: null
+ injector_dp_ratio_O_min: 0.2
+ injector_dp_ratio_O_max: 0.4
+ injector_dp_ratio_F_min: 0.2
+ injector_dp_ratio_F_max: 0.4
+ feed_pressure_model: dome_regulated
+ W_geom_ao_af_momentum: 3500.0
+ W_MOM: 75.0
+ impinging_momentum_R_min: 0.95
+ impinging_momentum_R_max: 1.05
+ layer1_momentum_log_deadband_rel: null
+ layer1_impinging_angle_deg_min: 80.0
+ layer1_impinging_jet_angle_min_deg: 40.0
+ layer1_impinging_angle_deg_max: 90.0
+ W_IMPINGING_ANGLE: 400.0
+ W_IMPINGING_JET_ASYM: 180.0
+ layer1_impinging_jet_angle_max_asym_deg: 10.0
+ W_SMD: 0.0
+ target_smd_microns: 50.0
+ layer1_smd_rel_tol: 0.2
+ W_TANK_EQUAL: 800.0
+ layer1_tank_equal_scale_psi: 100.0
+ layer1_chamber_od_increment_in: 0.5
+ layer1_lock_tank_pressures: null
+ layer1_thrust_deadband_rel: null
+ layer1_derive_tank_from_dp_ratio: null
+ layer1_dp_ratio_target: null
+ layer1_derive_fuel_jet_from_of: null
+ layer1_tank_equal_inband_frac: null
+ layer1_chamber_od_snap_target: null
+ layer1_Lstar_from_smd: null
+ layer1_Lstar_smd_ref_um: null
+ layer1_Lstar_ref_m: null
+ layer1_Lstar_smd_exponent: null
+ layer1_Lstar_deadband_m: null
+ layer1_impingement_Ld_target: 4.0
+ layer1_resultant_tilt_max_deg: null
+ layer1_resultant_tilt_gate_tol_deg: 0.5
+ layer1_resultant_tilt_scale_deg: null
+ layer1_momentum_wall_side_multiplier: null
+ layer1_momentum_scale: null
+ layer1_momentum_gate_safe_slack: null
+ layer1_derive_impingement_spacing: null
+ layer1_impingement_Ld_tol: 1.0
+ layer1_ring_order_fuel_outboard: null
+ layer1_integer_jet_angles: null
+ layer1_derive_expansion_ratio: null
+ layer1_derive_throat_from_thrust: null
+ layer1_derive_max_iters: null
+ layer1_derive_thrust_tol_rel: null
+ layer1_tank_equal_tol_psi: null
+ layer1_of_deadband_rel: null
+ layer1_exit_pressure_deadband_rel: null
+ layer1_W_LSTAR: null
+ layer1_Lstar_target_m: null
+ layer1_W_MASS: 3000.0
+ layer1_contraction_half_angle_deg: null
+ layer1_min_Lcyl_over_D: null
+ layer1_max_element_pitch_m: 0.0225
+ layer1_chamber_wall_density_kg_m3: 3400.0
+ layer1_chamber_mass_ref_kg: 5.0
+ layer1_W_EXIT: null
+ W_IMP_GEOM: 1500.0
+ layer1_exit_pressure_inside_quad_scale: 0.38
+ layer1_impinging_n_doublets_max: 30
+ layer1_random_seed: 37
+ layer1_cma_warmstart_trials: 16
+ layer1_cma_warmstart_sigma_frac: 0.04
+ layer1_cma_restart0_sigma_scale: 0.48
+ layer1_lbfgs_gtol: 1.0e-09
+ layer1_lbfgs_second_pass: true
+ W_DP: 800.0
+ W_DP_O: 12000.0
+ W_DP_F: 175000.0
+ W_DP_HIGH: 25000.0
+ W_DP_CENTER: null
+ W_DP_O_FLOOR: null
+ injector_dp_ratio_O_soft_floor: null
+ layer1_A_throat_mm2_min: null
+ layer1_A_throat_mm2_max: null
+ layer1_cf_upper_bound_for_throat_floor: null
+ layer1_pc_fraction_for_throat_floor: null
+ layer1_enforce_ring_geometry: true
+ layer1_injector_spray_radius_frac: 0.7071
+ layer1_injector_spray_radius_tol: 0.08
+ layer1_injector_plate_thickness_m: 0.0127
+ layer1_injector_min_face_incidence_deg: 40.0
+ layer1_injector_counterbore_dia_m: 0.004
+ layer1_injector_center_clear_dia_m: 0.0381
+ layer1_injector_min_web_m: 0.002
+ layer1_injector_wall_clearance_m: 0.008
+ layer1_resultant_tilt_from_reach: true
+ layer1_resultant_tilt_reach_margin: 1.5
+ layer1_impingement_Ld_min: 3.0
+ layer1_impingement_Ld_max: 5.0
+ layer1_momentum_band_width: null
+ layer1_momentum_low_side_multiplier: null
+ layer1_generations_per_restart: null
+ max_chamber_length_m: null
+ objective_cache_rel: null
+ report_every_n: null
+ layer1_infeasibility_gate_eps: 0.002
+ layer1_W_THRUST: 60000.0
+ layer1_W_PC: null
+ layer1_W_OF: 20000.0
+ layer1_W_OF_low_MR_scale: 1.0
+ layer1_W_OF_high_MR_scale: 1.0
+ layer1_of_validation_tol: null
+ layer1_thrust_validation_rel_tol: null
+ W_CHAMBER_SHAPE: 2500.0
+ layer1_chamber_dt_ratio_min: 2.2
+ layer1_chamber_dt_ratio_max: 3.2
+ layer1_chamber_ld_ratio_min: 1.0
+ layer1_chamber_ld_ratio_max: 3.2
+ layer1_stagnation_pressure_frac_min: 0.35
+ layer1_stagnation_pressure_frac_max: 1.0
+ layer1_expansion_ratio_min: 3.0
+ layer1_expansion_ratio_max: 14.0
+ layer1_P_O_start_psi_min: null
+ layer1_P_O_start_psi_max: null
+ layer1_P_F_start_psi_min: null
+ layer1_P_F_start_psi_max: null
+ frozen_parameters:
+ A_throat_mm2: null
+ Lstar_mm: null
+ expansion_ratio: null
+ D_chamber_outer_mm: 165.1
+ d_pintle_tip_mm: null
+ h_gap_mm: null
+ n_orifices: null
+ d_orifice_mm: null
+ n_doublets: 24
+ d_jet_O_mm: null
+ d_jet_F_mm: null
+ impingement_angle_O_deg: null
+ impingement_angle_F_deg: null
+ spacing_O_mm: null
+ spacing_F_mm: null
+ P_O_start_psi: null
+ P_F_start_psi: null
+pressure_curves: null
+design_valid_for: null
diff --git a/EngineDesign/docs/stability/chug-double-time-lag.md b/EngineDesign/docs/stability/chug-double-time-lag.md
index 78364d88b..5f4264c46 100644
--- a/EngineDesign/docs/stability/chug-double-time-lag.md
+++ b/EngineDesign/docs/stability/chug-double-time-lag.md
@@ -159,8 +159,51 @@ the trustworthy output.
| η sweep fixed at 0.08–0.45 for every engine | window anchored to the design point (`_eta_window`) |
| `T_crit` absent — no model needed it | `FluidConfig.critical_temperature`, config → CoolProp → handbook, every fallback recorded |
| frontend legend hardcoded "O (LOX)" / "F (fuel)" | actual fluid names and phases from the payload |
+| vaporization card, radar and SMD slider all oxidizer-only | both streams; headline and radar follow `rate_limiting_stream` (§4b) |
+| `fallbacks_used` accumulated across runs and propellants | `assumptions.scope()` per report (§4b) |
+| Forward Mode kept the previous propellant's stability panel on screen | results and sensitivity overrides cleared when the engine identity changes (`lib/engineIdentity.ts`) |
| jet diameter unavailable to the lag model | `_jet_geometry` resolves it for impinging / coaxial / pintle, and returns NaN (recorded) rather than a stand-in when the injector type has no equivalent dimension |
+## 4b. Reporting the right stream, and the right run
+
+Three reporting defects sat downstream of the physics and survived the §4 pass, because each is
+*correct on methalox* and only wrong on a propellant whose fuel is the slower vaporizer.
+
+**The vaporization card described the oxidizer, not the rate-limiting stream.** `L_vap`,
+`tau_conv`, SMD and the completion percentage were hardwired to the O side. On LOX/CH₄ the oxidizer
+happens to be slower (3.7 ms vs 2.9 ms), so the card read correctly by luck. On LOX/ethanol it does
+not (13 ms vs 25 ms): the card reported LOX needing 211 mm in a 203 mm chamber — marginal — while
+ethanol, the stream actually setting the lag, needed **426 mm**. The one-glance health radar scores
+its "vaporization" axis off those same keys, so it read 0.96 (nearly passing) instead of 0.48.
+`_vaporization_profile` now computes **both** streams and the headline keys follow
+`rate_limiting_stream`; the UI draws both curves and names which one paces the burn.
+
+**The SMD slider could not reach the stream that mattered.** `smd_um` overrode `D32_O` only. On an
+engine whose fuel is rate-limiting, the atomization lever moved a number that was not setting the
+lag. Added `smd_F_um` and `eta_inj_F`; the panel marks the rate-limiting stream with ★ and sizes
+each slider's range off that design's own spray (a fixed 30–120 µm window put an ethanol doublet's
+180 µm spray off the end of its own slider).
+
+**The "fallbacks used" note was cumulative across runs.** `assumptions.py` documented `clear()` at
+the start of an evaluation and nothing called it, so the registry was process-global and monotone:
+after a methalox run recorded "fluids.oxidizer.latent_heat missing", an ethalox run whose preset
+supplies every field still announced *the previous propellant's* gaps. Fixed with
+`assumptions.scope()` — a re-entrant, thread-local collector that the rich report wraps itself in —
+rather than `clear()`, which would have destroyed the process-wide diagnostic record the logs want.
+The note also now says *where* the missing values live; "load a propellant preset" was printed even
+for feed-line lengths, which no preset supplies.
+
+Alongside these, `configs/default.yaml` carried `latent_heat: null` and `boiling_point: null` for
+LOX, so every evaluation of the default config silently substituted handbook values and reported
+three fallbacks it never needed. Those are stability-only inputs — the forward performance path does
+not read them — so filling them in cannot move thrust, Isp, or the golden anchors.
+
+**Still propellant-independent, by choice:** the Crocco interaction index `n` and the sensitive
+fraction `chi_acoustic` are calibration constants, not propellant data. Ethanol, methane and RP-1
+get the same combustion response. Giving each preset its own value would mean inventing three
+numbers where the literature supports none, so instead the panel says outright that they do not
+switch and that `chi` is the largest modelling uncertainty in the card. Sweep them.
+
## 5. The root locus
`chug.chug_root_locus` tracks the dominant eigenvalue of $1 + L(s) = 0$ through the s-plane as
@@ -187,6 +230,14 @@ using L17's own lags, (B) each lag model vs the experiment-derived τ_vap, (C) L
Ranz–Marshall, (D) blast radius on STAR-class engines, (E) the decider — each model end-to-end
against both measured quantities. Exit code is non-zero if any criterion regresses.
-Unit tests live in `tests/test_stability_timelag.py`. **Note that `tests/test_stability_*.py` is
-gitignored repo-wide** (`.gitignore:93`, "local-only tests"), so neither these nor the pre-existing
-stability tests run in CI.
+Unit tests live in `tests/test_stability_timelag.py`, and the multi-propellant regression in
+`tests/test_stability_propellants.py`. The latter exists because every other stability test loads
+`configs/default.yaml` — methalox lineage, where "the oxidizer" and "the rate-limiting stream" are
+the same thing, so a LOX/CH₄ assumption is invisible. It runs the extraction across all three
+shipped presets with each one's real spray, and carries an explicit guard that at least one preset
+actually has a slower fuel: without it the key assertion would pass vacuously against the very bug
+it guards.
+
+**Note that `tests/test_stability_*.py` is gitignored repo-wide** (`.gitignore:93`, "local-only
+tests"), so neither these nor the pre-existing stability tests run in CI.
+`scripts/chug_timelag_benchmark.py` is tracked and is the only enforceable gate.
diff --git a/EngineDesign/engine/core/runner.py b/EngineDesign/engine/core/runner.py
index 1ec113081..8854d941b 100644
--- a/EngineDesign/engine/core/runner.py
+++ b/EngineDesign/engine/core/runner.py
@@ -763,7 +763,11 @@ def evaluate_arrays_with_time(
try:
from engine.pipeline.time_varying_solver import TimeVaryingCoupledSolver
- solver = TimeVaryingCoupledSolver(self.config, self.cea_cache)
+ # Same ambient resolution as evaluate(): explicit, else the site elevation.
+ solver = TimeVaryingCoupledSolver(
+ self.config, self.cea_cache,
+ P_ambient=self._get_ambient_pressure(P_ambient),
+ )
states = solver.solve_time_series(times, P_tank_O, P_tank_F)
results = solver.get_results_dict()
diff --git a/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py b/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py
index 9ab82949b..730e2889e 100644
--- a/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py
+++ b/EngineDesign/engine/optimizer/layers/layer1_static_optimization.py
@@ -7922,6 +7922,8 @@ def __init__(self, x, fun, success=True):
eval_cache=eval_cache,
make_cache_key_fn=_make_eval_cache_key,
stop_event=stop_event,
+ seed=int((layer1_seed_base + track_i * 7919) % (2 ** 31)),
+ popsize=popsize,
)
if t_f < best_f_global:
@@ -7950,6 +7952,8 @@ def __init__(self, x, fun, success=True):
eval_cache=eval_cache,
make_cache_key_fn=_make_eval_cache_key,
stop_event=stop_event,
+ seed=int(layer1_seed_base),
+ popsize=popsize,
)
else:
@@ -9970,9 +9974,22 @@ def run_hybrid_optimization(
eval_cache: Optional[dict] = None,
make_cache_key_fn: Optional[Callable[[np.ndarray], Tuple[int, ...]]] = None,
stop_event: Optional[Any] = None, # threading.Event for stop signal
+ seed: Optional[int] = None,
+ popsize: int = 16,
) -> Tuple[np.ndarray, float, int]:
"""
Run Hybrid CMA-ES + Block Re-optimization.
+
+ ``seed`` makes the whole search a deterministic function of its inputs. It was not:
+ this function built ``np.random.default_rng()`` with no seed and called ``run_cma_core``
+ without ``seed=`` in Stage A, in every block and in every refresh, so CMA seeded itself
+ from the clock. ``layer1_random_seed`` reached the warm start and nothing after it --
+ measured, three runs of one config at seed 37 gave three different injectors (included
+ angle 89 / 87 / 83 deg). Every ``run_cma_core`` call below now takes a seed derived from
+ this one, distinct per stage so no two stages replay the same sample stream.
+
+ ``popsize`` is the Stage A / refresh population. It was hardcoded to 16 while the caller
+ computed and logged 48; the block stage keeps its own smaller population.
Logic:
1. Stage A: Global Exploration (Standard CMA-ES)
@@ -10001,6 +10018,13 @@ def run_hybrid_optimization(
# 1. Initialize Elite Pool
elite_pool = ElitePool(k=hybrid_config.elite_k)
+
+ # One generator for everything this function samples (Stage A kick, block partition),
+ # and one derived CMA seed per stage. ``None`` keeps the old fresh-entropy behaviour.
+ rng = np.random.default_rng(seed)
+
+ def _sub_seed(k: int) -> Optional[int]:
+ return None if seed is None else int((int(seed) + k * 1_000_003) % (2 ** 31))
# 2. Budget allocation
# Reserve slice for Stage A
@@ -10042,7 +10066,7 @@ def run_hybrid_optimization(
# Run 1
x_res, f_res, evs = run_cma_core(
objective, x0, sigma0, bounds, budget_a1,
- popsize=16, cma_stds=cma_stds, elite_pool=elite_pool,
+ popsize=popsize, cma_stds=cma_stds, elite_pool=elite_pool, seed=_sub_seed(1),
valley_escape_tracker=valley_escape_tracker, logger=logger,
# Parallel evaluation
executor=executor, integer_dims=integer_dims, eval_cache=eval_cache,
@@ -10055,15 +10079,25 @@ def run_hybrid_optimization(
best_f_global = f_res
best_x_global = x_res
- # Run 2 (Restart from best or random?)
- # Valid restart: Perturb best logic
- rng = np.random.default_rng()
- x0_2 = best_x_global + rng.standard_normal(dim) * (0.01 * span) # Small perturbation
+ # Run 2: GLOBAL re-exploration, not a second polish of run 1.
+ #
+ # This used to restart from the incumbent with a 1 % kick and half the step size -- a
+ # local refine -- and nothing downstream (blocks, refreshes) ever leaves the incumbent's
+ # neighbourhood either. So after run 1 stagnated (typically ~5k of a 25k Stage A budget)
+ # the entire remaining budget polished one basin, and the objective's flat directions
+ # (injector angle, O/F inside its band) were settled by whichever basin run 1 happened
+ # to stop in. The legacy CMA path's odd restarts kick 30 % of each dimension's span off
+ # the incumbent at full sigma -- anchored so the start is not almost-surely infeasible,
+ # wide enough to leave the basin -- and that is what run 2 does now.
+ x0_2 = np.clip(best_x_global + rng.standard_normal(dim) * (0.30 * span),
+ lower_bounds, upper_bounds)
+ if logger:
+ logger.info("Stage A run 2: global re-exploration, 30 %% span kick off f=%.5f", best_f_global)
if budget_a2 > 100:
x_res, f_res, evs = run_cma_core(
- objective, x0_2, sigma0 * 0.5, bounds, budget_a2,
- popsize=16, cma_stds=cma_stds, elite_pool=elite_pool,
+ objective, x0_2, sigma0, bounds, budget_a2,
+ popsize=popsize, cma_stds=cma_stds, elite_pool=elite_pool, seed=_sub_seed(2),
valley_escape_tracker=valley_escape_tracker, logger=logger,
# Parallel evaluation
executor=executor, integer_dims=integer_dims, eval_cache=eval_cache,
@@ -10170,6 +10204,7 @@ def block_obj_fn(z):
z_best, z_f, z_evals = run_cma_core(
block_obj_fn, z0, z_sigma, block_bounds, budget_per_block,
popsize=max(8, 4 + int(3 * np.log(len(z0)+1))), # Smaller pop for blocks
+ seed=_sub_seed(100 + 10 * cycle_idx + b_i),
elite_pool=None,
true_objective_fn=block_obj_fn,
valley_escape_tracker=valley_escape_tracker, logger=logger,
@@ -10218,7 +10253,8 @@ def block_obj_fn(z):
# refresh walk element counts and jet angles off their integer grid.
x_ref_res, f_ref_res, evs_ref = run_cma_core(
objective, x_ref, sigma_ref, bounds, ref_budget,
- popsize=16, cma_stds=cma_stds, elite_pool=elite_pool,
+ popsize=popsize, cma_stds=cma_stds, elite_pool=elite_pool,
+ seed=_sub_seed(1000 + cycle_idx),
valley_escape_tracker=valley_escape_tracker, logger=logger,
executor=executor, integer_dims=integer_dims,
od_index=od_index, od_step_m=od_step_m, fixed_variables=fixed_variables,
diff --git a/EngineDesign/engine/pipeline/assumptions.py b/EngineDesign/engine/pipeline/assumptions.py
index a399915d1..0b3443532 100644
--- a/EngineDesign/engine/pipeline/assumptions.py
+++ b/EngineDesign/engine/pipeline/assumptions.py
@@ -19,14 +19,31 @@
from __future__ import annotations
+import contextlib
import logging
import threading
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, Iterator, List, Optional
_log = logging.getLogger(__name__)
_lock = threading.Lock()
_registry: Dict[str, Dict[str, Any]] = {}
+# Active `scope()` collectors, per thread. The registry itself is process-global and cumulative on
+# purpose -- it is the diagnostic record of everything this process has assumed. But a REPORT must
+# describe one evaluation, and the global registry cannot do that: after a methalox run recorded
+# "fluids.oxidizer.latent_heat missing", an ethalox run whose preset supplies every field still
+# printed "N physics input(s) fell back to recorded defaults", naming the previous propellant's
+# gaps. Scopes solve that without destroying the global record (which `clear()` would).
+_local = threading.local()
+
+
+def _active_scopes() -> List[Dict[str, Dict[str, Any]]]:
+ scopes = getattr(_local, "scopes", None)
+ if scopes is None:
+ scopes = []
+ _local.scopes = scopes
+ return scopes
+
def assume(name: str, value: Any, *, unit: str = "", reason: str = "") -> Any:
"""Record that ``value`` is being ASSUMED (config did not provide it) and return it.
@@ -42,9 +59,46 @@ def assume(name: str, value: Any, *, unit: str = "", reason: str = "") -> Any:
else:
entry["count"] += 1
entry["value"] = value
+ # Also record into every open scope, so a report can describe its own run. Nested scopes all
+ # see it: an outer scope must not miss what an inner one collected.
+ for collected in _active_scopes():
+ scoped = collected.get(name)
+ if scoped is None:
+ collected[name] = {"value": value, "unit": unit, "reason": reason, "count": 1}
+ else:
+ scoped["count"] += 1
+ scoped["value"] = value
return value
+@contextlib.contextmanager
+def scope() -> Iterator[Dict[str, Dict[str, Any]]]:
+ """Collect the assumptions recorded inside this block, leaving the global registry alone.
+
+ Use it around one evaluation whose report must say what *that* evaluation assumed::
+
+ with assumptions.scope() as used:
+ ...
+ payload["fallbacks_used"] = assumptions.as_list(used)
+
+ Thread-local and re-entrant. It does NOT suppress the global record -- `get_assumptions()` still
+ returns everything the process has assumed, which is what the logs and the future
+ /api/assumptions endpoint want.
+ """
+ collected: Dict[str, Dict[str, Any]] = {}
+ scopes = _active_scopes()
+ scopes.append(collected)
+ try:
+ yield collected
+ finally:
+ scopes.remove(collected)
+
+
+def as_list(registry: Dict[str, Dict[str, Any]]) -> List[Dict[str, Any]]:
+ """Compact list form of a scope's collection, matching ``fallbacks_used()``."""
+ return [{"name": k, **v} for k, v in sorted(registry.items())]
+
+
def get_assumptions() -> Dict[str, Dict[str, Any]]:
"""Snapshot of all assumptions used so far in this process."""
with _lock:
diff --git a/EngineDesign/engine/pipeline/config_schemas.py b/EngineDesign/engine/pipeline/config_schemas.py
index d20eaf4ed..b8deaba23 100644
--- a/EngineDesign/engine/pipeline/config_schemas.py
+++ b/EngineDesign/engine/pipeline/config_schemas.py
@@ -2158,8 +2158,14 @@ class HybridOptimizerConfig(BaseModel):
cycles: int = Field(default=3, gt=0, description="Number of re-optimization cycles")
- # Soft freezing / Penalty parameters
- lambda0: float = Field(default=1e-3, gt=0, description="Initial penalty weight base")
+ # Soft freezing / Penalty parameters.
+ #
+ # NOT WIRED. ``run_hybrid_optimization`` computes ``base_lambda`` and ``f_scale`` from
+ # these every cycle and then never applies them: the block objective stitches the block's
+ # coordinates into the incumbent and evaluates the plain objective, with no penalty on
+ # leaving the incumbent. Blocks are therefore hard-frozen, and changing any of these four
+ # fields changes nothing. Kept so shipped configs still validate; do not tune them.
+ lambda0: float = Field(default=1e-3, gt=0, description="Initial penalty weight base (currently unused -- see note above)")
lambda_mult: float = Field(default=10.0, gt=1.0, description="Multiplier for lambda per cycle")
lambda_max: float = Field(default=1.0, gt=0, description="Maximum lambda (relative to f-scale)")
lambda_normalize: bool = Field(default=True, description="Normalize lambda using objective function scale magnitude")
diff --git a/EngineDesign/engine/pipeline/stability/analysis.py b/EngineDesign/engine/pipeline/stability/analysis.py
index 12777eb49..3488af834 100644
--- a/EngineDesign/engine/pipeline/stability/analysis.py
+++ b/EngineDesign/engine/pipeline/stability/analysis.py
@@ -521,14 +521,24 @@ def build_stability_inputs(config, Pc: float, MR: float, mdot_total: float, csta
reason="closure produced no fuel SMD; order-of-magnitude liquid-fuel spray")
D32_F = float(D32_F)
ov = overrides or {}
+ # The SMD sliders. `smd_um` has always meant the OXIDIZER spray and keeps that meaning for
+ # back-compatibility; `smd_F_um` was missing entirely, so on an engine whose FUEL is the
+ # rate-limiting vaporizer (LOX/ethanol: 25 ms vs 13 ms) the atomization slider could not move
+ # the quantity that sets the lag.
if ov.get("smd_um") is not None:
D32_O = float(ov["smd_um"]) * 1e-6
+ if ov.get("smd_F_um") is not None:
+ D32_F = float(ov["smd_F_um"]) * 1e-6
if ov.get("eta_inj_O") is not None:
eta_O = float(ov["eta_inj_O"])
dpiO = eta_O * Pc
else:
eta_O = dpiO / Pc if Pc > 0 else 0.3
- eta_F = dpiF / Pc if Pc > 0 else 0.3
+ if ov.get("eta_inj_F") is not None:
+ eta_F = float(ov["eta_inj_F"])
+ dpiF = eta_F * Pc
+ else:
+ eta_F = dpiF / Pc if Pc > 0 else 0.3
rho_O = _fluid_thermo(config, "oxidizer", "density")
rho_F = _fluid_thermo(config, "fuel", "density")
@@ -651,6 +661,11 @@ def build_stability_inputs(config, Pc: float, MR: float, mdot_total: float, csta
"rho_O": rho_O, "rho_F": rho_F, "K_bulk_O": K_bulk_O,
"feed_length_O": L_feed_O, "feed_length_F": L_feed_F,
"u_O": diagnostics.get("u_O"), "Cd_O": diagnostics.get("Cd_O"),
+ "u_F": diagnostics.get("u_F"), "Cd_F": diagnostics.get("Cd_F"),
+ # Which stream actually paces the burn. Everything that reports "the" vaporization length,
+ # "the" lag or "the" SMD has to follow this, not the oxidizer by position.
+ "rate_limiting_stream": ("O" if (np.isfinite(tau_conv_O) and tau_conv_O >= tau_conv_F)
+ else "F"),
"Pc": Pc, "wh_pressure_pa": None,
}
diff --git a/EngineDesign/engine/pipeline/stability/report.py b/EngineDesign/engine/pipeline/stability/report.py
index eaa3bfae8..b0a56d6c4 100644
--- a/EngineDesign/engine/pipeline/stability/report.py
+++ b/EngineDesign/engine/pipeline/stability/report.py
@@ -72,50 +72,88 @@ def gm_at(kfac: float) -> float:
return curve
-def _vaporization_profile(inp: Dict[str, Any], Pc: float, n_pts: int = 40) -> Dict[str, Any]:
- """Viz #5: d^2-law droplet decay along the chamber + vaporization length vs chamber length."""
- D32 = inp["D32_O"]
- K_v = inp["K_v_O"]
- L_ch = inp["L_ch"]
- # Config-sourced via build_stability_inputs (P2c). The old `inp.get("rho_O", 1140.0)` put LOX's
- # density behind every oxidizer as an invisible default; build_stability_inputs always supplies
- # it now, and a missing one is recorded rather than substituted.
- rho_O = inp.get("rho_O")
- if rho_O is None or not np.isfinite(float(rho_O)) or float(rho_O) <= 0.0:
- from engine.pipeline.assumptions import assume
- rho_O = assume("stability.viz.rho_oxidizer", 1140.0, unit="kg/m^3",
- reason="oxidizer density missing when drawing the vaporization profile")
- rho_O = float(rho_O)
- eta = inp["eta_inj_O"]
- # Representative droplet axial speed: the solved oxidizer injection velocity when the closure
- # provides it, else Bernoulli with the solved Cd (a fixed Cd of 0.6 used to sit here).
- u_O = inp.get("u_O")
- if u_O is not None and np.isfinite(float(u_O)) and float(u_O) > 0.0:
- v_drop = float(u_O)
+def _stream_vaporization(inp: Dict[str, Any], Pc: float, key: str, n_pts: int) -> Dict[str, Any]:
+ """d^2-law droplet decay for ONE stream. ``key`` is "O" or "F"."""
+ from engine.pipeline.assumptions import assume
+
+ D32 = float(inp[f"D32_{key}"])
+ L_ch = float(inp["L_ch"])
+ phase = str(inp.get(f"phase_{key}", "liquid"))
+ fluid = str(inp.get(f"fluid_name_{key}", key))
+ tau_vap = float(inp[f"tau_conv_{key}"])
+ side = "oxidizer" if key == "O" else "fuel"
+
+ if phase.lower().startswith("g"):
+ # A gas has no droplets to track. Say so rather than drawing a decay curve for it.
+ return {"stream": key, "fluid": fluid, "phase": phase, "smd_um": None,
+ "tau_conv_s": tau_vap, "L_vap_m": None, "L_ch_m": L_ch,
+ "vaporized_in_chamber": True, "d2_profile": [],
+ "note": f"{fluid} is injected as a gas — no atomization or vaporization to plot."}
+
+ rho = inp.get(f"rho_{key}")
+ if rho is None or not np.isfinite(float(rho)) or float(rho) <= 0.0:
+ rho = assume(f"stability.viz.rho_{side}", 1140.0 if key == "O" else 800.0, unit="kg/m^3",
+ reason=f"{side} density missing when drawing the vaporization profile")
+ rho = float(rho)
+ eta = float(inp[f"eta_inj_{key}"])
+
+ # Representative droplet axial speed: the solved injection velocity when the closure provides
+ # it, else Bernoulli with the solved Cd.
+ u = inp.get(f"u_{key}")
+ if u is not None and np.isfinite(float(u)) and float(u) > 0.0:
+ v_drop = float(u)
else:
- Cd = inp.get("Cd_O")
+ Cd = inp.get(f"Cd_{key}")
if Cd is None or not np.isfinite(float(Cd)) or float(Cd) <= 0.0:
- from engine.pipeline.assumptions import assume
- Cd = assume("stability.viz.Cd_oxidizer", 0.6, unit="-",
- reason="solved oxidizer discharge coefficient unavailable for the droplet "
- "velocity; sharp-edged-orifice value")
- Cd = float(Cd)
- v_drop = Cd * float(np.sqrt(max(2.0 * eta * Pc / rho_O, 1.0)))
- tau_vap = inp["tau_conv_O"]
+ Cd = assume(f"stability.viz.Cd_{side}", 0.6, unit="-",
+ reason=f"solved {side} discharge coefficient unavailable for the droplet "
+ f"velocity; sharp-edged-orifice value")
+ v_drop = float(Cd) * float(np.sqrt(max(2.0 * eta * Pc / rho, 1.0)))
+
L_vap = v_drop * tau_vap if np.isfinite(tau_vap) else float("nan")
x_max = float(max(L_ch, L_vap if np.isfinite(L_vap) else L_ch) * 1.1)
xs = np.linspace(0.0, x_max, n_pts)
- # d^2(x)/d0^2 = 1 - x/L_vap (linear in x under d^2-law at constant v_drop), clipped at 0
- d2 = np.clip(1.0 - xs / L_vap, 0.0, 1.0) if (np.isfinite(L_vap) and L_vap > 0) else np.ones_like(xs)
+ d2 = (np.clip(1.0 - xs / L_vap, 0.0, 1.0)
+ if (np.isfinite(L_vap) and L_vap > 0) else np.ones_like(xs))
return {
- "d2_profile": [[float(x), float(y)] for x, y in zip(xs, d2)],
- "L_vap_m": float(L_vap), "L_ch_m": float(L_ch),
- "tau_conv_s": float(inp["tau_conv_O"]), "tau_sens_s": float(inp["tau_sens"]),
+ "stream": key, "fluid": fluid, "phase": phase,
"smd_um": float(D32 * 1e6), "smd_band_um": [float(D32 * 0.8e6), float(D32 * 1.2e6)],
+ "tau_conv_s": float(tau_vap),
+ "L_vap_m": float(L_vap), "L_ch_m": L_ch,
"vaporized_in_chamber": bool(np.isfinite(L_vap) and L_vap <= L_ch),
+ "d2_profile": [[float(x), float(y)] for x, y in zip(xs, d2)],
}
+def _vaporization_profile(inp: Dict[str, Any], Pc: float, n_pts: int = 40) -> Dict[str, Any]:
+ """Viz #5: droplet decay along the chamber, for BOTH streams.
+
+ The top-level keys (``L_vap_m``, ``smd_um``, ``tau_conv_s``, ``vaporized_in_chamber``) describe
+ the **rate-limiting** stream — the one that paces the burn — not the oxidizer. They used to be
+ hardwired to the oxidizer, which is right only when the oxidizer happens to be the slower
+ vaporizer. On LOX/methane it is (3.7 ms vs 2.9 ms) so the card read correctly by luck; on
+ LOX/ethanol it is not (13 ms vs 25 ms), and the card reported a 211 mm vaporization length for
+ LOX while ethanol -- the stream actually setting the lag -- was far worse. The health radar
+ scores off these keys, so it was scoring the wrong stream too.
+ """
+ per_stream = [_stream_vaporization(inp, Pc, k, n_pts) for k in ("O", "F")]
+ rl = str(inp.get("rate_limiting_stream", "O"))
+ lead = next((s for s in per_stream if s["stream"] == rl), per_stream[0])
+ # A gas stream can never be the one to plot; fall back to the liquid if it somehow is.
+ if lead.get("L_vap_m") is None:
+ lead = next((s for s in per_stream if s.get("L_vap_m") is not None), lead)
+
+ out = dict(lead)
+ out.pop("note", None)
+ out["streams"] = per_stream
+ out["rate_limiting_stream"] = lead["stream"]
+ out["tau_sens_s"] = float(inp["tau_sens"])
+ if lead.get("smd_um") is None:
+ out["smd_um"] = float(inp["D32_O"] * 1e6)
+ out["smd_band_um"] = [float(inp["D32_O"] * 0.8e6), float(inp["D32_O"] * 1.2e6)]
+ return out
+
+
def _sensitivity(inp: Dict[str, Any]) -> Dict[str, Any]:
"""n / chi sensitivity bands for the acoustic limiting-mode growth rate (cheap sweep)."""
D_ch, L_ch, gas, coeffs = inp["D_ch"], inp["L_ch"], inp["gas"], inp["damping_coeffs"]
@@ -188,7 +226,7 @@ def mode_alpha(name):
def _diagnostics(state: str, chug_margin: float, acoustic_margin: float, gate_threshold: float,
limiting: Optional[str], chug_rich: Dict[str, Any], ac: Dict[str, Any],
- vap: Dict[str, Any]) -> Dict[str, Any]:
+ vap: Dict[str, Any], fallbacks: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Turn the rich quantities into a verdict, findings, and design actions tied to the
sensitivity sliders (η_inj, SMD, n, χ). Derived from the SAME numbers the cards render,
so the headline can never disagree with the charts."""
@@ -274,12 +312,25 @@ def _diagnostics(state: str, chug_margin: float, acoustic_margin: float, gate_th
headline = (f"Unstable risk — {limiting or 'a mode'} is driven. "
"Change the design before hot fire.")
- fb = _fallbacks_used()
+ fb = fallbacks
if fb:
names = ", ".join(str(f.get("name", "?")) for f in fb[:3])
more = "…" if len(fb) > 3 else ""
+ # Say where the missing values live. "Load a propellant preset" was printed for every
+ # fallback including feed-line lengths and chamber geometry, which no propellant preset
+ # supplies -- advice that cannot work reads as noise and gets ignored.
+ kinds = {("propellant" if ".fluids." in str(f.get("name", "")) else
+ "plumbing" if ".feed." in str(f.get("name", "")) else
+ "model") for f in fb}
+ hints = []
+ if "propellant" in kinds:
+ hints.append("load a propellant preset for the fluid properties")
+ if "plumbing" in kinds:
+ hints.append("set feed_system lengths/bores for the plumbing")
+ if "model" in kinds:
+ hints.append("the rest are model calibration defaults")
assumptions_note = (f"{len(fb)} physics input(s) fell back to recorded defaults "
- f"({names}{more}). Load a propellant preset for measured values.")
+ f"({names}{more}). " + "; ".join(hints).capitalize() + ".")
else:
assumptions_note = "Config fully specified the stability physics — no fallbacks used."
@@ -303,6 +354,17 @@ def build_rich_report(config, Pc: float, MR: float, mdot_total: float, cstar: fl
cg: Any, *, gate_threshold: float = 1.05,
overrides: Optional[Dict[str, float]] = None) -> Dict[str, Any]:
"""Assemble the full rich stability payload (plan §A5 schema). <=5 s."""
+ from engine.pipeline import assumptions as _assumptions
+ with _assumptions.scope() as _used_here:
+ return _build_rich_report(config, Pc, MR, mdot_total, cstar, gamma, R, Tc, diagnostics, cg,
+ gate_threshold=gate_threshold, overrides=overrides,
+ used_here=_used_here)
+
+
+def _build_rich_report(config, Pc: float, MR: float, mdot_total: float, cstar: float,
+ gamma: float, R: float, Tc: float, diagnostics: Dict[str, Any],
+ cg: Any, *, gate_threshold: float, overrides: Optional[Dict[str, float]],
+ used_here: Dict[str, Any]) -> Dict[str, Any]:
inp = analysis.build_stability_inputs(
config, Pc, MR, mdot_total, cstar, gamma, R, Tc, diagnostics, cg, overrides=overrides,
)
@@ -356,6 +418,7 @@ def build_rich_report(config, Pc: float, MR: float, mdot_total: float, cstar: fl
vap = _vaporization_profile(inp, Pc)
sens = _sensitivity(inp)
+ fallbacks = _fallbacks_used(used_here)
radar = _radar(chug_margin, ac, vap, gate_threshold, inp["acoustic_gate_alpha_offset"])
min_margin = float(min(chug_margin, acoustic_margin))
@@ -364,7 +427,7 @@ def build_rich_report(config, Pc: float, MR: float, mdot_total: float, cstar: fl
else "marginal" if min_margin >= 0.95 else "unstable")
limiting = "chug" if chug_margin <= acoustic_margin else ac.get("limiting_mode")
diag = _diagnostics(state, chug_margin, acoustic_margin, gate_threshold, limiting,
- chug_rich, ac, vap)
+ chug_rich, ac, vap, fallbacks)
return {
"summary": {"state": state, "min_margin": min_margin,
@@ -395,6 +458,8 @@ def build_rich_report(config, Pc: float, MR: float, mdot_total: float, cstar: fl
"dP_reg_max_psi": float(streams[0].regulator.max_excursion_pa / _PA_PER_PSI),
"eta_inj_O": inp["eta_inj_O"], "eta_inj_F": inp["eta_inj_F"],
"smd_O_um": float(inp["D32_O"] * 1e6),
+ "smd_F_um": float(inp["D32_F"] * 1e6),
+ "rate_limiting_stream": inp.get("rate_limiting_stream"),
"mach_nozzle_entrance": float(inp["mach_nozzle_entrance"]),
"contraction_ratio": float(inp["contraction_ratio"]),
"feed_length_O_m": float(inp["feed_length_O"]), "feed_length_F_m": float(inp["feed_length_F"]),
@@ -411,15 +476,25 @@ def build_rich_report(config, Pc: float, MR: float, mdot_total: float, cstar: fl
"lag_breakdown": lag_break,
# Every recorded silent-default substitution this process has made (P2c registry).
# Empty list = config fully specified the physics. The hardcoded-Cd bug class, surfaced.
- "fallbacks_used": _fallbacks_used(),
+ "fallbacks_used": fallbacks,
},
"sensitivity": sens,
}
-def _fallbacks_used():
+def _fallbacks_used(used_here: Optional[Dict[str, Any]] = None):
+ """Substitutions made by THIS evaluation.
+
+ ``used_here`` is the collection from the ``assumptions.scope()`` wrapped around the report. The
+ old form read the process-global registry, so a report inherited every fallback the process had
+ ever recorded -- after a methalox run, an ethalox run with a complete preset still announced the
+ previous propellant's missing fields. Falls back to the global registry only when called without
+ a scope (kept so an external caller does not break).
+ """
try:
- from engine.pipeline.assumptions import fallbacks_used
- return fallbacks_used()
+ from engine.pipeline import assumptions
except ImportError:
return []
+ if used_here is not None:
+ return assumptions.as_list(used_here)
+ return assumptions.fallbacks_used()
diff --git a/EngineDesign/engine/pipeline/time_varying_solver.py b/EngineDesign/engine/pipeline/time_varying_solver.py
index fd367a861..cf116e9b1 100644
--- a/EngineDesign/engine/pipeline/time_varying_solver.py
+++ b/EngineDesign/engine/pipeline/time_varying_solver.py
@@ -141,9 +141,18 @@ def __init__(
self,
config: PintleEngineConfig,
cea_cache: Any,
+ P_ambient: Optional[float] = None,
):
"""
Initialize the coupled time-varying solver.
+
+ ``P_ambient`` is the back pressure the nozzle fires into, in Pa. Explicit wins;
+ otherwise it comes from ``environment.elevation`` through the same standard
+ atmosphere the steady solve uses; only with neither is it sea level. This used to be
+ hardcoded to 101325 Pa inside ``solve_time_step`` while ``PintleEngineRunner.evaluate``
+ derived it from the site, so the two paths disagreed about the same engine by exactly
+ ``(101325 - P_a) * A_exit`` -- 61.35 N on the 6.5 kN ethalox at 626.67 m -- and every
+ time-series thrust, impulse and burn time was low by the pad's altitude.
Parameters:
-----------
@@ -154,6 +163,15 @@ def __init__(
"""
self.config = config
self.cea_cache = cea_cache
+ if P_ambient is not None:
+ self.P_ambient = float(P_ambient)
+ else:
+ self.P_ambient = 101325.0
+ env = getattr(config, "environment", None)
+ elevation = getattr(env, "elevation", None) if env is not None else None
+ if elevation is not None and elevation >= 0:
+ from engine.core.runner import compute_ambient_pressure_from_elevation
+ self.P_ambient = float(compute_ambient_pressure_from_elevation(elevation))
# Ensure chamber_geometry exists
cg = ensure_chamber_geometry(config)
@@ -287,13 +305,8 @@ def solve_time_step(
# as geometry evolves. This was missing before!
from engine.core.chamber_profiles import calculate_chamber_intrinsics
# Get ambient pressure from config if available, otherwise use fallback (0.9 * 1 atm)
- P_back = None
- if hasattr(self.config, 'environment') and self.config.environment is not None:
- elevation = getattr(self.config.environment, 'elevation', None)
- if elevation is not None:
- # Use standard atmosphere model
- from engine.core.runner import compute_ambient_pressure_from_elevation
- P_back = compute_ambient_pressure_from_elevation(elevation)
+ # One ambient for the whole solver -- the intrinsics and the thrust must see the same sky.
+ P_back = self.P_ambient
# If still None, fallback will be used (0.9 * 1 atm)
chamber_intrinsics = calculate_chamber_intrinsics(
Pc=Pc,
@@ -616,7 +629,7 @@ def solve_time_step(
# Calculate thrust with shifting equilibrium
# CRITICAL: Pass reaction progress so shifting equilibrium accounts for time-varying chemistry
- Pa = 101325.0 # Ambient
+ Pa = self.P_ambient # site ambient, same source as the steady solve (see __init__)
thrust_results = calculate_thrust(
Pc,
diff --git a/EngineDesign/frontend/src/components/ForwardMode.tsx b/EngineDesign/frontend/src/components/ForwardMode.tsx
index c26aae155..e6b5e74e0 100644
--- a/EngineDesign/frontend/src/components/ForwardMode.tsx
+++ b/EngineDesign/frontend/src/components/ForwardMode.tsx
@@ -1,4 +1,5 @@
import { useState, useCallback, useEffect } from 'react';
+import { engineIdentity } from '../lib/engineIdentity';
import { evaluate } from '../api/client';
import type { RunnerResults, EngineConfig } from '../api/client';
import { ResultsDisplay } from './ResultsDisplay';
@@ -37,6 +38,11 @@ export function ForwardMode({ config }: ForwardModeProps) {
stabilityOverrides: [stabilityOverrides, setStabilityOverrides],
});
+ // What makes a displayed result belong to a DIFFERENT engine (see lib/engineIdentity).
+ // Seeded from the current config, so the first render adopts rather than clearing.
+ const identityKey = engineIdentity(config);
+ const [lastIdentity, setLastIdentity] = useState(identityKey);
+
// Update defaults when config changes
useEffect(() => {
if (config) {
@@ -51,6 +57,28 @@ export function ForwardMode({ config }: ForwardModeProps) {
}
}, [config]);
+ // Drop a result that describes the previous propellant or injector.
+ //
+ // The tabs stay mounted (hidden, not unmounted), so nothing cleared `results` on a switch: after
+ // methalox -> ethalox the Combustion stability panel kept showing methane's frequencies, lags,
+ // radar and verdict, while the tank pressures beside it had already moved to the new config. It
+ // read as a report about the engine now on screen. The sensitivity overrides survived too, so a
+ // methane-tuned SMD was silently applied to ethanol on the next evaluation.
+ //
+ // Done during render rather than in an effect: this is React's "adjusting state when a prop
+ // changes" pattern, which re-renders before committing instead of painting the stale panel once
+ // and then clearing it. An effect would show the previous propellant's numbers for a frame.
+ if (config && identityKey !== lastIdentity) {
+ setLastIdentity(identityKey);
+ setResults(null);
+ setAmbientPressure(null);
+ setError(null);
+ setStabilityOverrides({});
+ setDesignWarning(
+ 'Propellant or injector changed — previous results cleared. Run Evaluate to analyse the new engine.',
+ );
+ }
+
const handleEvaluate = useCallback(async (overridePatch?: StabilityOverrides) => {
const lox = parseFloat(loxPressure);
const fuel = parseFloat(fuelPressure);
diff --git a/EngineDesign/frontend/src/components/Layer1Optimization.tsx b/EngineDesign/frontend/src/components/Layer1Optimization.tsx
index 186404815..a6ef93ed8 100644
--- a/EngineDesign/frontend/src/components/Layer1Optimization.tsx
+++ b/EngineDesign/frontend/src/components/Layer1Optimization.tsx
@@ -1714,26 +1714,51 @@ export function Layer1Optimization({
)}
+ {/* This number is NOT a residual. Once every requirement is met it is
+ the shaping terms that remain -- chamber mass (W_MASS*(m/m_ref)^2),
+ SMD, tank match -- and those never reach zero, so a fully converged
+ design floors in the 1e3 range. Colouring it green<=1 / red>10 painted
+ every real design red and read as "unconverged". Colour and verdict
+ come from the physics residual instead; the objective is reported
+ with the term that dominates it named. */}
{
const v = results.convergence_info.best_objective;
return typeof v === 'number' && Number.isFinite(v) ? formatLayer1ResidualScalar(v) : '-';
})()}
isText
- color={
- (results.convergence_info.best_objective ?? 0) <= 1
- ? 'green'
- : (results.convergence_info.best_objective ?? 0) <= 10
- ? 'yellow'
- : 'red'
- }
+ color={(() => {
+ const v = results.convergence_info.best_objective;
+ if (typeof v !== 'number' || !Number.isFinite(v) || v >= 1e6) return 'red';
+ const rms = results.convergence_info.primary_relative_residual?.rms_primary;
+ if (typeof rms === 'number' && Number.isFinite(rms)) {
+ return rms <= 0.01 ? 'green' : rms <= 0.05 ? 'yellow' : 'red';
+ }
+ return 'green';
+ })()}
footnote={(() => {
const v = results.convergence_info.best_objective;
if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) {
return 'Weighted sum of squared penalties; infeasible runs floor at ~1e6.';
}
- return `Weighted penalty sum (W×term²); log10 ≈ ${Math.log10(v).toFixed(3)}. Sum of breakdown terms ≈ objective when feasible.`;
+ if (v >= 1e6) return 'Infeasible: no candidate cleared every hard constraint.';
+ const bd = results.convergence_info.best_objective_breakdown ?? {};
+ let topKey = '';
+ let topVal = 0;
+ for (const [k, raw] of Object.entries(bd)) {
+ if (!k.endsWith('_penalty') || k === 'infeasibility_penalty') continue;
+ const x = typeof raw === 'number' ? raw : NaN;
+ if (Number.isFinite(x) && x > topVal) {
+ topVal = x;
+ topKey = k;
+ }
+ }
+ const share =
+ topKey && topVal > 0
+ ? ` ${((100 * topVal) / v).toFixed(1)}% of it is ${topKey.replace(/_penalty$/, '').replace(/_/g, ' ')}.`
+ : '';
+ return `Not a residual: shaping terms (chamber mass, SMD, tank match) never reach 0, so a converged design floors near 1e3.${share} Convergence is the physics residual below.`;
})()}
/>
[
+ Math.max(5, Math.round(v * 0.35)),
+ Math.round(Math.max(v * 1.8, 60)),
+ ];
+ const [smdMin, smdMax] = smdRange(data.assumptions.smd_O_um);
+ const [smdFMin, smdFMax] = smdRange(data.assumptions.smd_F_um ?? data.assumptions.smd_O_um);
const nVal = overrides.n_interaction ?? data.assumptions.n;
const chi = overrides.chi_acoustic ?? data.assumptions.chi_acoustic;
const lagModel = overrides.time_lag_model ?? data.assumptions.time_lag_model ?? 'leonardi_dtl';
@@ -106,11 +119,30 @@ export function StabilityPanel({
{interactive && onOverridesChange && (
- setOverride({ eta_inj_O: v })} />
- setOverride({ smd_um: v })} />
- setOverride({ n_interaction: v })} />
- setOverride({ chi_acoustic: v })} />
+ setOverride({ eta_inj_O: v })} />
+ setOverride({ eta_inj_F: v })} />
+ setOverride({ smd_um: v })}
+ />
+ setOverride({ smd_F_um: v })}
+ />
+ setOverride({ n_interaction: v })} />
+ setOverride({ chi_acoustic: v })} />
+
+ ★ marks the rate-limiting stream — the one whose lag sets the chug and acoustic verdicts.
+ Atomizing the other one finer buys nothing.{' '}
+
+ n and χ are combustion-response calibration constants, not propellant data: they do not
+ change when you switch propellants, and χ is the single largest modelling uncertainty
+ here. Sweep them rather than trusting one value.
+
+