pid-designer overhaul, feed-twin and the physics library into the tree, EngineDesign solver and stability - #57
Merged
Merged
Conversation
Three separate faults, all reported as "it doesn't work": **The palette dropped nothing, and Take needed pressing twice.** Taking the checkout reloads the diagram before flipping `held`, so the canvas remounts while it is still read-only. Every handler captured `readOnly === true` at that moment and kept it, because none of them listed it as a dependency. Whether a handler ever got a corrected copy came down to whether some unrelated dependency happened to change afterwards -- `onDrop` was rebuilt when `onInit` set the ReactFlow instance, and won or lost that race -- which is why the workaround going round the team was take, release, take again. Guards now read `readOnlyRef.current`, which cannot be a render behind the chip that says you are editing. `onDrop` also bailed on a null `rfInst` for the frame or two after the remount: precisely when someone has just enabled editing and is reaching for the palette. It takes `screenToFlowPosition` from the provider instead, which is there from first render. **Some connections drew nothing -- and were saved anyway.** `ConnectionMode. Loose` relaxes two of the three places React Flow consults a handle's type, not the third: `getEdgePosition` resolves an edge's target end against `target ∪ source` but its source end against `source` alone. Dragging from a `target` port onto another `target` port puts the port you dropped on in the source slot, where it cannot be found, so the edge went into state and into autosave and never rendered. Top-of-tank to top-of-QD was the reported case. Every port is now declared a source (`nodes/Port.tsx`), which deletes the failing quadrant rather than validating against it -- and is what a P&ID means anyway, since a pipe has no direction on a drawing. **New nodes could collide with existing ones.** The id counter was module-level and restarted at 1 each page load while saved ids did not, so adding a node to a diagram holding `node_1…node_9` minted `node_1` again -- two components a reader cannot tell apart, and node ids are the tags feed-twin will key on. `ids.ts` seeds the counters from whatever was loaded, on every path that replaces the canvas. The gating audit gains a third check, that handlers guard on the ref rather than the closure; it fails against the code before this commit.
… shape
Double-click a symbol and set what it actually is: tank pressure and
temperature, regulator setpoint and droop, relief set and reseat, check valve
cracking pressure, chamber pressure and injector drop. The request was "anything
that has a pressure setting should have a config", so the answer is a table
rather than a dialog per component -- `spec.ts` declares what each kind of
hardware has and `ConfigDialog` renders whatever it finds, which is the same
decision `feedtwin/model/components.toml` makes and keeps that promise true
after the next four components land.
A value is stored as `{value, unit, source, reference}`, which is
`feedtwin.model.Param` verbatim, so the Phase-11 reader lifts numbers across
rather than re-typing them. Two consequences are deliberate:
- **Provenance is a field.** A number with no stated source records as
`default`, which reads as "nobody has looked", not as agreement. It is what
lets a run report separate the nine measured inputs from the two guesses.
- **There is no psig.** Gauge is a reference, not a unit, and a psig value
stored as psi is one atmosphere low everywhere downstream, silently. The
units offered are exactly those `feedtwin.model.units` registers, and every
pressure field says "absolute, not gauge" beside it.
A blank field stays absent rather than becoming zero, so an unfilled parameter
never passes for a measured nought.
New in the palette:
- **Engine** — injector and chamber as one symbol. Two were the wrong seam:
the face is where the feed system ends, the Pc behind it is the boundary the
feed runs against, and the pair is built, tested and replaced together.
- **Manifold** — one feed in, a configurable number of ports out, so a tank
feeding eight things is a block with ports rather than eight edges leaving
one pixel.
- **Tanks take a port count per end**, for the same reason: a real lid carries
pressurant, vent, burst disc and instrumentation.
- **Transducers and QDs split into two entries each** — high/low press, and
ground/rocket half. Calling both halves "QD" hides the one mistake that
matters, and the QD side is what the pairing check will read.
Two states are now drawn rather than only stored: a valve's NO/NC, because
which way it fails is what a procedure review looks for first, and a dome
regulator's control port, because a line run to the dome by mistake is a
regulator held at whatever that line happens to be.
**Fluid is a species now, declared once and inherited.** The palette offered fuel, lox, pressurant and default -- four colours, not four fluids, and "fuel" never said which fuel. The species are the ones `feedtwin/props/species.toml` declares, under the same names, so a drawing names something the property layer can resolve. You set ethanol on one tank and LOX on another; every line, valve and fitting downstream inherits it. Sixty entries become four, and the four cannot drift out of step with the drawing because the answer is recomputed from it rather than stored. Getting the propagation rules right took two corrections, both found by drawing the ordinary case: - **A tank is a source, not a junction.** Nitrogen arriving at a LOX tank was reported as fuel and oxidiser meeting. It is the most common arrangement in the system -- the ullage is nitrogen, the outlet is LOX, and separating them is what the tank is *for*. A check that fires on that teaches people to ignore it. - **A tank's top ports are its ullage side.** With the above fixed, the LOX tank pushed LOX back up its own pressurant line and the regulator feeding it came out blue. Ports are keyed on handle id, not screen position, so rotating a tank does not change which is which -- and a bottle connected only by its top is still painted, on a second pass, rather than left blank on a technicality. What is left is a check worth having: two fluids at an ordinary component means a line drawn to the wrong port, and it is reported rather than blended. Meeting them at an engine is not. **Lines carry their hardware.** An edge had a colour and nothing else, so length, bore, roughness and lumped fitting K -- where most of the pressure drop in a feed system actually is -- had nowhere to live, and a reader could import the topology and still not compute a single pressure. Double-click a line and it is a hardline, a flex hose, a bend or a fitting, with the parameters `components.toml` declares for each. A hose is not a rougher pipe: its crimped ends dominate the loss on a short run and its bend radius is usually what constrains the routing. **Every component takes a part number**, and that is the more important half. The catalogue already holds the datasheet and whatever the bench measured, so a drawing that re-types Cv is a second copy that will disagree with the first. Name the part, leave the fields blank, and fill one only to override that part for one installation. **Quick disconnects** pick the half they mate with, or say they need no pair, so the checks panel has something to check rather than something to guess. They come in hydraulic and fluid, drawn differently -- dashed body, and the side marked R or G on the symbol -- because which half is which decides whether a fill line can actually be disconnected. Right-click now sets an explicit colour on anything, from swatches or a hex code, replacing the per-edge fluid menu that fluid inheritance made redundant. Clearing an override hands the symbol back to its fluid colour rather than leaving it grey.
Placing a thermocouple meant wiring it into the graph -- tank outlet to RTD, RTD onward to whatever was next -- so a probe could only go somewhere by breaking a line and rejoining it. Drop one on a tank, a valve or a line now and it attaches, with a faint leader and no edge. It was wrong in the model as well as in the drawing. `feedtwin.solve` peels a probe off as a `DeadEnd` *because* it carries no flow, so every wired-in sensor was two unknowns and a branch standing for a stub that is not there. A sensor is a measurement point -- "report the pressure here" -- and `attachedTo` says exactly that, which is also what somebody meant when they dropped it on a tank. Two things learned while building it: - **Hit-testing had to come off the DOM.** `elementsFromPoint` at the centre of a tank returns React Flow's drag surface, which it paints above the node layer. Rectangles and a distance-to-segment in flow coordinates answer the same question from data that is there whether or not anything is rendered. - **A probe is placed clear of its host**, not under the pointer. Dropped where the cursor was it covers the symbol it is attached to, and the reason to attach rather than connect is that the drawing gets easier to read. Dragging a component brings its instruments with it. That is a delta applied in `onNodesChange` rather than React Flow's `parentId`, which would make a child's stored position relative to its parent and require parents ordered ahead of children -- two migrations of every saved diagram for a behaviour that is four lines.
A badge in the corner with a count, and a panel listing what it found. Clicking a finding selects and centres what it is about, because "QD-R1 has no mating half" is only useful if you can then find QD-R1. What it checks, and why each earns its place: - **Disconnect pairing.** A flight half with nothing to mate to is an error -- that is a fill line nobody can disconnect, and it is invisible on a drawing until somebody is standing at the pad. Two halves on the same side, a pair whose other half was deleted, and a pairing only one of the two agrees with are all caught. A half marked as needing no pair is left alone. - **Two fluids at one component**, which on a drawing is a line run to the wrong port. - **Boundary conditions.** A tank with no pressure, temperature or fluid, and an engine with no chamber pressure, are the two ends a feed solve runs between. - **Probes wired into the flow path**, which a solver has to peel off as dead ends. - **Values nobody has established** — every parameter recorded as estimated or unchecked, which is the list a design review should be reading. The severities matter more than the checks. A drawing mid-edit is *supposed* to be incomplete, so unsized lines and unassigned fluids are notes and the badge does not count them; a check that says 40 on every drawing is a check people learn to dismiss, and then they dismiss the ones that matter too. An unpaired ground half is a note for the same reason, while an unpaired flight half is an error. Also fixes a fluid-propagation bug the tests found: the ullage pass, which exists so a bottle connected only by its top port still seeds its own contents, was walking tank top ports as an authority rather than a fallback -- so a LOX tank pushed LOX back up its pressurant line and the regulator feeding it was reported as a fluid conflict. It now fills only where nothing is known and never raises a conflict.
Tabs along the bottom, where a spreadsheet puts them, because these are sheets of one document rather than a second answer to "which diagram". **The graph is whole; only the view is filtered.** That is the whole reason to do this with pages instead of two diagrams: fluid still propagates across the umbilical, and the checks panel still sees both halves of every disconnect pair. A pairing check that only looked at the page you were on would report every correct pair as broken. A page lives on the components rather than in a container of its own, so a component is on exactly one page by construction and there is no second structure to keep in step with the first. Moving a selection takes its clipped instruments along -- a transducer left behind while its tank moves is measuring nothing, and nobody would have meant that. A line between two pages is not drawn on either, and the checks panel says so. That is a drawing convention rather than a limitation: what crosses the umbilical is a disconnect pair, and a line the reader has to imagine leaving one page and arriving on another is how off-page connectors were invented. A pair whose halves sit on the same page gets a note for the same reason. Also fixes a layout overflow this made visible: the canvas sized itself from the viewport (`h-[calc(100vh-56px)]`) while its parent had padding, so it ran 32 px past the bottom of the window. Nothing had been down there to notice until the page tabs were. It fills its parent now.
A labelled, resizable, dashed box drawn round part of the diagram, for the grouping a solver has no opinion about: this panel, that skid, everything inside the blast shelter. It holds no parameters and contributes nothing to the graph, which is the point -- it is for the reader. Two details are what make it usable rather than in the way: - **The middle takes no clicks.** A region is large and sits over other components, and a box that swallowed a click on the valve underneath it would be abandoned within a minute. Only the border, the title and a grab strip along the top edge are interactive. - **It renders behind.** The drawing reads as components with a box around them rather than a box with components on top. Regions are also excluded from being an instrument's host -- a probe dropped inside a section box means the valve it landed on, not the box -- and from the "no fluid reaches this" check, since annotation is not plumbing.
**Orthogonal, not smoothstep.** A P&ID is drawn with square corners; 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. Ends that face different ways get a plain corner instead, which has nothing to move and no longer pretends otherwise. **The crossbar drags.** Automatic routing puts it halfway between the two ends, which is exactly where the next line also wants to be: a tank feeding three things produced three runs stacked on the same pixels. Grab the handle and the crossbar moves; the offset is stored on the line, so a routing somebody chose survives a reload rather than being recomputed back into the same mess. The grip needs `pointer-events: all` said out loud, because React Flow sets `visibleStroke` on an edge and a shape with a fill and no stroke is invisible to the pointer at any size. **Crossing lines are still not joined, and the panel now says how many there are.** Nothing infers a connection from two paths overlapping: a crossing is usually one line passing over another, and guessing wrong either invents a leak path or hides a real one. Click a line to drop a junction where two are meant to meet; drag the crossbar where they are not. Saying the count out loud is what stops people assuming it works either way.
The README described a drawing tool. It is still that, and it is also the input to feed-twin, so the things that decide whether a solve can read it -- the parameter record and its provenance, the absence of psig, part numbers over typed datasheet values, fluid inheritance, instrument attachment, pages, and the checks panel -- are worth stating where somebody will find them.
`feedtwin.solve.Node` documents its id as "the tags on the P&ID". A tag is therefore not a caption: it is the name one piece of hardware answers to in a solve, in a run report and in a procedure. Nothing stopped two components sharing one, and the drawing is where that is cheap to notice and expensive everywhere after. Duplicates are a check; untagged components are a note, since most drawings have a few at any moment.
**Supplies are symbols.** A K-bottle (GN2 or helium) and a dewar (LN2 for cold flow, LOX for a hotfire), each declaring its own fluid so everything downstream inherits it without a trip to the config. They are the boundary of the whole system — a solve starts at a bottle pressure — and a reader tracing a line backwards should arrive somewhere that says "this is where it comes from" rather than at another cylinder. Bottle pressure is one field with the two standard fills named in its description rather than a 2K/6K toggle beside it, because two representations of one number is the drift this whole schema is arranged to avoid. **A valve open on one side is a vent to atmosphere**, drawn with the open-to-atmosphere mark and noted in the checks panel. Not a symbol you place: a vent valve is drawn as a valve with nothing downstream, which is what people already draw, so the tool reads the drawing rather than asking for a second statement of it. The rule is deliberately narrow — **valves only, and only with exactly one connected port**. Widening it to any component with a spare port was wrong: a spare manifold port or a blanked tee branch is a plug, plugs are not drawn on a P&ID, and inferring atmosphere at one would model a tank venting through a fitting that holds pressure. A valve with no connections at all is undrawn, not a vent, which is most valves for most of the time a diagram is being built. The one exception that looked real — a fill valve, where flow goes in rather than out — closed itself when supplies became symbols. The fill valve now has a dewar on its far side, so nothing fires. There is a test pinning exactly that.
`edgePage`, `ventsByNode` and `useFluidMap` were written for callers that do not exist. Speculative API is the kind of thing that is still there in a year, subtly wrong, with someone assuming it works because it is exported. `portOffsets` and `manifoldLength` stay but stop being exported — they are one symbol's own helpers, not a vocabulary.
chamber_geometry.design_MR / design_pressure / design_thrust are supposed to
describe the design the config represents. Nothing ever wrote them.
config_schemas builds them with `getattr(chamber, 'design_MR', 2.55)`, so an
optimised config carried its template's values forward forever. On a real
emitted design they read MR 2.55 / 350 psi / 7000 N for an engine actually
solved at O/F 1.65 / 416 psi / 7200 N.
Not cosmetic: backend/routers/geometry.py reads design_MR and passes it
straight to solve_chamber_geometry_with_cea, so the Chamber Geometry tab drew
the contour at the stale mixture ratio -- and 2.55 sits OUTSIDE that config's
CEA cache range (MR_range [1.0, 2.5]), i.e. extrapolating past the table edge
in the one place the cache has no data.
_layer1_stamp_design_point() now writes the solved MR / Pc / F onto the config
at the point Layer 1 hands it back, mirrors them onto the legacy `chamber`
section for readers that still fall back to it, skips non-finite values so a
failed evaluate cannot overwrite a good design point with NaN, and warns when
the achieved MR falls outside combustion.cea.MR_range. Verified on a live run:
2.55 / 350 psi / 7000 N in, 1.6461 / 416.1 psi / 7200 N out, matching the
solver exactly.
Three regression tests cover it -- stamping, the range warning, and the
non-finite guard -- because the root problem was that nothing checked these
fields at all.
Also deletes engine/pipeline/comprehensive_geometry_sizing.py: 495 lines, zero
importers, unchanged since the initial commit, and carrying an IndentationError
that means it has NEVER been importable. It is recoverable from history if it
turns out to be wanted.
Audited for the same class of staleness and found two more, NOT fixed here
because they belong to other layers and are inert on the configs I can see:
- regen_cooling.chamber_inner_diameter / channel_length are synced only by
chamber_optimizer.py, not Layer 1, so they still read 0.08491 m / 0.18162 m
against an actual 0.127 m / 0.20337 m chamber. Harmless while
regen_cooling.enabled is false; wrong channel sizing the moment it is not.
- pressure_curves.initial_lox/fuel_pressure_pa (537.3 / 523.7 psi) disagree
with lox_tank/fuel_tank.initial_pressure_psi (548.6), because Layer 2 wrote
the curves before Layer 1 last moved the tanks. An ordering problem, not a
missing write.
Separately verified: all 379 config schema fields are referenced somewhere, so
the config is not carrying dead knobs; and comprehensive_geometry_sizing was
the only module in engine/ or backend/ that fails to import.
Verified: pytest 472 passed / 84 skipped with only the 4 pre-existing failures;
accelerator parity 16/16.
A manifold is one symbol on a drawing and a plenum plus one branch per port in a solve, so "four outlets" was never enough — which port feeds the engine and which is a transducer tapping is what a reader and a solver both need, and it lived nowhere. Every port on a manifold or a tank now takes a name and a kind: - **flow** — carries fluid, and what a line attaches to. - **instrument** — a real tapping that carries no flow, drawn hollow and small so it does not read as a feed. - **plug** — blanked off, and therefore **not drawn**. A P&ID does not draw plugs, which is exactly why this is a port kind rather than a symbol you place: the port is simply not there until somebody says it is. Only ports that differ from the default are stored, so a manifold with four plain outlets saves nothing. **Port ids stop renaming themselves.** Index zero keeps the bare prefix — `t`, not `t1` — so raising a tank's port count from one to two no longer renames the port every existing line is attached to. It did, and that silently orphaned them: React Flow cannot place a line whose handle is gone, so it was saved and never drawn. That is the same invisible-edge failure this branch opened by fixing, arriving by a different route. A check now catches the rest of that class outright: **a line attached to a port that is gone** is an error, whether the port was removed by lowering a count or plugged after the line was drawn. Both look harmless at the time. Also fixes a race in dragging. Instruments clipped to a component were moved by a delta measured against the last *rendered* snapshot, and React Flow emits position changes faster than React re-renders during a drag — so several arrived against the same stale base and the probes lagged and then jumped. The changes and the follow are now one updater computing deltas against the array it is about to change.
The initial tank pressure exists twice, owned by different layers and never reconciled: Layer 1 writes lox_tank/fuel_tank.initial_pressure_psi (layer1_static_optimization.py:2435), Layer 2 writes pressure_curves.initial_lox/fuel_pressure_pa (layer2_pressure.py:312). Re-running Layer 1 moves the tanks and silently leaves the curves describing the previous design. Measured on a real emitted config: LOX 548.6 psi vs a 537.3 psi curve start (11.3 psi), fuel 548.6 vs 523.7 (24.9 psi). This matters more than it looks. Tank pressure is the UPSTREAM BOUNDARY CONDITION for the feed-system twin, and per docs/adr/0001 EngineDesign's optimizer imports lib/feedtwin directly for Layer X rather than calling a service. Two disagreeing values for one boundary condition is precisely what silently poisons a twin, so Layer 1 now says so at the end of every run. Detection only. Which layer should own the value is a design call and guessing it here would be worse than the warning.
Resizing one worked on screen and was undone by a reload. React Flow reports a resize as a `measured` dimension, and `toStored` strips `measured` on the way out -- correctly, since it is a post-layout measurement recomputed on load. So the box sprang back to the size it was dropped at, every time. The size is copied into `width`/`height`, which is the authored size and does persist. Two conditions decide when, and the second is not redundant: `setAttributes` is React Flow's own marker for "the author resized this", but the final change of a drag arrives without it, so following that flag alone stored a size one step behind what was on screen. A node that already has an authored size keeps it in step with every measurement after that; a node that never had one -- every ordinary symbol -- never acquires one, which is what stops the diagram filling up with sizes nobody asked for. Dropping a region also no longer writes `style.width`/`style.height` alongside `width`/`height`. That stored the size twice: the resizer updates the first pair and leaves the second at the drop value, so a resized box saved 200x70 next to a style still claiming 320x220, and one of the two would eventually be believed.
Two problems docs/adr/0001 names by name, both in the COPV -> regulator -> tank chain that lib/feedtwin absorbs. Fixed surgically: defaults are exactly the old literals, so nothing in EngineDesign changes behaviour unless someone overrides them. 1. Orifice geometry was hardcoded in the function body. dynamics.step() defined gamma_gas 1.4, Cd_regulator 0.7, Cd_valve 0.65, A_regulator 2e-5, A_valve_F/O 5e-5 as locals. These are real hardware numbers -- a different regulator or solenoid could not be modelled without editing code. They are now DynamicsParams fields, surfaced on ControllerConfig, and read through from_config with getattr so an older config still loads. 2. The polytropic reference state was PROCESS-GLOBAL. step() stored its reference temperatures, masses and volumes as attributes on the FUNCTION object, guarded by `hasattr(step, '_temp_initialized')`. It initialised on the first call ever made in a process and never reset, so the first trajectory stepped set the reference for every later one -- across different configs. An optimizer evaluating thousands of candidates, which is exactly the Layer X access pattern the ADR describes, would hand candidates 2..N the reference state of candidate 1. All 36 references now live on the `params` instance, which scopes them per-config. Verified: two DynamicsParams objects no longer share reference state, and nothing is left on the step function object. Four regression tests cover both fixes, including one that fails if process-global state ever returns. Also checked the other two feed fragments the ADR names, feed_loss.py and stability/chug.py: neither carries hardcoded physical constants, so they need nothing before Phase 04 collapses them into feedtwin. Verified: pytest 477 passed / 84 skipped with only the 4 pre-existing failures; robust_ddp suite 12 passed; accelerator parity 16/16.
The README described the drawing up to fluid inheritance and stopped. Port identity, the port-id rule that stops lines being orphaned, and the vent inference are all things somebody would otherwise have to read the source to discover.
Every field carried a sentence explaining it. Sixty of those made the dialogs unreadable and they were explaining the trade to people who do it for a living -- "the plenum has to fill before anything downstream sees pressure" next to a volume field is not help, it is noise. All of it is gone. If a label needs a paragraph the label is wrong. **Provenance is two options, not four essays.** "Estimate" and "Verified", where it used to say "Unchecked -- nobody has looked at this yet" on every row of every dropdown. `feedtwin.model.Param`'s four-way vocabulary still sits underneath for the run report that actually wants it. **The dialog only asks what a component has.** Fluid appears on tanks, bottles and dewars, because those hold one; a part number appears where a catalogue part exists. Asking an RTD for its fluid, its part number and a temperature range was the spec table applied without judgement -- an RTD's config is now a tag and a size. **Instruments are drawn, not plumbed.** No ports at all: a probe clips to what it measures, so four handles existed only to be dragged from by mistake. They come in two sizes, because a stand has a lot of these within a few inches of each other. Their leader now starts at the edge of the circle nearest its host rather than the centre, so it stops crossing the symbol and reading like a pipe through the probe. **One bottle and one dewar in the palette**, with the fluid set in the config and restricted to what actually turns up: GN2, helium or GOx in a bottle; LN2 or LOX in a dewar. Four palette entries that each also let you change the fluid was two ways to say the same thing. Fixes three things that were simply broken: - **The checkout cut you off mid-edit.** It expires five minutes after the last *save*, and a save only happens when content changes -- so reading, panning or thinking dropped the design out from under you. It is now kept alive on a timer while held. (`lib/stardesign-ui`, so all three design tools get it.) - **A section box could not be renamed.** The invisible drag strip was drawn after the title and swallowed the double-click. There is one interactive element now, so nothing can shadow it. - **Every value field was 18 px wide.** `w-full` in the shared input class beat the per-control widths, so a number sat in a sliver beside a full-width unit dropdown.
…ight **The checkout was being stolen, and I caused it.** Every client in dev is the same `local` user, so my own testing tab could re-take a design the moment somebody else had it -- and the heartbeat I added last round made it do that every ninety seconds, which is why Take had to be pressed again every few seconds. The heartbeat now only runs in a tab that is actually visible, so an idle window cannot win a design back off the one being typed in. **Lines to a multi-port tank all bundled at its midpoint.** React Flow measures a node's handles once, when it mounts; adding a port afterwards leaves it out of `handleBounds` and every edge on that node falls back to the node centre. Tanks, manifolds and dome regulators now ask for a re-measure when their ports change, so a line lands on the port it was drawn to. **A junction landed near the pipe rather than on it** -- it took the pointer position straight, which is up to six pixels off inside the hit area, so the two new edges ran to a node beside the line they replaced. It snaps to the nearest point on the run, corners included. **Colour is a tool now, not a hidden right-click.** A paint bucket in the top left: pick a colour, click things until you press Escape. Right-click still works. Section boxes can be painted, which previously they could not be at all. Also: - **Normally-open valves are drawn hollow**, the way an open bore is drawn. Two letters in the corner was not visible across a sheet. - **Rotating a symbol no longer rotates its tag.** The tag counter-rotates and its default position swings round to stay under the symbol; a tag you have dragged is left where you put it. - **The regulator asks what a datasheet says**: setpoint, Cv, orifice, and the supply effect as two pressures -- outlet rise per inlet drop -- rather than the dimensionless ratio nobody reads off a spec sheet. Droop, reference inlet and lockup are gone. - **Gauges and transducers connect again.** They are fittings, plumbed into a tee or a port; only temperature probes and load cells clip. - **One quick disconnect symbol per service.** Side is gone -- naming the half it mates with is what says which two go together -- and the picker filters as you type, over disconnects of the same service only.
Answers steps 1-6 of `docs/integration/pid-to-feedtwin-handoff.md`. **1. A line's panel opens on double-click** -- confirmed, and the segments UI hangs off it. **2. `dome_bias` and `dome_pressure` on PR.** `feedtwin/pid/network.py` already defaults the bias to 50 psi marked unchecked, so this is what lets somebody state the real one. `dome_pressure` is superseded when a loading regulator is drawn -- feed-twin takes that one's setpoint -- so the field is for a dome set from a panel that is not on the drawing. **3. `engineConfig` on ENGINE**, one string, which needed free-text options back. **4-6. Segments.** Ordered segments, unordered fitting bag inside each -- because bore order is worth 2.1× and same-bore fitting order is worth 0.003%. A change of bore between two segments *is* a reducer, so it is derived and shown with its K rather than being a row somebody can forget: Borda-Carnot for an expansion, Crane's form for a contraction. ΣK is live in the panel header. The 15 fitting kinds are exactly those registered in `correlations.py`, with a test pinning the count and the labels, because a kind that is not registered there has no correlation behind it and would silently price at zero. **On the through-bore table: I did not seed it.** The handoff withheld catalogue numbers rather than hand over figures it could not cite, and the same applies here -- a bore invented from memory arrives in feed-twin wearing a `source` and a `reference`, looking checked, and is wrong. `THROUGH_BORE` is declared and empty, one cited row at a time, and the dialog asks for the bore when it has nothing to offer. Tube is different and is implemented: you order tube by OD and wall, and the bore is `OD − 2 × wall`. Nothing is remembered, and it reproduces the two figures in the handoff's own sketch (10.92 mm and 7.75 mm). A derived bore is stored `source: 'default'` with a reference naming the arithmetic, so it reads as unchecked in a run report until somebody puts a caliper on the part. Everything is optional: no segments and a line behaves exactly as before. Absent means not stated, never zero.
The question behind this is "what does the computer need to calculate a line
loss, and what is the quickest honest way to put it in". The answer is one
selector, five methods, explicit precedence -- because a feed system's
resistance is known *differently* at different stages, and both ends of that
have to be first-class.
Δp vs ṁ curve > measured K > fittings > estimated K > not stated
Higher wins, only one applies, and the panel says which is in force. That is
the point: a pile of fields where precedence is a guess is how a measured
number and a guessed one get quietly added together. It also answers "must I
itemise every elbow" -- no. Flow the line, type the number. Itemising is for
what has not been built yet, which is most of a design.
**A fitting's K already contains its own friction.** So fitting body length is
not a loss input, and adding it to the pipe length counts the fitting twice --
about 4% of L on a 1.6 m run with three elbows, ~2% on Δp, the same order as
miscounting one. The friction term takes the *tube* length.
Body length and engagement are still worth recording, for the one thing they
are actually for: the **cut list**. Measure a run end to end, and the tube to
cut is `overall − Σ(body − engagement)`. It refuses to answer unless every
fitting has a length, because a partial subtraction is a mis-cut part rather
than an approximate one.
**Fittings are ordered rows with counts.** Ordered even though same-bore order
is worth 0.003%, because a fitting can carry its own bore and then the order is
load-bearing -- and because a list matching the run as built is what somebody
checks against the hardware. Each row carries bore, body length, engagement and
a measured K of its own.
**The catalogue keeps three kinds of number apart.** Arithmetic is implemented:
a dash size is sixteenths of an inch of tube OD, a tube bores `OD − 2 × wall`.
Catalogue data -- through-bore, body length, engagement -- is a library the
team fills, and is deliberately **not** seeded: a bore invented here reaches
feed-twin wearing a source and a reference, looking checked, and is wrong, which
is worse than absent because absent is visible. Pick JIC -8 today and it says
"no catalogue bore for JIC -8 — type it. Thread size is not flow diameter."
Relief valves draw their set and reseat pressure on the symbol. It is the one
component whose number is what a reader is scanning for -- whether the thing
protecting a vessel lifts below what the vessel is rated to -- and two clicks
into a dialog is two clicks nobody takes.
The plan, the reasoning and three asks of feed-twin are in
`docs/integration/line-loss-plan.md`.
A list of fittings is a claim about a piece of hardware, and until you can see it there is no way to tell whether the program read it the way you meant. The line panel now draws the inner wall about the centreline, beside the editor that produces it: where the bore steps, which way a reducer faces, how much tube an engagement swallows, where the run turns. A flow path is axisymmetric, so one radius against distance carries all of it. The exception is a bend, where what matters is not the section but that the path *turns* -- so the centreline turns and the wall follows it round. Three things it is careful about: **The radius is exaggerated, and it says so.** At true scale a 1.6 m run at 10 mm bore is 160:1 and the bore is a hairline. Length and radius carry their own scales, both stated, the way a bore profile is drawn anywhere else. What is preserved exactly is what the picture is for: the ratio between bores, and where along the run each change happens. **Bends alternate direction.** Which way a real run turns is not in the tally and the drawing must not imply it -- and turning every bend the same way spirals the path back over itself, hiding the elements this exists to show. **An assumed number is drawn as assumed.** A fitting with no stated body length still has to occupy something, so it is given a plausible one and marked amber. The picture never shows a guess as though it were measured. Nothing here feeds a calculation, and it is built from the same fields feed-twin reads: if the picture is wrong, the model is wrong. A segment whose loss is a measured K or a curve draws no fittings at all -- they are not what is being modelled there, and showing them would claim the solve uses them.
Putting a junction on a line was the plain click handler on that line. So a double-click -- which is two clicks -- inserted two junctions and *then* opened a dialog for an edge that no longer existed, and clicking around a drawing to look at things scattered them everywhere. A gesture that rewrites the graph cannot be the same gesture as "look at this". Junction is a tool now, beside Paint: arm it, click a line, press Escape when done. Same shape as the paint bucket, so there is one idea to learn rather than two, and only one tool can be armed at a time. While a tool is armed double-click does not open a config -- you are placing, not reading. The hover dot that followed the pointer along a line is gone unless the tool is armed. It was advertising a click that should never have been there. The gating audit gains a check that `onClickBranch` returns early unless the tool is armed; it fails against the code before this commit.
It emptied the whole diagram without asking. With the rocket side and the GSE side living in one document that turned "start this page over" into losing the other one, and the only way back was version history -- which helps nobody who does not know it is there. Clear now takes what the page shows and says so first: *"This removes 2 components and 1 line from GSE. Other pages are untouched."* Counted from the diagram rather than described in general, so the sentence is about the thing in front of you. A line goes when either of its ends goes, including one reaching across to another page. Half a pipe is worse than none, and the checks panel already reports a cross-page line as something that should not be there. The confirm button is gated on the checkout as well as the button that opens it -- it destroys work, so it does not rely on the dialog being unreachable.
…agging **Dragging a tag on a rotated symbol went the wrong way.** The offset lives in the symbol's own frame and the symbol may be turned, but the drag is measured on screen -- so at 90° the tag moved sideways and at 180° it moved opposite the mouse. The delta is rotated back into the symbol's frame before it is applied. **Manifold ports can be put where they actually are.** A real block has its tappings where the machinist put them, and evenly spaced along one face is a drawing that does not match the hardware. The config opens a small editor: set the number of ports, drag each one round the perimeter, set the block's width and height, Save. What is stored per port is a **fraction of the way round the perimeter**, not an (x, y). Resize the block and the ports stay where they were put relative to the shape instead of ending up inside it or off the end -- and 0.25 means a quarter of the way round whatever size the block is. The layout commits on Save rather than on release, because dragging is fiddly and every twitch would otherwise be a version in somebody's history. A manifold with no saved layout draws exactly as it always did. **Transducers and gauges have one port.** They screw into a single tapping and are a dead end; four connection points were three invitations to draw a pipe through an instrument. **A junction is a tee.** It now carries bore, branch bore, tee kind and which leg is the branch -- because once something flows out of its third leg it is a node with a mass balance and a loss on each path, not an anonymous dot. It deliberately carries no K: a tee's K depends on how the flow splits, and that is solved rather than drawn. The reasoning, and four asks of feed-twin, are in `docs/integration/tees-and-branches.md`.
Six things, all of them the drawing arguing with what somebody meant. **A branch does not need to be placed.** Drag from a relief valve, let go on a line, and the junction appears where you let go. A branch does need a node -- three flows meeting need a mass balance, which is the whole reason a tee that carries flow is a node and not a fitting -- but needing one is not a reason to make anybody think about one. The Junction tool stays for placing one deliberately on a line nothing is connected to yet. **Deleting a junction gives the run back.** It is a point *in* a line, so taking it out should leave the line, exactly as putting it in left it. React Flow took both halves with it, which is never what "remove that junction" meant. Built from what was deleted rather than from what is left, because by then the halves are already gone from state; a chain of junctions rejoins as one line, and a junction with a third leg on it has no single run to give back, so that one behaves as it always did. **Splitting a line no longer doubles the pipe.** Both halves used to inherit the whole run -- three feet and two elbows each -- so a junction silently doubled a line's pressure drop. Bore and roughness are true of both halves and copy; length, lumped K and the fitting tally stay with the upstream half and the downstream one starts unstated, which in this codebase means not stated rather than zero. Split then delete is now exactly a round trip. **A junction on a corner stopped breaking the line.** Each half meets the face pointing at where it came from instead of always top-in bottom-out, which sent a horizontal run up and over the junction and back down. **Lettering stays upright when a symbol turns.** PT, S, HYD, INJ, TANK, N2 -- rotating a part is about pointing it somewhere, not about reading sideways. The annotation swings round to stay beside what it labels. **Manifold ports sit on the manifold, and take paint.** The port style overrode the transform React Flow uses to seat a handle on an edge, so ports floated half their width outside the block; and the symbol still read the retired `fluidType`, which is why the paint bucket appeared to miss the one component people most want coloured. The Junction tool and the connect-drop now share one `splitEdgeAt` rather than two implementations that had already drifted -- one of them stamped a junction the delete could recognise and the other did not.
An adversarial pass over the branching work, and most of what it found was older than the branching. **Opening a diagram left you at 1:1 on the origin.** The `fitView` prop fits the nodes present at the first render and a diagram arrives from the server a moment later, so the frame nobody had asked for never happened. Same for pages: switching to one whose contents are drawn somewhere else showed empty canvas, and a page bar that appears to do nothing reads as broken. Both are "nobody has been here yet", so both are one rule -- no remembered viewport for this (diagram, page) means frame it, one means put it back, and where you were looking is now per page. **Taking the checkout threw the viewport away.** The canvas remounts, and the gesture that means "I would like to edit this" dropped the reader at the origin, away from the thing they were about to edit. And Fit View itself was dead on the first press after a Take, because it ran on the ReactFlow instance `onInit` had handed up -- which belonged to the mount that had just been replaced. Same root cause as the palette dropping nothing right after Take; fixed the same way, off the live store. That was the last thing holding an instance in state, so the whole chain is gone. **A junction could not be clicked.** It carried `nodrag`, which turns off the pointer handling that *selects* a node as well as the part that moves it -- so the dot could not be picked, Delete over it did nothing, and it could never be nudged off a bad spot. It selects, moves, shows that it is selected, and has a hit area you can actually hit. **A drop could land on another page.** `targetAt` hit-tested the whole document, and the graph is whole on purpose -- so a probe could clip to, or a junction land in, something not on screen. Scoped to the page. **Two narrower ones on the branch-by-dropping gesture.** It refused nothing when you dropped a connection on a line that component is already an end of, which is a parallel path and not what anybody meant. And it put the junction where the pointer was rather than on the pipe: the hit test measures against the straight line between two ends and the run is drawn orthogonally, so those differ by the whole depth of a bend. **The checks panel said everything twice.** "TK-1 has no operating pressure", then "An operating pressure is not set", then a lesson about boundary conditions. Now the title says what, one line says why, and six findings fit where three did. `onDelete` and `onConnectEnd` join the gating audit: both rewrite the graph without going through a control, so no audit of controls would see them. Verified it fails on the unguarded version.
More of the same pass. **Take threw you off the page you were on.** `page` and the list of declared-but-empty pages were state inside the canvas, and the canvas remounts on Take -- so editing a GSE drawing put you back on Main. Both now live above the remount, next to the viewport. **Clearing a page deleted the page.** A page exists because components are on it, so emptying one removed it from the bar and dropped the reader on Main -- which is neither what the confirmation says nor what somebody starting a page over wants. Clear declares the page it emptied, and `listPages` now orders used-then-empty so a tab does not jump when a page crosses between those two states in either direction. **The line panel reported guesses as facts.** Three of them. "ΣK 0.00 + 3 fittings" summed the *bore-transition* K, which is zero when there are no transitions -- reporting "nothing stated" as "nothing", on a line whose fittings feed-twin prices from geometry. The flow-path figure printed a length in metres that was whatever the picture needed when no segment stated one. And "radius ×11.2" read as a bend radius when it is the bore exaggeration, without which a metre of 10 mm tube is a hairline. **Two ways to say how long a line is, and nothing saying which won.** The line-level Length/Bore/Roughness/K sat above the segment list, both live, with precedence left to the reader. The plan's own rule for loss methods -- the panel says which is in force -- applies a level up too: with segments present those fields are dimmed and say they are superseded.
Three data-integrity faults in the config dialog, each reachable by opening
it and pressing Save without touching anything:
- Provenance was collapsed on the way in. A datasheet Cv (`manufacturer`)
came back `measured`; a catalogue bore (`default`) came back `estimated`.
The two-way select the dialog offers now stands over the four sources
feed-twin's run report distinguishes, and only moves a value when somebody
moves it.
- Every reference was dropped. "Tescom 26-1000 datasheet rev C" is the
whole reason a number can be trusted, and Save threw it away.
- Spec suggestions were written as values. An untouched line saved
K_minor: 0, elevation_change: 0, a roughness and a wall thickness, all
tagged estimated -- numbers nobody stated, indistinguishable afterwards
from numbers somebody did. The drawing's own rule is that absent means
not stated, never zero. Suggestions are placeholders now ("900 J/(kg.K)
if blank"), and a blank field stays absent; feed-twin fills it and says
so in its report.
The draft <-> ParamValue logic is its own module with tests, because the
component could not be tested and this is exactly the kind of thing that
regresses quietly.
Also:
- Dropped symbols get a tag of their own. The palette's `ROT_#` was stamped
on literally, so every rotary valve was tagged `ROT_#` and the second one
tripped the duplicate-tag check; a drawing in this repo has `HQD_#`,
`PG_#`, `PR_#` and `ROT_#` saved as the names of real hardware. `ROT_#`
is now `ROT-1`, then `ROT-2`, counting past a deleted valve's number
rather than reusing it. The four palette entries with no placeholder get
one, in the team's own spelling: TK-#, ENG-#, INJ-#, MF-#.
- The unsized-line check read only the one-number fields and fired on every
line built the recommended way, with its length and bore in segments.
- Backspace deletes. A Mac keyboard has no key marked Delete.
- `pressure_ratio` joins the dimensions, ahead of the regulator spec fix.
…wn numbers The regulator spec offered `supply_effect_out` and `supply_effect_in` -- two fields under names that appear nowhere in feed-twin's catalogue and that nothing read. A datasheet number typed there went nowhere. What the catalogue actually reads (`supply_coefficient`, `inlet_reference`, `flow_droop`, `rated_flow`, `lockup_rise`, `min_inlet_differential`) was not settable at all -- and feed-twin's own notes say a regulator with no droop is one it cannot solve transiently, because a perfect regulator's branch equation is true for every mass flow. The spec now carries the catalogue's names; the valve gains xT, FL and seat leak; the check valve gains reverse leak. The parity test that guarded line params now guards the inline components too, in both directions, so PR cannot drift again. Checks that a reviewer does by eye, done by the drawing: - A relief set above its vessel's MAWP is an error: it would not open before the tank failed. Set at or below the operating pressure is also an error: it would be open the whole time. A tank run above its own rating is an error. The vessel is found by walking from the relief through lines, junctions and manifolds and never through a valve, past which it would be protecting something else. Nothing fires unless both numbers are stated -- a missing MAWP is not a fault. - A check valve facing against the flow is a warning. Every symbol's hop count from the nearest source says which side the flow arrives on, and that had better be the inlet. Silent when the two sides are equidistant, which is what a fill line between a dewar and a tank looks like. - Joints priced from an unchecked NPT engagement are counted, so a cut list built on the seeded figures does not read as a citation. - A fresh tank gets one row naming everything it lacks. Two used to put six amber rows in the panel, which is the wall of the same thing said again that people stop reading. On the symbols: the check valve now carries an arrow, because its direction is the whole component and the old ball-and-seat read either way; the regulator draws its setpoint the way the relief draws its set pressure; a junction takes the colour of the pipe it is in rather than sitting on it as a grey dot; and every number on a symbol is formatted by one function, so the engine no longer prints `300psi` next to a relief printing `650 psi`.
Copy, paste, duplicate. A stand has eight solenoid valves that are the same solenoid valve, and there was no way to copy one -- the eighth was configured from the palette like the first. Cmd+C / Cmd+V / Cmd+D, and Cmd+A for everything on the page. What lands is a new symbol, not a reference: fresh ids, because an id is the tag a solver keys on; a fresh tag numbered from the same stem, so a copy of SOL-3 is the next free SOL and not SOL-3-1 or a second SOL-3; only the lines whose both ends came along; a probe clipped to a copied host stays clipped to the copy. The copy lands selected, so a drag straight after moves it and not the original. Nothing fires while a field has focus. Export. It meant the JSON, which is the drawing for a machine. A P&ID that cannot be printed for a review or pasted into a test plan is one that lives only in the tool it was drawn in. The menu now offers a PNG and an SVG of the current sheet, framed on its symbols, with a title block along the bottom -- drawing, sheet, revision, date -- and the JSON, named after the diagram rather than `pid_diagram.json` as every export of every drawing was. Rendered from the live canvas with html-to-image, so the export and the screen cannot disagree. The PNG is rasterised on our own canvas from the SVG rather than by the library's toPng: that hands a 2 MB SVG to HTMLImageElement.decode(), which a background tab defers indefinitely, while the same SVG fires onload in a millisecond. A render is bounded at twenty seconds, so the menu cannot say "Rendering…" for as long as anyone cares to wait. A title block in the corner of the sheet, saying the same four things the export prints. What every engineering drawing has and this one did not. A minimap, under the checks badge. A drawing with forty symbols is bigger than a screen. Restore asks through the same dialog Clear does, not a browser confirm() that lands wherever the browser puts it. The toolbar and the palette move onto the shared colour tokens rather than their own slate hexes.
Two faults in the export frame, found by looking at the picture rather than counting its pixels: - `getViewportForBounds` takes its padding as a fraction of the frame, and it was handed a pixel count. It obliged with ninety-six times the frame, translating the drawing clean off the sheet and leaving a title block, one vent arrow near the origin, and an ink count that passed. The transform is three lines of arithmetic and is now written out. - The frame was the symbols' boxes. Tags hang below their symbols and a turned valve's tag sits off to its side, all outside the box, so MAN-FILL printed as "MAN-" at the edge of the sheet. The rendered elements are measured instead -- every node and everything inside it, mapped back through the viewport transform -- so the frame is the drawing as it appears.
Checkouts exist so two people on the shared server do not overwrite each other. On a developer's own machine the same machinery was throwing them out of their own session: the tab released its claim the moment it was hidden -- switching to another window was enough -- and stopped heartbeating, so coming back meant pressing Take again, every time. When served from localhost the checkout is taken on open, kept alive regardless of focus, never released on hide, and re-taken if it ever lapses. The server still requires it for a save; it is simply never something the one person in the room has to think about. The shared server keeps the full behaviour.
…erline sketch The plan for the next round, phase by phase, with the cross-app contract changes stated first so the feed-twin side can be told before either half lands.
… is a jog - The "P&ID Designer" bar was fifty-six pixels of title over empty space. The name sits at the left of the diagram bar now and the canvas has the height. - The regulator's lettering was placed by three rules -- "PR" and "DOME" counter-rotated about their own anchors inside the artwork, the setpoint an HTML span under the box -- and a quarter turn spread them across the symbol, the setpoint under the port the line now left from. All of it lives outside the rotation, placed afresh per turn in the corners the diagonal does not cross, and off to the side when a port is underneath. - A run whose ends were up to ten pixels apart was "straightened" by moving both ends half the error, so a valve one grid step to the side of the tank above it got a line landing on neither port. Four pixels now, and beyond that the run jogs. Snap-on-drop lines symbols up; what remains is rounding. - The standalone injector symbol is gone; a drawing that has one opens with it read as an engine (migrate.ts, applied on load).
…heir numbers The rule: a field stays only if somebody on the stand would know the number. Everything else is derived from a choice and written as a `default` with a reference naming what it came from -- and `verified: false` in that reference wherever the figure has not yet been checked against a datasheet. Tank: fluid; operating pressure (nominal, drawn on the face); temperature as a dropdown of ambient / LOX 90.2 K / LN2 77.4 K / LCH4 111.7 K (NIST normal boiling points) with custom; volume; burst pressure in place of MAWP; a Material section (6061-T6 default, 316, 304, COPV) that writes the wall specific heat, and a dry mass; an Insulation section (none, fiberglass R-13 batt with k derived from the bag's own rating, custom) that asks a thickness only when there is something to be thick; ports. Gas-to-wall conductance is a custom-only advanced field: it is the gas and the geometry, not the material, and feed-twin estimates it (Phase 2). Bottle: pressure and water volume. Dewar: fluid and delivery pressure, with the temperature filled from the NIST saturation curve at that pressure (interpolated in log p) and shown for the taking. Engine: chamber pressure and temperature, both drawn in the chamber, and the Layer-1 config that is the real engine. Valves, check valves, disconnects, reliefs: Cd or Cv, default Cd, plus bore; when Cd is chosen the Cv it amounts to is written beside it (Cv = 38.0·Cd·A[in²], derived and tested) so feed-twin's Cv-only valves still solve. xT, FL, seat leaks, cracking pressure and reverse leak are gone from the dialogs; they stay solver-side, reported as assumed. Transducers: range only, preset 1000 psi low and 5000 psi high at drop. Regulator: setpoint, relabelled "Dome pressure" and saved under that key when dome-loaded, with dome bias beside it; Cv (default) or Cd; orifice; the supply effect as one row -- N psi rise per M psi inlet drop -- stored as psi/1000psi; measured-at inlet. Droop, rated flow, lockup and dropout are gone. The checks follow: a relief at or above the burst pressure, a tank run at or above it, and a factor of safety under two. The parity test's NOT_DRAWN names every retired catalogue field and why, and the vessel test now asserts the material-and-dry-mass shape.
One kind of line. It used to be four -- pipe, flex hose, bend, fitting -- picked from a dropdown before anything could be typed, and the choice was the wrong first question: a run is a run, and what is in it is what the sketch (Phase 4) will say. What the dialog asks: length, bore, material, fall, a lumped K, a weighed line mass. What it writes: - The material (6061-T6 drawn; 316 polished; 316 rough) becomes feed-twin's `roughness`, with the source in the reference -- Moody's drawn-tubing row for aluminium, Farshad's electropolished and bare stainless (as shipped in `fluids.roughness_Farshad`) for the two finishes. Farshad measured a 13 % chrome steel, not 316, so both stainless rows say so and carry `verified: false`. A custom roughness is still typed under *custom*. - "Fall, inlet − outlet" is what somebody measures; feed-twin defines a signed rise. Stored negated under `elevation_change`, reopened negated back. One number, the name each side uses for it. - `line_mass`: one weighed figure for tube and fittings together, which the cockpit now takes outright over its wall-thickness-and-fitting-count estimate. What a person with a scale actually knows. - A hose is a flag under the material, not a type: it becomes feed-twin's `flex_hose` with its construction, and the two bend radii appear only when it is set. Gone from the dialog: the type dropdown, the fitting chips, the per-fitting body and K rows, the joint and engagement panel, the tube-only / end-to-end toggle, the loss-method selector, the bore profile. A run that still carries an itemised list from before says so in one amber line -- the solver reads it in place of the numbers above -- and offers to let it go. The parity test's NOT_DRAWN names the hose second-order terms and the per-fitting mass, and the thermal test follows `fitting_mass` → `line_mass`.
feed-twin/ and lib/feedtwin/ -- the cockpit and the physics core it and EngineDesign import -- had never been committed on any branch; they existed only as untracked files in one working directory. Same for the docs tree, the benchmark script, the stability solver package and the CI workflow. Build trees, per-app user data and engine run output are now ignored so they never come along. Also the deployment and config edits that were sitting uncommitted (the feed-twin service in docker-compose and the Caddyfile, the flush guard in the document store and its test, the EngineDesign frontend tweaks), the Phase 2 physics work (burst/safety-factor trip, gas-derived wall film, Cd valve models, line_mass), and the start of the centerline sketch model. EngineDesign/engine/native is left out on purpose: the C port was replaced by the Numba accelerator in #37 and the local copy is a leftover.
Upstream's "Mega commit (#22)" reworked the design checkout (beat on activity, lost-hold dialog, pagehide-only release, a vitest suite for lib/stardesign-ui) and folded recovery-calculator and onshape-viewer into star-openrocket. Resolutions: - lib/stardesign-ui/useCheckout.ts: upstream's hook, with the branch's local-machine behaviour re-added as an opt-in `local` option (default from the hostname): take on open, beat every tick, take back on lapse. The lib's vitest config pins a deployed jsdom URL so the suite tests the deployed model; local mode has its own cases. - docker-compose.yml, Caddyfile, setup.sh, README.md: upstream's star-openrocket entries plus the branch's feed-twin ones, images tagged ${STAR_IMAGE_TAG} like the rest. - format.sh: upstream's node_modules/vendored prunes plus the branch's .venv/__pycache__/egg-info prunes for the Python targets. - PRNode.tsx: the branch's lettering-outside-rotation symbol keeps upstream's plain top port (id "t") when the regulator has no dome.
The line dialog asked for a length, a bore, a fall and a lumped K, which is the shape of the answer rather than the shape of the question. Nobody measures a run that way: you follow it, note where it turns, read a tape along each leg and a radius off each bend. So the dialog now offers a centerline sketch. Click to lay out legs (8 directions, Alt for a free angle), click a corner and give it a radius, click a dimension to type an exact length, and split the run where the bore steps. The inner wall is drawn as an envelope either side of the centerline so the thing on screen is the pipe, not a wire. A size chart turns NPT, JIC/AN dash sizes and McMaster tube walls into a bore. On save the sketch becomes the itemised segment list the solver already reads: a straight per stretch with its own bore and measured length, a bend fitting per corner carrying its radius as r/D and its turn angle, the total length, and the fall taken from where the run ends relative to where it started. The lumped K comes off, because there is nothing left for it to stand in for. feedtwin's `Fitting` gains `bend_diameters` and `angle` so a drawn bend is priced at the radius and angle it was drawn with, instead of at the catalogue's generic elbow.
The bore field could not be typed into, and the failure was specific to the numbers that matter: to write 0.48 you must first write "0" and then "0.", and neither parses to a bore the field would accept, so each was thrown away and the text snapped back to what was there before. The first keystroke that did parse was immediately reformatted -- "4" became "4.00" -- so the rest of the number had nowhere to go, and correcting it meant fighting the cursor. Only whole bores were reachable. The cause is a field whose text is re-derived from the model on every keystroke. `NumberField` keeps the text being typed separately: what you typed is what is shown while the field has focus, every keystroke that parses is committed so the drawing follows along, and the model's own formatting comes back on blur -- which is also how a unit change or a size picked off the chart reaches the field. The manifold editor's width and height had the same defect and now use the same field. While there: the sketch's number formatting dropped trailing zeros and went finer. Whole millimetres could not express 12.7, and two decimals on inches turned a 0.402" bore into 0.40 on the way to the screen.
Carlsaurus
force-pushed
the
fix/stale-design-metadata
branch
from
September 12, 2026 21:05
0980e94 to
83d9d78
Compare
The frontend had NO error boundary anywhere -- grep for ErrorBoundary / componentDidCatch / getDerivedStateFromError returned nothing. In React that means any exception thrown during render unmounts the ENTIRE tree, so every render bug presents identically: a white page, no message, no stack, no way to report what happened. That is how "press Optimize on the flight page and it goes blank" arrived with nothing to act on. Adds components/ErrorBoundary.tsx and wraps each of the nine tab panels in one. The tab buttons live in <header>, above the panels, so a crashing tab now shows a copyable error with its component stack and a Try again button while the tab bar stays alive and every other tab keeps working. Verified end to end, not just by inspection: injected a throw at the top of FlightSimulation, reloaded, and confirmed the boundary rendered "Flight Simulation hit an error" with the stack while the rest of the app -- header, all nine tabs, the other panels -- stayed mounted and usable. Throw reverted. Note this does not by itself remove the underlying throw on the flight optimize path. I could not reproduce that one: the handler is correctly guarded (checks result.error, checks result.data, wrapped in try/catch -- though try/catch does not cover render), and every field the flight UI renders is a REQUIRED float or List[float] in the backend models, with all Optional sub-objects already guarded (results?.truncation?., results.propellant &&, and the !results?.trajectory early return). With the boundary in place the next occurrence prints the real error instead of a blank page, which is what makes it fixable. The Try again button is declared VIEW_ONLY in the checkout gating audit: it clears local error state and touches no design state. Frontend gating audit 4 passed; npm run build clean.
… flight dedupe, lockout heartbeat, UI cleanup
Physics / solver
- Chamber-pressure root-find window: choked-flow floor (2 atm) and tank-pressure ceiling
(min tank minus 2%) replace the 1 bar floor and hardcoded 15% "feed loss margin"; both
Python and the numba kernel now scan for the HIGHEST-Pc sign change before Brent. The old
window let Brent lock onto a spurious ~20 psi root: configs/default.yaml reported
F = -333 N at its own configured tank pressures, and the Python side said "no solution"
at 550/650/900 psi where the kernel solved. Both paths now agree at every pressure.
- Stability model reads config, not constants: StabilityConfig (n, chi, nozzle-entrance
Mach, damping fractions, regulator, acoustic gate allowance), feed_system.<side>.length
(the chug inertance length was a hardcoded 0.305 m with no schema field), cp_g from the
CEA state (was a fixed 2200 J/kg-K), nozzle-entrance Mach solved from the contraction
ratio (was 0.2), per-fluid handbook fallbacks recorded via assume() (fuel fallbacks were
methane's for every fuel). comprehensive_stability_analysis looked up feed_system["lox"]
.length, a key and attribute that never existed, so feed acoustics always used 1.0 m x
10 mm; the legacy and rich paths also used different chamber lengths. Chug gate centred
at gain margin 1.0 (0.80 called an unstable loop "stable"). Rigged heuristics removed:
stability_index floors, the water-hammer-to-margin map, and stability/enhanced.py.
- Ea mixture-ratio step in reaction_chemistry smoothed (20% jump at MR 1.5); the dead
reaction-progress computation on every chamber solve removed.
- Layer 1: refuses a target O/F outside the propellant's CEA table (a propellant switch
kept the old target and optimized against a table that could not evaluate it); names the
blocking hard constraint when a run ends with no feasible candidate; 1 m chamber-length
cap removed; ED_L1_WORKERS / ED_L1_TRACE_INFEAS debugging hooks.
- Canonical pintle design_MR 2.55 -> 1.4 (ethalox table is [1.0, 2.5]).
Flight
- Optimizer Layer 4 now renders the shared FlightSimulation component; the 1100-line copy
it replaced hardcoded RP-1 density and an 85% fill factor client-side, so every
non-kerolox design capped its fuel load against the wrong tank.
- flight router: explicit_capacity_kg carried a bool; rocket/environment fallbacks derived
from the request models instead of a drifting literal copy.
- tests/test_flight_propellant_iteration.py rebuilt on configs/default.yaml (it read a file
out of one developer's Downloads folder); the tank-cap test now constructs its premise.
Checkout lockout
- useCheckout re-takes the checkout every 60 s while held and no longer releases on tab
hide: the token lapsed after 5 min without a changed autosave and the next write came
back 423 ("Take Design 1 before saving").
UI
- Emoji removed everywhere; legend and axis-title overlap fixed on every chart with the
pattern (Layer 1 convergence, pressure curves, controller, custom plotter, heat flux,
flight); Design Requirements input caps that assumed one rocket removed; CEA O/F range
shown under the target; stale stability prose replaced; Time-Series simple profile seeds
from the design's tank pressures; Stability Model section in the config editor;
unreachable Coaxial label dropped.
Schema hygiene
- Removed fields nothing read (hot_gas_cp, yield_strength, youngs_modulus, mixing_model)
and the stale hot_gas_cp keys in shipped configs; pydantic ConfigDict; fuel tank
descriptions no longer say RP-1.
Local mode defaulted to `isLocalHost(location.hostname)` inside the hook, which broke EngineDesign's Playwright suite: E2E drives a real browser against a Vite dev server on localhost, so every such run silently entered local mode, the tab took the design on open, and "a tab that never took the design does not refresh its hold" failed. There is no ambient signal that separates a developer's own session from a test driving a dev server -- `import.meta.env.DEV` is true for vitest and the E2E server too -- so the app has to say. `local` now defaults to false, which makes the hook behave exactly as it did before the option existed, and pid-designer passes `isLocalHost(location.hostname)`. EngineDesign does not, so its suite is unaffected. `isLocalHost` moves out to the library's exports for that. The jsdom URL pin goes away with it: the hook no longer reads `location`, so there is nothing for a test origin to influence.
`_engine_state`, `_SESSIONS`, `_SESSION_LIMIT`, `_setup`, `_session` and `_session_out` were each defined twice in backend/main.py, the second copy shadowing the first. It predates version control -- the file was only ever an untracked working copy until this branch committed it, so nothing had type-checked it. The two copies of `_session_out` differed, and the shadowed one was the better of the two: it serialises the setup with `wire_setup`, which walks the tunables table, while the live one listed twelve fields by hand. So the live payload silently omitted every knob added after it was written -- including this branch's own gas-film and burst-factor settings -- and the console seeded its Configuration tab from that, so those knobs showed the client's defaults rather than the stand's values. It now sends all 43. The live copy's `notes` is kept; the route table is untouched. Also the three annotations the duplicate was hiding: - `PSI: float`. feedtwin is installed editable, which mypy cannot follow, so `get_unit(...)` arrives as Any and spread to every pressure this module returns -- `psig()` included, the one place absolute and gauge meet. - the wall-default tables are `dict[str, Any]`, not bare `dict`. - the frame `advance()` returns is never None: `steps` is clamped to at least 1, so the loop always assigned one, including on the tick that trips the stand.
Three of these gates passed here and failed, or would have failed, on the runner -- on nothing but the dependency set. The library job installs for Python 3.12 and resolves numpy 2.5.3 and CoolProp 8.0.0; this machine is 3.11 with numpy 2.4.2 and CoolProp 7.2.0. - mypy pinned `python_version = "3.11"` and so refused to parse numpy 2.5.3's stubs, which use a PEP 695 `type` statement. The pin goes: mypy now targets the interpreter it runs under, because the stubs it has to read are the ones installed for that interpreter. The 3.11 floor is proved by running rather than asserting -- the backend job installs and exercises the package on 3.11. - CoolProp 8.0 gives its input-pair constants a nominal type that `AbstractState.update` then demands, so `_INPUT_PAIRS` declaring `int` made the stubs reject the very call the table exists to feed. They are ints at runtime on both versions; the annotation is now `Any`. - whether PyYAML ships usable types varies by version, and an inline `# type: ignore` that becomes unnecessary is itself an error under strict. Silenced in the overrides table instead. And one that is not about types at all. CoolProp 7.2 evaluates the fluid equation of state wherever it is asked, including above the melting line and below the triple point; 8.0 added a melting-line check and refuses. The property-chain test reached past the tables by asking for nitrogen at 5 GPa / 300 K and 1 MPa / 20 K, which are both solid, and one of the densities 7.2 invented for that region -- 1579.7 kg/m3 -- was pinned in a test as an expectation. The refusal is correct, so the test points moved rather than the check. The edges that are still genuine fall-throughs, where the table stops and the equation of state continues, are low pressure and high temperature; both agree to 15 significant figures across the two versions, as does the whole benchmark. Recorded as trap 4.10.
Upstream #63 found that `import protected` expands to a `route` while the adapter sorts `handle` ahead of `route`, so a site written the obvious way proxies and terminates the request before forward_auth runs. It wrapped each gated site's body in a single route{} and added a CI check that reads the adapted JSON and asserts auth precedes any proxy. The feed-twin site this branch adds was written the obvious way, so the new check failed on it: sequence ['proxy', 'proxy', 'auth'], the app served to anyone. Wrapped like the others. It matters more here than elsewhere, because feed-twin reads designs out of pid-designer and EngineDesign using the X-Auth-Email this gate sets -- ungated, the header is absent and every import runs as whatever the backend falls back to.
aahilsyed72
approved these changes
Sep 12, 2026
aahilsyed72
left a comment
Contributor
There was a problem hiding this comment.
Reviewed. Large diff is mostly first-time commit of feed-twin/lib/feedtwin (previously untracked) plus JSON fixtures, not equivalent hand-written logic. Two minor follow-ups noted but not blocking: local checkout default regression in star-openrocket's useCheckout call, and Caddy gate-order test not covering bare reverse_proxy sites (landing page, daq-viewer).
aahilsyed72
approved these changes
Sep 12, 2026
aahilsyed72
left a comment
Contributor
There was a problem hiding this comment.
we ball if it breaks its carlos fault and not mine
The backend job was cancelled at 15.2 minutes, which reads as a failure and is not one: it hit its own timeout-minutes partway through the tests. That budget had never been exercised. Until the format and type steps ahead of it passed, the tests had never once run in CI, so the job always finished in well under a minute by failing earlier. The suite is 197 tests that integrate a stand, measured at 21 minutes in this job's own dependency set on a developer's machine, and a runner core is slower again. 45 minutes. `-n auto` with pytest-xdist takes it to about 7 minutes and the suite passes clean under it. Left out for now: a physics suite whose tests share a library directory deserves more than one green parallel run before the gate depends on it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
One branch, everything current. This PR carries:
Into version control for the first time
feed-twin/(the cockpit) andlib/feedtwin/(the physics core EngineDesign and feed-twin import) had never been committed on any branch; they existed only as untracked files in one working directory.docs/(physics benchmark, integration notes, thermal, overnight),scripts/physics_benchmark.py,EngineDesign/engine/stability_hifi, the feed-twin CI workflow,CLAUDE.md.EngineDesign/engine/nativeis deliberately left out (replaced by the Numba accelerator in Replace the C physics port with a Numba accelerator #37).pid-designer
feed-twin / lib/feedtwin (Phase 2)
ht) when the drawing leaves it blank; cockpit default on, library default unchanged. Benchmark identical with it off; +0.03 psia on 2.3 with it on, noted indocs/PHYSICS-BENCHMARK.md.cdmodel for valves and check valves;line_massas one weighed figure for a run.Phase 4: the centerline sketch
feedtwin.model.Fittinggainsbend_diametersandangle, so a drawn bend is priced at the radius and angle it was drawn with rather than the catalogue's generic elbow.Merge of main (#22 and the four quick fixes)
pagehide-only release, the newlib/stardesign-uivitest suite) is kept as-is; the local-machine behaviour is re-added as an opt-inlocaloption defaulting from the hostname, so on a dev box the design is taken on open, held while the tab lives, and taken back if it lapses. The library's vitest config pins a deployed jsdom URL so the suite tests the deployed model.${STAR_IMAGE_TAG}like the rest.One more fix
0and0.were thrown away and the first parseable digit was reformatted: 0.48 was unreachable. The manifold editor's width and height had the same defect. Both now useNumberField.EngineDesign (folded in from #58, which this supersedes)
default.yaml.StabilityConfig,feed_system.length, CEA cp, per-fluid fallbacks throughassume(), a chug gate at GM=1.enhanced.pyis gone, and so isLayer4Optimization.tsx, which never ran.ErrorBoundary), which is Stop a render error from blanking the whole app #56's change.Gates: pid-designer frontend 336, pid-designer Python 112, lib/stardesign-ui 82, EngineDesign Python 579 (the 4 local failures are in untracked, gitignored test files CI never runs), EngineDesign frontend tsc + 4, lib/feedtwin + mypy + black, feed-twin backend 188, physics benchmark all checks passed.