STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
sources.py
Go to the documentation of this file.
1"""Where Layer X's drawings, state machines and combustion tables come from.
2
3A drawing is the hardware record (ADR-0002): topology, line geometry, tank and
4bottle volumes, the regulator. Layer X never edits one. It reads drawings from
5these places and treats them identically once read, because what identifies a
6drawing is its **bytes**:
7
8* the feed-twin set shipped in ``feed-twin/backend/diagrams`` (the stand and the
9 two COPV-study drawings), listed as benchmarks;
10* the feed twin's own library, and the sample set it bundles in
11 ``feed-twin/backend/sample`` (the team's current stand, which is *featured*:
12 a browser with no drawing of its own opens on it);
13* drawings uploaded to Layer X, kept per user;
14* pid-designer documents, pulled by the router (it owns the HTTP) and stored
15 here like an upload, with where they came from recorded.
16
17Every record carries the sha256 of its bytes. A run quotes it, so "which drawing
18was this" is answerable without trusting a name.
19"""
20
21from __future__ import annotations
22
23import hashlib
24import json
25import re
26import os
27import time
28from dataclasses import dataclass, field
29from pathlib import Path
30from typing import Any, Dict, List, Mapping, Optional
31
32_ENGINE_ROOT = Path(__file__).resolve().parents[2]
33_STAR_ROOT = _ENGINE_ROOT.parent
34
35
36def feedtwin_root() -> Path:
37 """The feed-twin app checkout, whose shipped drawings and DAQ tables Layer X reads."""
38 return Path(os.environ.get("FEEDTWIN_ROOT") or (_STAR_ROOT / "feed-twin"))
39
40
42 return Path(os.environ.get("LAYERX_DRAWINGS") or (feedtwin_root() / "backend" / "diagrams"))
43
44
46 """The feed twin's own library: every drawing its cockpit can open (the shipped ones and what it
47 pulled from pid-designer), as ``manifest.json`` plus ``blobs/``."""
48 return Path(os.environ.get("LAYERX_FEEDTWIN_LIBRARY") or (feedtwin_root() / "backend" / "library"))
49
50
51def sample_dir() -> Path:
52 """The sample set feed-twin bundles (the team's current stand drawing and engine): the drawing
53 in it is the featured one, the stand a fresh browser opens on."""
54 return Path(os.environ.get("LAYERX_SAMPLE") or (feedtwin_root() / "backend" / "sample"))
55
56
57def machines_dir() -> Path:
58 """The DAQ's state-machine tables, as feed-twin ships them (see NEEDS-REPAIR.md there)."""
59 return Path(os.environ.get("LAYERX_STATEMACHINES") or (feedtwin_root() / "backend" / "statemachines"))
60
61
62def _fallback_psi(kind: str, name: str) -> Optional[float]:
63 """The feed twin's own default for a parameter a drawing leaves out, as a difference in psi."""
64 try:
65 from feedtwin.model.units import to_si
66 from feedtwin.pid.network import FALLBACKS
67
68 value, unit = FALLBACKS[kind][name]
69 return to_si(float(value), unit) / 6894.757293168361
70 except Exception: # noqa: BLE001 - a summary never fails a listing
71 return None
72
73
74def source_problems() -> List[str]:
75 """Each place Layer X reads drawings from that is not there, in words, for the server log and
76 ``GET /api/layerx/status``. A missing one lists nothing and says nothing: the deployed image
77 resolved the twin's library and sample set to ``/feed-twin/backend/...`` (no checkout beside it),
78 so production Layer X never saw what the cockpit holds, nor the featured stand."""
79 out: List[str] = []
80 for what, path, env in (("the shipped drawings", shipped_drawings_dir(), "LAYERX_DRAWINGS"),
81 ("the feed twin's library", feedtwin_library_dir(), "LAYERX_FEEDTWIN_LIBRARY"),
82 ("feed-twin's sample set (the featured stand)", sample_dir(), "LAYERX_SAMPLE"),
83 ("the DAQ state-machine tables", machines_dir(), "LAYERX_STATEMACHINES")):
84 if not path.is_dir():
85 out.append(f"{what}: {path} is not a directory (set {env})")
86 return out
87
88
89def cea_table_path(config: Any) -> Optional[Path]:
90 """The engine's own CEA cache, resolved the way EngineDesign's CEACache resolves it."""
91 try:
92 name = config.combustion.cea.cache_file
93 except AttributeError:
94 return None
95 if not name:
96 return None
97 path = Path(name)
98 candidates = [path] if path.is_absolute() else [Path.cwd() / path, _ENGINE_ROOT / path]
99 for candidate in candidates:
100 if candidate.is_file():
101 return candidate.resolve()
102 return None
103
104
105# ------------------------------------------------------------------ drawings
106
107
108def sha256(data: bytes) -> str:
109 return hashlib.sha256(data).hexdigest()
110
111
112def drawing_id(data: bytes) -> str:
113 """Short content address. Sixteen hex characters: a collision needs ~2^32 drawings."""
114 return sha256(data)[:16]
115
116
117def normalise(payload: Mapping[str, Any]) -> bytes:
118 """A pid-designer document reduced to what feed-twin reads, as stable bytes."""
119 return json.dumps(
120 {"nodes": payload.get("nodes") or [], "edges": payload.get("edges") or []},
121 indent=1,
122 ).encode("utf-8")
123
124
125def summarize(payload: Mapping[str, Any], name: str = "") -> Dict[str, Any]:
126 """What a person choosing a drawing needs to see, without building a network.
127
128 Read through feedtwin's own reader so a drawing it cannot read says so here,
129 not halfway into a run.
130 """
131 from feedtwin.pid import DiagramError, read_diagram
132
133 try:
134 diagram = read_diagram(payload, name=name or "drawing")
135 except DiagramError as exc:
136 return {"readable": False, "error": str(exc)}
137
138 def param(node: Any, key: str, unit: str) -> Optional[float]:
139 p = node.params.get(key)
140 if p is None:
141 return None
142 try:
143 if unit == "psig": # an absolute pressure, as a gauge reads it
144 return (float(p.si) - 101325.0) / 6894.757293168361
145 if unit == "psi": # a difference
146 return float(p.si) / 6894.757293168361
147 return float(p.si if unit == "si" else p.value)
148 except Exception: # noqa: BLE001 - a summary never fails a listing
149 return None
150
151 from engine.layerx.vehicle import vehicle_ids
152
153 # Tanks and bottles on the vehicle only; a GSE page's transfer tank and cart bottles are listed
154 # apart, as ground support (engine.layerx.vehicle).
155 vehicle = vehicle_ids(diagram)
156 ground: List[Dict[str, Any]] = []
157 tanks, bottles, regulators, engines, valves = [], [], [], [], []
158 for node in diagram.nodes:
159 if node.type in ("TANK", "KBOTTLE", "DEWAR") and vehicle is not None and node.id not in vehicle:
160 volume = node.params.get("volume")
161 ground.append({"id": node.id, "label": node.label, "fluid": node.fluid, "type": node.type,
162 "volume_L": (volume.si * 1e3) if volume is not None else None})
163 continue
164 if node.type == "TANK":
165 volume = node.params.get("volume")
166 tanks.append({
167 "id": node.id, "label": node.label, "fluid": node.fluid,
168 "volume_L": (volume.si * 1e3) if volume is not None else None,
169 "mawp_psi": param(node, "MAWP", "psig"),
170 })
171 elif node.type == "KBOTTLE":
172 volume = node.params.get("volume")
173 bottles.append({
174 "id": node.id, "label": node.label, "fluid": node.fluid,
175 "volume_L": (volume.si * 1e3) if volume is not None else None,
176 "pressure_psi": param(node, "pressure", "psig"),
177 "mawp_psi": param(node, "MAWP", "psig"),
178 })
179 elif node.type == "PR":
180 droop = param(node, "flow_droop", "psi")
181 regulators.append({
182 "id": node.id, "label": node.label,
183 "dome_loaded": node.options.get("domeLoaded") == "yes",
184 "setpoint_psi": param(node, "setpoint", "psig"),
185 "dome_bias_psi": param(node, "dome_bias", "psi"),
186 "supply_coefficient": param(node, "supply_coefficient", "value"),
187 # Not on the drawing: the twin runs its library default (feedtwin.pid.network FALLBACKS),
188 # and the rail says so rather than leaving the row blank beside 'from the drawing'.
189 "flow_droop_psi": droop if droop is not None else _fallback_psi("regulator", "flow_droop"),
190 "flow_droop_default": droop is None,
191 })
192 elif node.type in ("ENGINE", "INJECTOR"):
193 engines.append({"id": node.id, "label": node.label})
194 elif node.is_inline and node.type in ("SOL", "MAN", "ROT", "BV", "AV"):
195 valves.append({"id": node.id, "label": node.label, "type": node.type})
196 return {
197 "readable": True,
198 "symbols": len(diagram.nodes),
199 "lines": len(diagram.edges),
200 "tanks": tanks,
201 "bottles": bottles,
202 "regulators": regulators,
203 "engines": engines,
204 "valves": len(valves),
205 "ground_support": ground,
206 }
207
208
209#: Words for the tokens of a file stem, so a menu reads "COPV study, helium", not "copv_study_he".
210_STEM_WORDS = {"copv": "COPV", "gn2": "GN2", "he": "helium", "n2": "N2", "ln2": "LN2", "lox": "LOX", "le4": "LE4"}
211_GAS_WORDS = {"GN2", "helium", "N2", "LN2"}
212
213
214def display_name(name: str) -> str:
215 """A drawing's name as a person reads it. A file stem (``copv_study_he``, what a shipped drawing
216 is named by) comes back in words (``COPV study, helium``); any other name, which a person chose,
217 comes back as it is."""
218 if not re.fullmatch(r"[a-z0-9]+(?:_[a-z0-9]+)+", name or ""):
219 return name
220 words = [_STEM_WORDS.get(w, w) for w in name.split("_")]
221 if len(words) > 2 and words[-1] in _GAS_WORDS:
222 text = " ".join(words[:-1]) + ", " + words[-1]
223 else:
224 text = " ".join(words)
225 return text[:1].upper() + text[1:]
226
227
228def origin_words(source: str) -> str:
229 """Where a drawing came from, in words, for the menu's second line (the raw ``source`` stays in
230 the run record)."""
231 if source.startswith("shipped:"):
232 return "benchmark, shipped with the code"
233 if source.startswith("sample:"):
234 return "Hotbench sample stand"
235 if source.startswith("feed-twin library"):
236 rest = source.split(":", 1)[1].strip() if ":" in source else ""
237 return "Feed Twin library, from pid-designer" if rest.startswith("pid-designer") else "Feed Twin library"
238 if source.startswith("pid-designer:"):
239 return "pid-designer, " + ("working copy" if source.endswith("working copy") else "release")
240 if source == "upload":
241 return "uploaded here"
242 return source
243
244
245@dataclass
247 """One drawing, read and addressed by content."""
248
249 id: str
250 name: str
251 source: str
252 """``shipped:<file>``, ``sample:<file>``, ``feed-twin library: <its source>``, ``upload``, or
253 ``pid-designer:<owner>/<id>@<release|working copy>``."""
254 sha256: str
255 payload: Dict[str, Any] = field(repr=False)
256 added: float = 0.0
257 featured: bool = False
258 """The team's current stand: the sample feed-twin bundles, or a library entry marked featured.
259 A browser with no drawing of its own opens on it."""
260
261 def summary(self) -> Dict[str, Any]:
262 return summarize(self.payload, self.name)
263
264 def listing(self) -> Dict[str, Any]:
265 return {
266 "id": self.id,
267 "name": self.name,
268 # What a menu shows: the name in words, where it came from in words, and which shelf it
269 # sits on (the team's stands, or the benchmarks shipped with the code).
270 "label": display_name(self.name),
271 "origin": origin_words(self.source),
272 "group": "Benchmarks" if self.source.startswith("shipped:") else "Stands",
273 "featured": self.featured,
274 "source": self.source,
275 "sha256": self.sha256,
276 "added": self.added,
277 "summary": self.summary(),
278 }
279
280
281def _read(path: Path) -> Optional[bytes]:
282 try:
283 return path.read_bytes()
284 except OSError:
285 return None
286
287
288#: Shipped drawings, by (path, mtime, size): parsed and hashed once per version.
289_SHIPPED_CACHE: Dict[Any, Any] = {}
290
291
293 """The shipped set plus one user's own drawings.
294
295 Uploads and pulls live in ``<user dir>/layerx/drawings`` as ``<id>.json`` with
296 a ``<id>.meta.json`` beside them. Shipped drawings are read in place and
297 never copied.
298 """
299
300 def __init__(self, user_dir: Optional[Path]) -> None:
301 self.dir = (user_dir / "layerx" / "drawings") if user_dir is not None else None
302
303 def _shipped(self) -> List[Drawing]:
304 out: List[Drawing] = []
305 base = shipped_drawings_dir()
306 if not base.is_dir():
307 return out
308 for path in sorted(base.glob("*.json")):
309 try:
310 st = path.stat()
311 except OSError:
312 continue
313 # Read, parsed and hashed once per file version, not on every lookup.
314 stamp = (str(path), st.st_mtime_ns, st.st_size)
315 hit = _SHIPPED_CACHE.get(stamp)
316 if hit is None:
317 raw = _read(path)
318 if raw is None:
319 continue
320 try:
321 payload = json.loads(raw.decode("utf-8"))
322 except (UnicodeDecodeError, json.JSONDecodeError):
323 continue
324 hit = Drawing(id=drawing_id(raw), name=path.stem, source=f"shipped:{path.name}",
325 sha256=sha256(raw), payload=payload, added=st.st_mtime)
326 _SHIPPED_CACHE[stamp] = hit
327 out.append(hit)
328 return out
329
330 def _feedtwin_library(self) -> List[Drawing]:
331 """The drawings in the feed twin's library, read in place: Layer X burns the feed system
332 the twin holds (2026-10-03, "pull from it and trust it"), including what the twin pulled
333 from pid-designer, not only the files feed-twin ships."""
334 out: List[Drawing] = []
335 base = feedtwin_library_dir()
336 manifest_path = base / "manifest.json"
337 try:
338 st = manifest_path.stat()
339 except OSError:
340 return out
341 stamp = (str(manifest_path), st.st_mtime_ns, st.st_size)
342 hit = _SHIPPED_CACHE.get(stamp)
343 if hit is not None:
344 return list(hit)
345 try:
346 manifest = json.loads(manifest_path.read_text("utf-8"))
347 except (OSError, UnicodeDecodeError, json.JSONDecodeError):
348 return out
349 for entry in manifest if isinstance(manifest, list) else []:
350 if not isinstance(entry, Mapping) or entry.get("kind") != "diagram" or not entry.get("filename"):
351 continue
352 raw = _read(base / "blobs" / str(entry["filename"]))
353 if raw is None:
354 continue
355 try:
356 payload = json.loads(raw.decode("utf-8"))
357 except (UnicodeDecodeError, json.JSONDecodeError):
358 continue
359 if not summarize(payload, str(entry.get("name") or "")).get("readable"):
360 continue
361 imported = str(entry.get("imported_at") or "")
362 out.append(Drawing(id=drawing_id(raw), name=str(entry.get("name") or entry["filename"]),
363 source=f"feed-twin library: {entry.get('source') or 'library'}",
364 sha256=sha256(raw), payload=payload,
365 added=time.mktime(time.strptime(imported[:19], "%Y-%m-%dT%H:%M:%S")) if imported else 0.0,
366 featured=entry.get("featured") is True))
367 _SHIPPED_CACHE[stamp] = tuple(out)
368 return out
369
370 def _sample(self) -> List[Drawing]:
371 """The drawings in feed-twin's sample set, read in place: featured. A library that already
372 holds the same bytes lists it under the library's name (``list`` keeps the first met)."""
373 out: List[Drawing] = []
374 base = sample_dir()
375 if not base.is_dir():
376 return out
377 for path in sorted(base.glob("*.json")):
378 try:
379 st = path.stat()
380 except OSError:
381 continue
382 stamp = ("sample", str(path), st.st_mtime_ns, st.st_size)
383 hit = _SHIPPED_CACHE.get(stamp)
384 if hit is None:
385 raw = _read(path)
386 if raw is None:
387 continue
388 try:
389 payload = json.loads(raw.decode("utf-8"))
390 except (UnicodeDecodeError, json.JSONDecodeError):
391 continue
392 # The set also carries the engine's card and the like: only a drawing is listed.
393 if not isinstance(payload, dict) or not isinstance(payload.get("nodes"), list) \
394 or not summarize(payload, path.stem).get("readable"):
395 _SHIPPED_CACHE[stamp] = False
396 continue
397 hit = Drawing(id=drawing_id(raw), name=path.stem, source=f"sample:{path.name}",
398 sha256=sha256(raw), payload=payload, added=st.st_mtime, featured=True)
399 _SHIPPED_CACHE[stamp] = hit
400 if hit:
401 out.append(hit)
402 return out
403
404 def _own_one(self, path: Path) -> List[Drawing]:
405 raw = _read(path)
406 if raw is None:
407 return []
408 meta: Dict[str, Any] = {}
409 meta_raw = _read(path.with_suffix(".meta.json"))
410 if meta_raw:
411 try:
412 meta = json.loads(meta_raw.decode("utf-8"))
413 except (UnicodeDecodeError, json.JSONDecodeError):
414 meta = {}
415 try:
416 payload = json.loads(raw.decode("utf-8"))
417 except (UnicodeDecodeError, json.JSONDecodeError):
418 return []
419 return [Drawing(
420 id=drawing_id(raw), name=str(meta.get("name") or path.stem),
421 source=str(meta.get("source") or "upload"), sha256=sha256(raw),
422 payload=payload, added=float(meta.get("added") or path.stat().st_mtime),
423 )]
424
425 def _own(self) -> List[Drawing]:
426 out: List[Drawing] = []
427 if self.dir is None or not self.dir.is_dir():
428 return out
429 for path in sorted(self.dir.glob("*.json")):
430 if path.name.endswith(".meta.json"):
431 continue
432 out.extend(self._own_one(path))
433 return out
434
435 def list(self) -> List[Drawing]:
436 from dataclasses import replace
437
438 seen: Dict[str, Drawing] = {}
439 # The same bytes are one drawing, under the first name met: the shipped file, then the
440 # feed twin's library copy, then the sample set, then this user's own. Featured if any copy
441 # of it is: the sample's bytes, or a library entry marked so.
442 everything = self._shipped() + self._feedtwin_library() + self._sample() + self._own()
443 featured = {d.id for d in everything if d.featured}
444 for drawing in everything:
445 seen.setdefault(drawing.id, drawing)
446 out = [replace(d, featured=True) if d.id in featured and not d.featured else d for d in seen.values()]
447 # Two versions under one name (an older study, a re-import from pid-designer) are told apart
448 # by when they arrived; the first of a name keeps it plain.
449 from collections import Counter
450
451 count = Counter(d.name for d in out)
452 named: Dict[str, int] = {}
453 for k, d in enumerate(out):
454 if count[d.name] > 1:
455 named[d.name] = named.get(d.name, 0) + 1
456 if named[d.name] > 1 and d.added:
457 out[k] = replace(d, name=f"{d.name} ({time.strftime('%b %d %H:%M', time.localtime(d.added))})")
458 return out
459
460 def get(self, drawing_id_: str) -> Optional[Drawing]:
461 # An upload is stored under its id: open that one file rather than read every drawing.
462 if self.dir is not None and re.fullmatch(r"[0-9a-zA-Z_-]{1,80}", drawing_id_ or ""):
463 path = self.dir / f"{drawing_id_}.json"
464 if path.is_file():
465 for drawing in self._own_one(path):
466 if drawing.id == drawing_id_:
467 return drawing
468 for drawing in self._shipped() + self._feedtwin_library() + self._sample():
469 if drawing.id == drawing_id_:
470 return drawing
471 for drawing in self._own():
472 if drawing.id == drawing_id_:
473 return drawing
474 return None
475
476 def add(self, payload: Mapping[str, Any], *, name: str, source: str) -> Drawing:
477 """Store a drawing. Same bytes, same id: adding twice is a no-op that
478 updates nothing but the name a person sees."""
479 if self.dir is None:
480 raise RuntimeError("no user directory to store drawings in")
481 raw = normalise(payload)
482 summary = summarize(json.loads(raw), name)
483 if not summary.get("readable"):
484 raise ValueError(f"feed-twin cannot read that drawing: {summary.get('error')}")
485 self.dir.mkdir(parents=True, exist_ok=True)
486 ident = drawing_id(raw)
487 (self.dir / f"{ident}.json").write_bytes(raw)
488 meta = {"name": name, "source": source, "added": time.time()}
489 (self.dir / f"{ident}.meta.json").write_text(json.dumps(meta))
490 return Drawing(id=ident, name=name, source=source, sha256=sha256(raw),
491 payload=json.loads(raw), added=meta["added"])
None __init__(self, Optional[Path] user_dir)
Definition sources.py:300
List[Drawing] _sample(self)
Definition sources.py:370
Drawing add(self, Mapping[str, Any] payload, *, str name, str source)
Definition sources.py:476
Optional[Drawing] get(self, str drawing_id_)
Definition sources.py:460
List[Drawing] _own_one(self, Path path)
Definition sources.py:404
List[Drawing] _shipped(self)
Definition sources.py:303
List[Drawing] _feedtwin_library(self)
Definition sources.py:330
Dict[str, Any] summary(self)
Definition sources.py:261
Dict[str, Any] listing(self)
Definition sources.py:264
Path shipped_drawings_dir()
Definition sources.py:41
str display_name(str name)
Definition sources.py:214
Optional[Path] cea_table_path(Any config)
Definition sources.py:89
str drawing_id(bytes data)
Definition sources.py:112
Dict[str, Any] summarize(Mapping[str, Any] payload, str name="")
Definition sources.py:125
Optional[bytes] _read(Path path)
Definition sources.py:281
Optional[float] _fallback_psi(str kind, str name)
Definition sources.py:62
List[str] source_problems()
Definition sources.py:74
str origin_words(str source)
Definition sources.py:228
Path feedtwin_library_dir()
Definition sources.py:45
bytes normalise(Mapping[str, Any] payload)
Definition sources.py:117
str sha256(bytes data)
Definition sources.py:108
TOML_NODISCARD_CTOR time() noexcept=default