STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
model.py
Go to the documentation of this file.
1"""Assembly: a drawing and an engine in, a solvable model out, with a record of
2what happened.
3
4Moved here from the feed-twin app (``backend/assembly.py``) when the marching
5session became library code. The app keeps the half that reads artifacts out of
6its content-addressed store and calls :func:`assemble_model` with what it read;
7EngineDesign's Layer X reads its drawings and engines elsewhere and calls the
8same function, so both callers get the same network and the same report.
9
10Importing a drawing is not one step, it is four, and each can go wrong in a way
11worth naming:
12
131. **Read** the drawing. Malformed JSON, a line to nowhere, a parameter with no
14 provenance -- all refused here, with the field named.
152. **Resolve** what it points at. The engine symbol names a Layer-1 config; that
16 config has to be found (by the caller) and has to import.
173. **Build** the network. Symbols become nodes and branches; a tank becomes two
18 pressures; the engine becomes an injector face and a chamber.
194. **Fill in** what the drawing did not say -- and record every one of those.
20
21The output is a :class:`Model` and an :class:`AssemblyReport`. The report is the
22point. A drawing that omits a bore still solves, and the answer is still worth
23having, but it is a *different kind of claim* from one where every number was
24measured -- and the difference must never be invisible. So the report carries
25what was read, what was assumed, what was refused, and the hash of every artifact
26that went in.
27
28That is also what makes a run reproducible: a result names the artifacts by
29content, so "which drawing was this" is answerable a year later.
30"""
31
32from __future__ import annotations
33
34from dataclasses import dataclass, field
35from typing import Any, Mapping
36
37from feedtwin.engine import CEATable, Chamber, ConstantCStar, EngineDesign
38from feedtwin.model.param import assumed_params
39from feedtwin.model.spec import SpecError
40from feedtwin.pid import (
41 BuiltNetwork,
42 Diagram,
43 DiagramError,
44 PidNode,
45 build_network,
46)
47from feedtwin.pid.roles import unpaired_vents
48from feedtwin.pid.roles import vehicle_only as cut_to_vehicle
49
50
51class AssemblyError(ValueError):
52 """The model could not be assembled, naming which step failed."""
53
54 def __init__(self, step: str, detail: str) -> None:
55 super().__init__(f"{step}: {detail}")
56 self.step = step
57
58
59@dataclass(frozen=True, slots=True)
61 component: str
62 parameter: str
63 value: float
64 unit: str
65 source: str
66 reference: str = ""
67
68
69@dataclass(frozen=True, slots=True)
71 """What went into the model, and what the model had to invent."""
72
73 diagram: str
74 """Artifact id of the drawing."""
75
76 engine: str = ""
77 """Artifact id of the engine config, empty if the engine is a boundary."""
78
79 symbols: int = 0
80 lines: int = 0
81 nodes: int = 0
82 branches: int = 0
83 instruments: int = 0
84 actuators: int = 0
85 assumptions: tuple[Assumption, ...] = ()
86 warnings: tuple[str, ...] = ()
87
88 @property
89 def unchecked(self) -> int:
90 """Parameters nobody has even estimated. The number to worry about."""
91 return sum(1 for a in self.assumptions if a.source == "default")
92
93 @property
94 def coupled(self) -> bool:
95 return bool(self.engine)
96
97
98@dataclass(frozen=True, slots=True)
99class Model:
100 """Everything a run needs, assembled and audited."""
101
102 diagram: Diagram
103 built: BuiltNetwork
104 report: AssemblyReport
105 engine: EngineDesign | None = None
106 chamber: Chamber | None = None
107 #: Extra context for the report and the UI.
108 meta: Mapping[str, object] = field(default_factory=dict)
109
110
111def diagram_summary(payload: Mapping[str, Any]) -> dict[str, object]:
112 """A listing-level look at a drawing without building anything."""
113 raw_nodes = payload.get("nodes")
114 raw_edges = payload.get("edges")
115 nodes: list[Any] = list(raw_nodes) if isinstance(raw_nodes, list) else []
116 edges: list[Any] = list(raw_edges) if isinstance(raw_edges, list) else []
117
118 kinds: dict[str, int] = {}
119 engine_ref = ""
120 for node in nodes:
121 if not isinstance(node, dict):
122 continue
123 data = node.get("data")
124 if not isinstance(data, dict):
125 data = {}
126 # The same precedence the reader uses: a symbol's kind is in its data
127 # when the editor put it there, and in the node's own type when it did
128 # not -- junctions and text carry no data block at all. Reading only
129 # the first counted every one of those as "?".
130 kind = str(data.get("componentType") or node.get("type") or "?")
131 kinds[kind] = kinds.get(kind, 0) + 1
132 options = data.get("options")
133 if isinstance(options, dict) and options.get("engineConfig"):
134 engine_ref = str(options["engineConfig"])
135 return {
136 "symbols": len(nodes),
137 "lines": len(edges),
138 "kinds": kinds,
139 "engineConfig": engine_ref,
140 }
141
142
143def engine_summary(design: EngineDesign) -> dict[str, object]:
144 return {
145 "injector": design.injector_type,
146 "oxidiser": design.oxidiser.propellant,
147 "fuel": design.fuel.propellant,
148 "throat_mm2": round(design.throat_area * 1e6, 1),
149 "thrust_N": design.design_thrust,
150 "mixture_ratio": design.design_mixture_ratio,
151 "warnings": list(design.warnings),
152 }
153
154
155def find_engine_reference(diagram: Diagram) -> str:
156 """The engine artifact the drawing points at, if it points at one.
157
158 Read from the engine symbol's ``engineConfig`` option. Absent is fine and
159 common -- the engine stays a pressure boundary and the report says so.
160 """
161 for node in diagram.nodes:
162 if node.type in {"ENGINE", "INJECTOR"}:
163 reference = str(node.options.get("engineConfig", ""))
164 if reference:
165 return reference
166 return ""
167
168
170 diagram: Diagram,
171 *,
172 diagram_id: str,
173 engine: EngineDesign | None = None,
174 engine_reference: str = "",
175 chamber: Chamber | None = None,
176 cea_cache: str = "",
177 fluid_swap: Mapping[str, tuple[str, float]] | None = None,
178 gas_swap: Mapping[str, str] | None = None,
179 multiphase: bool = False,
180 meta: Mapping[str, object] | None = None,
181 vehicle_only: bool = False,
182) -> Model:
183 """Build and audit a model from a drawing already read and an engine already
184 imported. The half of the feed-twin app's ``assemble`` that needs no
185 artifact store, so a caller with its own (EngineDesign's Layer X) gets the
186 same model, the same assumptions and the same report.
187
188 Args:
189 diagram_id: What the report calls the drawing -- an artifact id, a
190 document id, a path. Recorded, never resolved.
191 engine_reference: What the report calls the engine. Defaults to the
192 engine's own name when an engine is given.
193 chamber: A chamber to attach. When omitted and an engine is given, one
194 is built from ``cea_cache`` (see :func:`chamber_for`).
195 gas_swap: The pressurant replaced, ``{"nitrogen": "helium"}``
196 (:func:`swap_gases`): every symbol declaring the one gas declares
197 the other. Tanks keep their contents.
198 vehicle_only: Cut the ground support away before building
199 (:func:`feedtwin.pid.roles.vehicle_only`, ``Setup.ignore_gse``):
200 the cart is not simulated and the session's built-in fills stand in
201 for it. ``meta`` then carries ``vehicle_only``, ``ground_cut``
202 (the labels of the vessels cut) and ``capped`` (the ids of the
203 vehicle's disconnects whose mate was cut).
204 """
205 loose_vents = unpaired_vents(diagram)
206 if vehicle_only:
207 mates = {
208 n.id: str(n.options.get("pairedWith", "") or "").strip()
209 for n in diagram.nodes
210 }
211 diagram, cut = cut_to_vehicle(diagram)
212 capped = [
213 n.id
214 for n in diagram.nodes
215 if mates.get(n.id) not in ("", "none", None)
216 and not str(n.options.get("pairedWith", "") or "").strip()
217 ]
218 meta = {
219 **dict(meta or {}),
220 "vehicle_only": True,
221 "ground_cut": list(cut),
222 "capped": capped,
223 }
224 if fluid_swap:
225 diagram = swap_fluids(diagram, fluid_swap)
226 if gas_swap:
227 diagram = swap_gases(diagram, gas_swap)
228
229 reference = engine_reference or (engine.name if engine is not None else "")
230 warnings: list[str] = []
231 if engine is not None:
232 warnings.extend(engine.warnings)
233 if chamber is None:
234 chamber = chamber_for(engine, cea_cache)
235
236 try:
237 built = build_network(diagram, engine=engine, multiphase=multiphase)
238 except (DiagramError, SpecError) as exc:
239 # A part the library refuses -- a Cd of 20 -- is a drawing error like
240 # any other. Escaping as a SpecError made it an API 500 with no text.
241 raise AssemblyError("build", str(exc)) from exc
242
243 warnings.extend(built.warnings)
244 warnings.extend(loose_vents)
245
246 assumptions = tuple(
248 component=branch.component.instance.id,
249 parameter=name,
250 value=branch.component.instance.params[name].value,
251 unit=branch.component.instance.params[name].unit,
252 source=branch.component.instance.params[name].source.value,
253 reference=branch.component.instance.params[name].reference,
254 )
255 for branch in built.network.branches.values()
256 for name in assumed_params(branch.component.instance.params)
257 )
258
259 # The engine's own provenance, in the same shape. A config carries its
260 # design point twice -- `design_requirements` (intent) and
261 # `chamber_geometry` (what the geometry was last sized at) -- and they
262 # drift. Intent wins on import, and *which* was used belongs on the report
263 # beside every other stated-versus-assumed number rather than shouted as a
264 # warning: a disagreement is the normal state of a config being worked on,
265 # and a warning that fires on healthy input teaches people to skim.
266 if engine is not None:
267 # provenance key -> (attribute on the design, unit for the report)
268 engine_fields = {
269 "mixture_ratio": ("design_mixture_ratio", "-"),
270 "design_chamber_pressure": ("design_chamber_pressure", "Pa"),
271 }
272 engine_notes: list[Assumption] = []
273 for key, note in sorted(engine.provenance.items()):
274 field = engine_fields.get(key)
275 if field is None:
276 continue
277 attribute, unit = field
278 engine_notes.append(
280 component="engine",
281 parameter=key,
282 value=float(getattr(engine, attribute, 0.0) or 0.0),
283 unit=unit,
284 # "resolved" when the config disagreed with itself and
285 # intent won; "measured" when it simply stated one value.
286 source="resolved" if "intent wins" in note else "measured",
287 reference=note,
288 )
289 )
290 assumptions = assumptions + tuple(engine_notes)
291
292 report = AssemblyReport(
293 diagram=diagram_id,
294 engine=reference,
295 symbols=len(diagram.nodes),
296 lines=len(diagram.edges),
297 nodes=len(built.network.nodes),
298 branches=len(built.network.branches),
299 instruments=len(built.instruments),
300 actuators=len(built.actuators),
301 assumptions=assumptions,
302 warnings=tuple(warnings),
303 )
304
305 return Model(
306 diagram=diagram,
307 built=built,
308 report=report,
309 engine=engine,
310 chamber=chamber,
311 meta=dict(meta or {}),
312 )
313
314
315def chamber_for(engine: EngineDesign, cea_cache: str) -> Chamber:
316 """A chamber for this engine, from a CEA table when one is reachable.
317
318 A constant ``c*`` is the fallback and it is a real loss: with it, mixture
319 ratio still moves during a run but nothing downstream of it responds, which
320 reads as "O/F drift does not matter here" when it means "this model cannot
321 see it". So the fallback is named in the warnings rather than taken quietly.
322 """
323 from pathlib import Path
324
325 # is_file, not exists: a directory passes an existence check and then dies
326 # inside numpy with an IsADirectoryError that names neither the setting nor
327 # the caller.
328 losses = {
329 "efficiency": engine.cstar_efficiency,
330 "nozzle_efficiency": engine.nozzle_efficiency,
331 }
332 if cea_cache and Path(cea_cache).is_file():
333 return Chamber(
334 engine.throat_area,
335 CEATable(cea_cache, expansion_ratio=engine.expansion_ratio),
336 **losses,
337 )
338 return Chamber(engine.throat_area, ConstantCStar(cstar=1700.0), **losses)
339
340
341def swap_gases(diagram: Diagram, swaps: Mapping[str, str]) -> Diagram:
342 """The pressurant replaced: every symbol that declares a gas in ``swaps``
343 -- the bottles, and any valve or line labelled with it -- declares the
344 other instead, keeping its own temperature. Tanks and dewars hold liquids
345 and are :func:`swap_fluids`'s. The network spreads gases from what is
346 declared (a K-bottle first), so this is the whole of the change: helium
347 in the COPV, its press lines and the ullages it fills."""
348 from dataclasses import replace
349
350 def swapped(node: PidNode) -> PidNode:
351 if node.type in ("TANK", "DEWAR") or node.fluid not in swaps:
352 return node
353 return replace(node, fluid=swaps[node.fluid])
354
355 return replace(diagram, nodes=tuple(swapped(n) for n in diagram.nodes))
356
357
358def swap_fluids(diagram: Diagram, swaps: Mapping[str, tuple[str, float]]) -> Diagram:
359 """Tank contents replaced, for a cold flow. Temperature travels with the
360 species -- water at the LOX tank's 90 K is ice."""
361 from dataclasses import replace
362
363 from feedtwin.model.param import Param, Provenance
364
365 def swapped(node: PidNode) -> PidNode:
366 if node.type != "TANK" or node.fluid not in swaps:
367 return node
368 species, temperature = swaps[node.fluid]
369 params = dict(node.params)
370 params["temperature"] = Param(
371 temperature, "K", Provenance.MEASURED, f"{species} for this test"
372 )
373 return replace(node, fluid=species, params=params)
374
375 return replace(diagram, nodes=tuple(swapped(n) for n in diagram.nodes))
None __init__(self, str step, str detail)
Definition model.py:54
Chamber chamber_for(EngineDesign engine, str cea_cache)
Definition model.py:315
dict[str, object] engine_summary(EngineDesign design)
Definition model.py:143
Diagram swap_fluids(Diagram diagram, Mapping[str, tuple[str, float]] swaps)
Definition model.py:358
Diagram swap_gases(Diagram diagram, Mapping[str, str] swaps)
Definition model.py:341
Model assemble_model(Diagram diagram, *, str diagram_id, EngineDesign|None engine=None, str engine_reference="", Chamber|None chamber=None, str cea_cache="", Mapping[str, tuple[str, float]]|None fluid_swap=None, Mapping[str, str]|None gas_swap=None, bool multiphase=False, Mapping[str, object]|None meta=None, bool vehicle_only=False)
Definition model.py:182
str find_engine_reference(Diagram diagram)
Definition model.py:155
dict[str, object] diagram_summary(Mapping[str, Any] payload)
Definition model.py:111