STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
document.py
Go to the documentation of this file.
1"""A P&ID as a document, before it is a network.
2
3pid-designer stores a drawing as ``{"nodes": [...], "edges": [...]}`` -- React
4Flow's own shape, with the engineering carried in each item's ``data``. This
5module reads that into typed records and does nothing else. No physics, no
6topology, no opinions: parsing and interpreting are separated because the parse
7must succeed on a drawing that is half-finished, and the interpretation must be
8allowed to refuse one.
9
10The field names are pid-designer's, unchanged. A translation table between the
11two apps would be a third place for a name to be wrong, and the whole reason
12the drawing stores ``feedtwin.model.Param``'s exact shape is so that no such
13table is needed.
14"""
15
16from __future__ import annotations
17
18import json
19from dataclasses import dataclass, field, replace
20from pathlib import Path
21from typing import Any, Iterable, Mapping
22
23from feedtwin.model.param import Param, Provenance
24from feedtwin.model.pressure import PressureReferenceError, drawn_unit
25from feedtwin.model.segments import LineLoss
26from feedtwin.pid.errors import DiagramError as DiagramError
27from feedtwin.pid.segments import read_segments
28
29#: Component types that carry mass between two points. Everything else is
30#: either a place (a tank, a junction) or an observer (a transducer).
31INLINE_TYPES = frozenset({"MAN", "ROT", "SOL", "MOV", "PR", "RV", "CV", "QD"})
32
33#: Types that declare a fluid and a pressure: where a solve starts.
34SOURCE_TYPES = frozenset({"TANK", "KBOTTLE", "DEWAR"})
35
36#: Symbols that are a boundary rather than a place: where the fluid goes when it
37#: leaves. ``VENT`` is atmosphere. See feedtwin.pid.network.SINK_TYPES.
38BOUNDARY_TYPES = frozenset({"ENGINE", "INJECTOR", "VENT"})
39
40#: Types that measure without flowing. They read the pressure where they are
41#: clipped and contribute nothing to the network -- which is exactly what a
42#: transducer does, and why treating one as a component would be wrong.
43INSTRUMENT_TYPES = frozenset({"PT", "PG", "RTD", "TC", "LC"})
44
45#: Drawn, but not part of a feed solve.
46ANNOTATION_TYPES = frozenset({"TEXT", "REGION"})
47
48
49@dataclass(frozen=True, slots=True)
50class PidNode:
51 """One symbol on the drawing."""
52
53 id: str
54 type: str
55 label: str
56 x: float
57 y: float
58 fluid: str = ""
59 role: str = ""
60 """What pid-designer colours this by -- ``fuel``, ``lox``, ``pressurant``.
61 A *role*, not a species: it says which leg a symbol belongs to and nothing
62 about what is in it. Carried so a drawing that set the colour and forgot the
63 fluid can be told so specifically, rather than defaulted in silence."""
64
65 page: str = ""
66 attached_to: str = ""
67 attached_at: float = 0.5
68 """Where on a *line* a clipped probe sits: the fraction of the line's drawn
69 length from its source end (pid-designer's ``attachedAt``). A probe saved
70 before it existed was drawn at the middle. Meaningless on a symbol."""
71 params: Mapping[str, Param] = field(default_factory=dict)
72 options: Mapping[str, str] = field(default_factory=dict)
73 ports: Mapping[str, Mapping[str, str]] = field(default_factory=dict)
74 drawn_as: str = ""
75 """The symbol the drawing used, when it is read as another -- see
76 :func:`_gas_tank_as_bottle`. Empty when the symbol is read as drawn."""
77 rotation: float = 0.0
78 """Degrees the symbol is turned on the drawing. A tank's ``t`` ports are on
79 its top only while it is upright."""
80
81 @property
82 def is_inline(self) -> bool:
83 return self.type in INLINE_TYPES
84
85 @property
86 def is_source(self) -> bool:
87 return self.type in SOURCE_TYPES
88
89 @property
90 def is_instrument(self) -> bool:
91 return self.type in INSTRUMENT_TYPES
92
93 @property
94 def is_annotation(self) -> bool:
95 return self.type in ANNOTATION_TYPES
96
97
98@dataclass(frozen=True, slots=True)
99class PidEdge:
100 """One line between two symbols."""
101
102 id: str
103 source: str
104 target: str
105 source_handle: str = ""
106 target_handle: str = ""
107 line_type: str = "pipe"
108 page: str = ""
109 params: Mapping[str, Param] = field(default_factory=dict)
110 options: Mapping[str, str] = field(default_factory=dict)
111 segments: LineLoss = field(default_factory=LineLoss)
112 """The run itemised: tube, bores, fittings. Empty when the drawing says
113 nothing, which is when :attr:`params` is the whole story.
114
115 A line that has segments has them *instead of* its line-level ``length``
116 and ``bore`` -- the drawing's editor greys those fields out and says so.
117 Reading both would double-count the run."""
118
119
120@dataclass(frozen=True, slots=True)
122 """A whole drawing."""
123
124 nodes: tuple[PidNode, ...]
125 edges: tuple[PidEdge, ...]
126 name: str = ""
127
128 def node(self, node_id: str) -> PidNode | None:
129 return next((n for n in self.nodes if n.id == node_id), None)
130
131 def of_type(self, *types: str) -> list[PidNode]:
132 wanted = set(types)
133 return [n for n in self.nodes if n.type in wanted]
134
135 @property
136 def pages(self) -> list[str]:
137 """Page names; a node with none is on "Main", as pid-designer reads it."""
138 return sorted({n.page or "Main" for n in self.nodes})
139
140 def on_page(self, page: str) -> Diagram:
141 """The drawing restricted to one page, with dangling edges dropped."""
142 nodes = tuple(n for n in self.nodes if (n.page or "Main") == page)
143 keep = {n.id for n in nodes}
144 edges = tuple(e for e in self.edges if e.source in keep and e.target in keep)
145 return Diagram(nodes=nodes, edges=edges, name=f"{self.name}#{page}")
146
147
148_PROVENANCE = {p.value: p for p in Provenance}
149
150
151def _params(raw: Any, where: str, symbol: str = "") -> dict[str, Param]:
152 """Read a ``{name: {value, unit, source, reference}}`` block.
153
154 A parameter with no ``source`` is refused rather than defaulted. The drawing
155 goes out of its way to always ask, so a missing one means the document was
156 written by something else -- and silently calling it a default would turn a
157 format mismatch into a number nobody checked.
158 """
159 if not isinstance(raw, Mapping):
160 return {}
161 out: dict[str, Param] = {}
162 for name, entry in raw.items():
163 if not isinstance(entry, Mapping) or entry.get("value") is None:
164 continue
165 source = str(entry.get("source", ""))
166 if source not in _PROVENANCE:
167 raise DiagramError(
168 f"{where}: parameter {name!r} has source {source!r}; expected "
169 f"one of {', '.join(sorted(_PROVENANCE))}"
170 )
171 try:
172 out[str(name)] = Param(
173 value=float(entry["value"]),
174 # A bare "psi" on an absolute pressure is gauge, as on the dial;
175 # a difference may not carry a reference (feedtwin.model.pressure).
176 unit=drawn_unit(str(name), str(entry.get("unit", "-")), symbol),
177 source=_PROVENANCE[source],
178 reference=str(entry.get("reference", "")),
179 )
180 except PressureReferenceError as exc:
181 raise DiagramError(f"{where}: {exc}") from exc
182 except (KeyError, TypeError, ValueError) as exc:
183 raise DiagramError(f"{where}: parameter {name!r} is malformed ({exc})")
184 return out
185
186
187def _options(raw: Any) -> dict[str, str]:
188 if not isinstance(raw, Mapping):
189 return {}
190 return {str(k): str(v) for k, v in raw.items()}
191
192
193def _ports(raw: Any) -> dict[str, dict[str, str]]:
194 if not isinstance(raw, Mapping):
195 return {}
196 out: dict[str, dict[str, str]] = {}
197 for port_id, entry in raw.items():
198 if isinstance(entry, Mapping):
199 out[str(port_id)] = {str(k): str(v) for k, v in entry.items()}
200 return out
201
202
203def _fraction(raw: Any, default: float) -> float:
204 """A fraction in [0, 1], or ``default`` when it is missing or not a number."""
205 try:
206 value = float(raw)
207 except (TypeError, ValueError):
208 return default
209 if value != value: # NaN
210 return default
211 return min(max(value, 0.0), 1.0)
212
213
214def _rotation(raw: Any) -> float:
215 try:
216 return float(raw or 0.0) % 360.0
217 except (TypeError, ValueError):
218 return 0.0
219
220
221def read_diagram(payload: Mapping[str, Any], *, name: str = "diagram") -> Diagram:
222 """Parse a saved drawing. Lenient by design -- see the module docstring."""
223 raw_nodes = payload.get("nodes")
224 raw_edges = payload.get("edges")
225 if not isinstance(raw_nodes, Iterable) or not isinstance(raw_edges, Iterable):
226 raise DiagramError(
227 f"{name}: expected an object with 'nodes' and 'edges' lists; got keys "
228 f"{', '.join(sorted(str(k) for k in payload))}"
229 )
230
231 nodes: list[PidNode] = []
232 for raw in raw_nodes:
233 if not isinstance(raw, Mapping):
234 continue
235 node_raw = raw.get("data")
236 data: Mapping[str, Any] = node_raw if isinstance(node_raw, Mapping) else {}
237 pos_raw = raw.get("position")
238 position: Mapping[str, Any] = pos_raw if isinstance(pos_raw, Mapping) else {}
239 node_id = str(raw.get("id", ""))
240 if not node_id:
241 raise DiagramError(f"{name}: a node has no id")
242 nodes.append(
243 PidNode(
244 id=node_id,
245 type=str(data.get("componentType", raw.get("type", ""))),
246 label=str(data.get("label", node_id)),
247 x=float(position.get("x", 0.0) or 0.0),
248 y=float(position.get("y", 0.0) or 0.0),
249 fluid=str(data.get("fluid", "") or ""),
250 role=str(data.get("fluidType", "") or "").strip().lower(),
251 page=str(data.get("page", "") or ""),
252 attached_to=str(data.get("attachedTo", "") or ""),
253 attached_at=_fraction(data.get("attachedAt"), 0.5),
254 params=_params(
255 data.get("params"),
256 f"{name}:{node_id}",
257 str(data.get("componentType", raw.get("type", ""))),
258 ),
259 options=_options(data.get("options")),
260 ports=_ports(data.get("ports")),
261 rotation=_rotation(data.get("rotation", raw.get("rotation"))),
262 )
263 )
264
265 edges: list[PidEdge] = []
266 for raw in raw_edges:
267 if not isinstance(raw, Mapping):
268 continue
269 edge_raw = raw.get("data")
270 edge_data: Mapping[str, Any] = edge_raw if isinstance(edge_raw, Mapping) else {}
271 edge_id = str(raw.get("id", ""))
272 source, target = str(raw.get("source", "")), str(raw.get("target", ""))
273 if not edge_id or not source or not target:
274 raise DiagramError(f"{name}: an edge is missing id, source or target")
275 edges.append(
276 PidEdge(
277 id=edge_id,
278 source=source,
279 target=target,
280 source_handle=str(raw.get("sourceHandle", "") or ""),
281 target_handle=str(raw.get("targetHandle", "") or ""),
282 line_type=str(edge_data.get("lineType", "pipe") or "pipe"),
283 page=str(edge_data.get("page", "") or ""),
284 params=_params(edge_data.get("params"), f"{name}:{edge_id}"),
285 options=_options(edge_data.get("options")),
286 segments=read_segments(edge_data.get("segments"), f"{name}:{edge_id}"),
287 )
288 )
289
290 nodes = [_liquid_dewar_as_tank(_gas_tank_as_bottle(n)) for n in nodes]
291
292 known = {n.id for n in nodes}
293 dangling = [e.id for e in edges if e.source not in known or e.target not in known]
294 if dangling:
295 raise DiagramError(
296 f"{name}: {len(dangling)} line(s) connect to symbols that are not on "
297 f"the drawing: {', '.join(dangling[:5])}"
298 )
299
300 return Diagram(nodes=tuple(nodes), edges=tuple(edges), name=name)
301
302
303def _gas_tank_as_bottle(node: PidNode) -> PidNode:
304 """A TANK that can only hold gas is the pressurant supply: read it as one.
305
306 pid-designer offers ``copv`` as a tank *wall material*, so a COPV is easily
307 drawn with the propellant-tank symbol. Read as drawn it supplies nothing --
308 the press lines land on the tanks' liquid side -- and a stand cannot even
309 open, because a propellant tank starts from a saturated liquid that does not
310 exist above the critical point.
311
312 Only when the drawing itself rules out a liquid: the fluid and the
313 temperature are both stated, and the temperature is above that fluid's
314 critical temperature. A LOX tank that merely forgot its temperature is left
315 alone (and warned about as a missing temperature, which is what it is).
316 Nothing is invented -- the pressure, fluid and volume are the drawing's own.
317 """
318 if node.type != "TANK" or not node.fluid:
319 return node
320 temperature = node.params.get("temperature")
321 if temperature is None:
322 return node
323 try:
324 from feedtwin.props import Fluid
325
326 critical = Fluid(node.fluid).critical_temperature
327 except Exception: # noqa: BLE001 - an unknown fluid is the reader's to report
328 return node
329 if not temperature.si > critical:
330 return node
331 return replace(node, type="KBOTTLE", drawn_as="TANK")
332
333
334def _liquid_dewar_as_tank(node: PidNode) -> PidNode:
335 """A dewar holding liquid is a liquid supply: read it as a tank.
336
337 A LOX dewar was read as a pressurant bottle -- a gas volume -- which the
338 GN2 High Press charge then tried to pump to 4,500 psig, and which no fill
339 line could draw liquid from. As a tank it holds its liquid under its own
340 ullage, and on the ground-support side of a drawing it is the supply the
341 flight tank is loaded from (``BuiltNetwork.supplies``).
342
343 Only when the drawing says so: fluid and temperature stated, the
344 temperature below the fluid's critical point. A dewar used as a gas source
345 (warm, or above critical) is left as drawn.
346 """
347 if node.type != "DEWAR" or not node.fluid:
348 return node
349 temperature = node.params.get("temperature")
350 if temperature is None:
351 return node
352 try:
353 from feedtwin.props import Fluid
354
355 critical = Fluid(node.fluid).critical_temperature
356 except Exception: # noqa: BLE001 - an unknown fluid is the reader's to report
357 return node
358 if not temperature.si < critical:
359 return node
360 return replace(node, type="TANK", drawn_as="DEWAR")
361
362
363def load_diagram(path: str | Path) -> Diagram:
364 source = Path(path)
365 if not source.exists():
366 raise DiagramError(f"no diagram at {source}")
367 with source.open(encoding="utf-8") as handle:
368 payload = json.load(handle)
369 if not isinstance(payload, Mapping):
370 raise DiagramError(f"{source}: not a JSON object")
371 return read_diagram(payload, name=source.name)
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
list[PidNode] of_type(self, *str types)
Definition document.py:131
PidNode|None node(self, str node_id)
Definition document.py:128
Diagram on_page(self, str page)
Definition document.py:140
float _fraction(Any raw, float default)
Definition document.py:203
Diagram read_diagram(Mapping[str, Any] payload, *, str name="diagram")
Definition document.py:221
dict[str, Param] _params(Any raw, str where, str symbol="")
Definition document.py:151
float _rotation(Any raw)
Definition document.py:214
dict[str, dict[str, str]] _ports(Any raw)
Definition document.py:193
dict[str, str] _options(Any raw)
Definition document.py:187
PidNode _gas_tank_as_bottle(PidNode node)
Definition document.py:303
Diagram load_diagram(str|Path path)
Definition document.py:363
PidNode _liquid_dewar_as_tank(PidNode node)
Definition document.py:334