STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
export.py
Go to the documentation of this file.
1"""A Layer X run, exported (DATA-CONTRACT 4: ``GET /api/layerx/runs/{id}/export/{fmt}``).
2
3* :func:`to_csv` -- every per-step signal in the result, one row per twin step (``series.t``), one
4 column per signal, headed ``name [unit]``. The series, the delivered (replay) columns on the firing
5 steps, the replay's own points on their steps, the full network record and every diagnostics
6 column that follows the series. Floats are written with ``repr``, so they read back bit for bit.
7* :func:`to_parquet` -- the same table as Parquet (pyarrow), each field's unit in its metadata.
8* :func:`fea_bundle` -- a zip for structural and thermal analysis: ``pc_t.csv``, ``thrust_t.csv``,
9 ``heatflux_xt.csv`` (from the ``axial`` sidecar, when given), ``loads.json`` with the peak loads,
10 and ``README.txt`` with units and basis.
11
12Nothing is interpolated onto the twin's grid. A value sampled at a replay point is written on that
13point's step and left empty elsewhere: a table must not imply a resolution the model did not have.
14A block on its own clock that does not land on the twin's steps is not written to the table;
15:func:`columns` returns what was left out, and the Parquet file's metadata lists it
16(``columns_skipped``).
17"""
18
19from __future__ import annotations
20
21import csv
22import io
23import json
24import math
25import re
26import zipfile
27from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
28
29PSI = 6894.757293168361
30
31#: Unit of a column by the end of its name (longest match first), then by whole name.
32_SUFFIX_UNITS: Tuple[Tuple[str, str], ...] = (
33 # Compound suffixes before their tails: "_mm_s" (a recession rate) would otherwise read as "_s".
34 ("_MW_m2", "MW/m^2"), ("_kg_m3", "kg/m^3"), ("_m_s2", "m/s^2"), ("_mm_s", "mm/s"), ("_m_s", "m/s"),
35 ("_kg_s", "kg/s"),
36 ("_psia", "psia"), ("_psig", "psig"), ("_psi", "psi"), ("_pa", "Pa"), ("_Pa", "Pa"), ("_K", "K"),
37 ("_kg", "kg"), ("_N", "N"), ("_Ns", "N*s"), ("_hz", "Hz"), ("_Hz", "Hz"), ("_mm", "mm"), ("_m2", "m^2"),
38 ("_m3", "m^3"), ("_um", "um"), ("_ms", "ms"), ("_deg", "deg"), ("_cal", "cal"), ("_pct", "%"), ("_J", "J"),
39 ("_W", "W"), ("_L", "L"), ("_s", "s"), ("_m", "m"), ("_g", "g0"),
40)
41_NAME_UNITS = {
42 "t": "s", "dt": "s", "mdot": "kg/s", "mdot_O": "kg/s", "mdot_F": "kg/s", "mdot_ox": "kg/s",
43 "mdot_fuel": "kg/s", "capacity_mdot": "kg/s", "cstar": "m/s", "cstar_ideal": "m/s", "cstar_actual": "m/s",
44 "isp": "s", "Isp": "s", "thrust": "N", "p": "psia", "T": "K",
45 "v_ox": "m/s", "v_fuel": "m/s", "omega": "rad/s", "frequency": "Hz", "firing": "bool", "converged": "bool",
46 "extrapolated": "bool", "choked": "bool", "wide_open": "bool", "summerfield": "bool", "schmucker": "bool",
47 "replay_point": "bool", "in_start": "bool",
48}
49
50
51def unit_of(name: str) -> str:
52 """The unit a Layer X key carries, from the repo's naming convention (``*_psia``, ``*_K``, ...);
53 ``1`` for a key without a unit suffix, which in this result is a ratio or a fraction (ΔP/Pc,
54 O/F, ε, η, margins). Instrument and network columns pass their own unit explicitly."""
55 leaf = name.rsplit(".", 1)[-1]
56 if leaf in _NAME_UNITS:
57 return _NAME_UNITS[leaf]
58 for suffix, unit in _SUFFIX_UNITS:
59 if leaf.endswith(suffix):
60 return unit
61 return "1"
62
63
64def _cell(v: Any) -> Any:
65 if isinstance(v, bool):
66 return int(v)
67 if isinstance(v, (int, float)):
68 f = float(v)
69 return f if math.isfinite(f) else None
70 return None
71
72
73def _is_column(v: Any, n: int) -> bool:
74 return isinstance(v, list) and len(v) == n and all(x is None or isinstance(x, (int, float, bool)) for x in v)
75
76
77def _walk(prefix: str, block: Any, n: int, out: List[Tuple[str, List[Any], Optional[str]]],
78 skipped: List[str], depth: int = 0) -> None:
79 """Every per-step list of length ``n`` under ``block``, as ``(dotted name, values, unit)``."""
80 if depth > 6:
81 return
82 if isinstance(block, Mapping):
83 for key in sorted(block, key=str):
84 if key in ("model", "t", "index"):
85 continue
86 _walk(f"{prefix}.{key}" if prefix else str(key), block[key], n, out, skipped, depth + 1)
87 elif isinstance(block, list) and block and all(isinstance(x, Mapping) for x in block):
88 for k, item in enumerate(block):
89 tag = str(item.get("id") or item.get("line") or item.get("tank") or k)
90 _walk(f"{prefix}.{tag}", item, n, out, skipped, depth + 1)
91 elif _is_column(block, n):
92 out.append((prefix, [_cell(v) for v in block], None))
93 elif isinstance(block, list) and block and all(x is None or isinstance(x, (int, float, bool)) for x in block):
94 skipped.append(prefix)
95
96
97def _place(n: int, rows: Sequence[int], values: Sequence[Any]) -> List[Any]:
98 col: List[Any] = [None] * n
99 for i, v in zip(rows, values):
100 if 0 <= i < n:
101 col[i] = _cell(v)
102 return col
103
104
105def _rows_of(times: Sequence[Any], t: Sequence[float], tol: float = 1e-6) -> Optional[List[int]]:
106 """The series step of each time, when every time is one of the series' steps; else None."""
107 out = []
108 j = 0
109 for x in times:
110 if not isinstance(x, (int, float)):
111 return None
112 while j < len(t) and t[j] < x - tol:
113 j += 1
114 if j >= len(t) or abs(t[j] - x) > tol:
115 return None
116 out.append(j)
117 return out
118
119
120def columns(result: Mapping[str, Any]) -> Tuple[List[Tuple[str, str, List[Any]]], List[str]]:
121 """``([(name, unit, values)], skipped)``: the export table, one entry per column, every column as
122 long as ``series.t``; and the dotted names of per-point blocks that were not on the twin's steps."""
123 series = result.get("series") or {}
124 t = [float(v) for v in (series.get("t") or [])]
125 n = len(t)
126 if n == 0:
127 return [], []
128 cols: List[Tuple[str, str, List[Any]]] = []
129 skipped: List[str] = []
130
131 seen: Dict[str, int] = {}
132
133 def add(name: str, values: List[Any], unit: Optional[str] = None) -> None:
134 # One header per column: a second signal of the same name (two diagnostics rows sharing an
135 # id) is numbered rather than written over.
136 seen[name] = seen.get(name, 0) + 1
137 cols.append((name if seen[name] == 1 else f"{name}#{seen[name]}", unit or unit_of(name), values))
138
139 add("t", [_cell(v) for v in t], "s")
140 core = ("dt", "firing", "converged", "copv_psia", "copv_mass_kg", "copv_wall_K")
141 for key in core:
142 if _is_column(series.get(key), n):
143 add(key, [_cell(v) for v in series[key]])
144 for reg, block in sorted((series.get("regulators") or {}).items()):
145 for key in sorted(block):
146 if _is_column(block[key], n):
147 add(f"regulators.{reg}.{key}", [_cell(v) for v in block[key]])
148 for iid, inst in sorted((series.get("instruments") or {}).items()):
149 if isinstance(inst, Mapping) and _is_column(inst.get("values"), n):
150 tag = inst.get("tag") or iid
151 add(f"instruments.{iid}.{tag}", [_cell(v) for v in inst["values"]], str(inst.get("unit") or "1"))
152 for group in ("ox", "fuel", "chamber"):
153 found: List[Tuple[str, List[Any], Optional[str]]] = []
154 _walk(group, series.get(group) or {}, n, found, skipped)
155 for name, values, unit in found:
156 add(name, values, unit)
157 rest = [k for k in sorted(series) if k not in ("t", "regulators", "instruments", "ox", "fuel", "chamber") + core]
158 for key in rest:
159 found = []
160 _walk(key, series[key], n, found, skipped)
161 for name, values, unit in found:
162 add(name, values, unit)
163
164 # The replay's answers on the firing steps (delivered) and at its own points (replay).
165 firing = [i for i, f in enumerate(series.get("firing") or []) if f]
166 dv = result.get("delivered")
167 if isinstance(dv, Mapping) and isinstance(dv.get("t"), list):
168 rows = _rows_of(dv["t"], t) or (firing if len(firing) == len(dv["t"]) else None)
169 if rows is not None:
170 for key in sorted(dv):
171 if key in ("t", "summary") or not _is_column(dv[key], len(rows)):
172 continue
173 add(f"delivered.{key}", _place(n, rows, dv[key]))
174 else:
175 skipped.append("delivered")
176 rp = result.get("replay")
177 if isinstance(rp, Mapping) and rp.get("available") and isinstance(rp.get("index"), list):
178 rows = [int(i) for i in rp["index"]]
179 for key in sorted(rp):
180 if key in ("t", "index", "available") or not _is_column(rp[key], len(rows)):
181 continue
182 add(f"replay.{key}", _place(n, rows, rp[key]))
183
184 # The full network, per step (DATA-CONTRACT 2).
185 net = result.get("network")
186 if isinstance(net, Mapping):
187 for nid, node in sorted((net.get("nodes") or {}).items()):
188 for key in ("p_psia", "T_K"):
189 if isinstance(node, Mapping) and _is_column(node.get(key), n):
190 add(f"network.nodes.{nid}.{key}", [_cell(v) for v in node[key]])
191 for bid, br in sorted((net.get("branches") or {}).items()):
192 for key in ("mdot", "dp_psi", "state"):
193 if isinstance(br, Mapping) and _is_column(br.get(key), n):
194 add(f"network.branches.{bid}.{key}", [_cell(v) for v in br[key]],
195 "kg/s" if key == "mdot" else "psi" if key == "dp_psi" else "1")
196
197 # Diagnostics: a block on the series' clock is written as is; one on replay points, by its own
198 # ``index`` (series steps) or by times that are series steps; anything else is listed as skipped.
199 diag = result.get("diagnostics")
200 if isinstance(diag, Mapping):
201 for name in sorted(diag):
202 block = diag[name]
203 if name == "vv" or not isinstance(block, (Mapping, list)):
204 continue
205 _diagnostic(f"diagnostics.{name}", block, t, add, skipped)
206 flight = result.get("flight")
207 if isinstance(flight, Mapping) and isinstance(flight.get("stability"), Mapping):
208 _diagnostic("flight.stability", flight["stability"], t, add, skipped)
209 # Blocks of per-point data the table does not carry, named so the omission is on the record:
210 # ``timeseries`` is a copy of ``delivered`` in EngineDesign's Time-Series format and units (the
211 # flight curve), ``engine_check``/``cross_check`` are rows of nested comparisons at chosen
212 # instants, ``feed_fit`` is a fit, not a history.
213 for key in ("timeseries", "engine_check", "cross_check", "feed_fit"):
214 if isinstance(result.get(key), Mapping):
215 skipped.append(key)
216 return cols, sorted(set(skipped))
217
218
219#: Sub-blocks of a diagnostic that are not histories on the burn's clock (a frequency grid, a lag
220#: sweep, a contour, a window, a model's inputs), whatever their lengths happen to be.
221NOT_HISTORIES = frozenset({"model", "nyquist", "tau_sweep", "acoustic", "contour", "frames", "soak", "inputs",
222 "worst", "settled_min", "other_basis", "check", "feed", "start", "summary"})
223
224
225def _diagnostic(prefix: str, block: Any, t: Sequence[float], add: Any, skipped: List[str],
226 rows: Optional[List[int]] = None, depth: int = 0) -> None:
227 """Write the histories under a diagnostics block. A block carrying its own ``index`` (series
228 steps) or ``t`` (times that are series steps) places its lists on those steps, and its children
229 inherit that clock; a list on no known clock is listed in ``skipped``."""
230 n = len(t)
231 if depth > 5:
232 return
233 if isinstance(block, list):
234 if block and all(isinstance(x, Mapping) for x in block):
235 for k, item in enumerate(block):
236 tag = str(item.get("id") or item.get("line") or item.get("tank") or k)
237 _diagnostic(f"{prefix}.{tag}", item, t, add, skipped, rows, depth + 1)
238 return
239 if not isinstance(block, Mapping):
240 return
241 own_t, index = block.get("t"), block.get("index")
242 if isinstance(index, list) and index and all(isinstance(i, int) and not isinstance(i, bool) for i in index):
243 rows = list(index)
244 elif isinstance(own_t, list) and own_t:
245 rows = _rows_of(own_t, t)
246 if rows is None:
247 # Its own clock, not the twin's steps: nothing below it can be placed without inventing.
248 skipped.append(prefix)
249 return
250 for key in sorted(block, key=str):
251 if key in ("t", "index") or key in NOT_HISTORIES:
252 continue
253 v = block[key]
254 name = f"{prefix}.{key}"
255 if isinstance(v, Mapping) or (isinstance(v, list) and v and isinstance(v[0], Mapping)):
256 _diagnostic(name, v, t, add, skipped, rows, depth + 1)
257 continue
258 if not isinstance(v, list) or not v:
259 continue
260 if isinstance(v[0], list):
261 skipped.append(name) # a 2-D grid (a sidecar's shape) is not a column
262 continue
263 if not all(x is None or isinstance(x, (int, float, bool)) for x in v):
264 continue
265 if rows is not None and len(v) == len(rows):
266 add(name, _place(n, rows, v))
267 elif rows is None and len(v) == n:
268 add(name, [_cell(x) for x in v])
269 else:
270 skipped.append(name)
271
272
273# ---------------------------------------------------------------------- CSV and Parquet
274
275
276def _header(name: str, unit: str) -> str:
277 return f"{name} [{unit}]"
278
279
280def to_csv(result: Mapping[str, Any]) -> str:
281 """Every per-step signal as CSV: a header row of ``name [unit]``, then one row per twin step.
282 Booleans are 1/0, undefined cells empty, floats ``repr`` (exact)."""
283 cols, _ = columns(result)
284 buf = io.StringIO()
285 w = csv.writer(buf, lineterminator="\n")
286 w.writerow([_header(name, unit) for name, unit, _ in cols])
287 n = len(cols[0][2]) if cols else 0
288 for i in range(n):
289 w.writerow(["" if c[2][i] is None else repr(c[2][i]) if isinstance(c[2][i], float) else str(c[2][i])
290 for c in cols])
291 return buf.getvalue()
292
293
294HEADER_RE = re.compile(r"^(?P<name>.*) \‍[(?P<unit>[^\‍]]*)\‍]$")
295
296
297def read_csv(text: str) -> Dict[str, Tuple[str, List[Optional[float]]]]:
298 """:func:`to_csv`'s output back as ``{name: (unit, values)}``: the round trip the tests use and a
299 reader for anyone scripting against an export."""
300 rows = list(csv.reader(io.StringIO(text)))
301 if not rows:
302 return {}
303 heads = [HEADER_RE.match(h) for h in rows[0]]
304 out: Dict[str, Tuple[str, List[Optional[float]]]] = {}
305 for j, m in enumerate(heads):
306 name, unit = (m.group("name"), m.group("unit")) if m else (rows[0][j], "")
307 out[name] = (unit, [float(r[j]) if r[j] != "" else None for r in rows[1:]])
308 return out
309
310
311def to_parquet(result: Mapping[str, Any]) -> bytes:
312 """The CSV's table as Parquet: one float64 column per signal (booleans as 0/1), named as the CSV
313 names it without the unit; each field's ``unit`` in its metadata, and the run's identity and the
314 skipped blocks in the file's."""
315 import pyarrow as pa
316 import pyarrow.parquet as pq
317
318 cols, skipped = columns(result)
319 fields, arrays = [], []
320 for name, unit, values in cols:
321 fields.append(pa.field(name, pa.float64(), nullable=True, metadata={"unit": unit}))
322 arrays.append(pa.array([None if v is None else float(v) for v in values], type=pa.float64()))
323 prov = result.get("provenance") or {}
324 meta = {
325 "layerx.export": "series table (engine/layerx/export.py)",
326 "config_sha256": str(prov.get("config_sha256") or ""),
327 "drawing_sha256": str((prov.get("drawing") or {}).get("sha256") or ""),
328 "columns_skipped": json.dumps(skipped),
329 }
330 table = pa.Table.from_arrays(arrays, schema=pa.schema(fields, metadata=meta))
331 sink = io.BytesIO()
332 pq.write_table(table, sink)
333 return sink.getvalue()
334
335
336# ---------------------------------------------------------------------- FEA bundle
337
338
339def _engine_history(result: Mapping[str, Any], key_delivered: str, key_twin: str) -> Tuple[List[float], List[Optional[float]], str]:
340 """``(t, values, source)`` over the firing steps: EngineDesign's delivered values when the run
341 has them (the eroding engine), else the twin's own chamber."""
342 series = result.get("series") or {}
343 t = series.get("t") or []
344 fire = [i for i, f in enumerate(series.get("firing") or []) if f]
345 dv = result.get("delivered")
346 if isinstance(dv, Mapping) and isinstance(dv.get(key_delivered), list) and len(dv[key_delivered]) == len(dv.get("t") or []):
347 return ([float(v) for v in dv["t"]], [_cell(v) for v in dv[key_delivered]],
348 "EngineDesign time-varying solve (erosion replay), interpolated between its replay points onto the "
349 "twin's firing steps")
350 col = (series.get("chamber") or {}).get(key_twin) or []
351 return ([float(t[i]) for i in fire], [_cell(col[i]) if i < len(col) else None for i in fire],
352 "the twin's engine card at the as-built throat (no erosion replay in this run)")
353
354
355def _peak(t: Sequence[float], v: Sequence[Optional[float]]) -> Optional[Dict[str, float]]:
356 pairs = [(x, tt) for tt, x in zip(t, v) if x is not None]
357 if not pairs:
358 return None
359 x, tt = max(pairs)
360 return {"value": x, "t": tt}
361
362
363def _csv(rows: Iterable[Sequence[Any]]) -> str:
364 buf = io.StringIO()
365 w = csv.writer(buf, lineterminator="\n")
366 for r in rows:
367 w.writerow(["" if v is None else repr(v) if isinstance(v, float) else v for v in r])
368 return buf.getvalue()
369
370
371def _heatflux_rows(side: Mapping[str, Any], qkey: str, tkey: Optional[str]) -> Optional[List[List[Any]]]:
372 x, t, q = side.get("x_mm"), side.get("t"), side.get(qkey)
373 if not (isinstance(x, list) and isinstance(t, list) and isinstance(q, list) and len(q) == len(t)):
374 return None
375 temp = side.get(tkey) if tkey else None
376 rows: List[List[Any]] = []
377 for k, tt in enumerate(t):
378 row_q = q[k] if isinstance(q[k], list) else []
379 row_T = temp[k] if isinstance(temp, list) and k < len(temp) and isinstance(temp[k], list) else []
380 for j, xx in enumerate(x):
381 rows.append([_cell(tt), _cell(xx), _cell(row_q[j]) if j < len(row_q) else None,
382 _cell(row_T[j]) if j < len(row_T) else None])
383 return rows
384
385
386def fea_bundle(result: Mapping[str, Any], sidecar: Optional[Mapping[str, Any]] = None) -> bytes:
387 """The loads a structural or thermal model needs, as a zip (bytes).
388
389 ``sidecar`` is the run's ``axial`` sidecar (``{x_mm, t, q_MW_m2, T_wall_K}``, rows t, columns x);
390 without it there is no ``heatflux_xt.csv`` and ``loads.json`` says so.
391 """
392 series = result.get("series") or {}
393 prov = result.get("provenance") or {}
394 t_pc, pc, pc_src = _engine_history(result, "pc_psia", "pc_psia")
395 t_f, thrust, f_src = _engine_history(result, "thrust_N", "thrust_N")
396 files: Dict[str, str] = {}
397 files["pc_t.csv"] = _csv([["t [s]", "pc_psia [psia]", "pc_Pa [Pa]"]]
398 + [[tt, p, p * PSI if p is not None else None] for tt, p in zip(t_pc, pc)])
399 files["thrust_t.csv"] = _csv([["t [s]", "thrust_N [N]"]] + [[tt, f] for tt, f in zip(t_f, thrust)])
400
401 loads: Dict[str, Any] = {
402 "units": {"t": "s from Fire", "pressure": "psia (absolute) and Pa", "thrust": "N", "heat_flux": "MW/m^2",
403 "temperature": "K", "x": "mm along the chamber axis from the injector face"},
404 "chamber_pressure": {"peak": _peak(t_pc, pc), "source": pc_src,
405 "basis": "injector-end chamber pressure, the highest static pressure the chamber wall sees"},
406 "thrust": {"peak": _peak(t_f, thrust), "source": f_src},
407 "run": {"config_sha256": prov.get("config_sha256"), "drawing": (prov.get("drawing") or {}).get("name"),
408 "drawing_sha256": (prov.get("drawing") or {}).get("sha256"), "converged": result.get("converged"),
409 "test_mode": result.get("test_mode")},
410 }
411 if loads["chamber_pressure"]["peak"]:
412 loads["chamber_pressure"]["peak"]["value_Pa"] = loads["chamber_pressure"]["peak"]["value"] * PSI
413 tanks = {}
414 t_all = [float(v) for v in (series.get("t") or [])]
415 for side in ("ox", "fuel"):
416 col = [_cell(v) for v in ((series.get(side) or {}).get("tank_psia") or [])]
417 pk = _peak(t_all, col)
418 if pk:
419 tanks[side] = {**pk, "unit": "psia", "basis": "ullage pressure over the recorded lead-in and the burn"}
420 if tanks:
421 loads["tank_pressure"] = tanks
422 bottle = _peak(t_all, [_cell(v) for v in (series.get("copv_psia") or [])])
423 if bottle:
424 loads["bottle_pressure"] = {**bottle, "unit": "psia"}
425 wh = ((result.get("diagnostics") or {}).get("water_hammer") or [])
426 if isinstance(wh, list) and wh:
427 loads["water_hammer"] = [{k: w.get(k) for k in ("line", "side", "closure_s", "peak_psia", "rating_psia")}
428 for w in wh if isinstance(w, Mapping)]
429
430 heat_rows = _heatflux_rows(sidecar, "q_MW_m2", "T_wall_K") if isinstance(sidecar, Mapping) else None
431 if heat_rows is not None:
432 files["heatflux_xt.csv"] = _csv([["t [s]", "x_mm [mm]", "q_MW_m2 [MW/m^2]", "T_wall_K [K]"]] + heat_rows)
433 live = [r for r in heat_rows if r[2] is not None]
434 if live:
435 top = max(live, key=lambda r: r[2])
436 loads["heat_flux"] = {"peak": {"value": top[2], "t": top[0], "x_mm": top[1]},
437 "basis": str(sidecar.get("basis") or "the run's axial sidecar")}
438 hot = [r for r in heat_rows if r[3] is not None]
439 if hot:
440 top = max(hot, key=lambda r: r[3])
441 loads["wall_temperature"] = {"peak": {"value": top[3], "t": top[0], "x_mm": top[1]}}
442 prof = sidecar.get("profile")
443 if isinstance(prof, Mapping):
444 prof_rows = _heatflux_rows({**prof, "t": sidecar.get("t")}, "q_MW_m2", None)
445 if prof_rows is not None:
446 files["heatflux_profile_xt.csv"] = _csv([["t [s]", "x_mm [mm]", "q_MW_m2 [MW/m^2]", "T_wall_K [K]"]] + prof_rows)
447 else:
448 loads["heat_flux"] = {"available": False, "error": "no axial sidecar for this run"}
449
450 files["loads.json"] = json.dumps(loads, indent=2, sort_keys=True, default=lambda o: None)
451 files["README.txt"] = _readme(files, pc_src, f_src, sidecar)
452 sink = io.BytesIO()
453 with zipfile.ZipFile(sink, "w", compression=zipfile.ZIP_DEFLATED) as z:
454 for name in sorted(files):
455 info = zipfile.ZipInfo(name, date_time=(2026, 1, 1, 0, 0, 0)) # fixed: the same run zips identically
456 info.compress_type = zipfile.ZIP_DEFLATED
457 z.writestr(info, files[name])
458 return sink.getvalue()
459
460
461def _readme(files: Mapping[str, str], pc_src: str, f_src: str, sidecar: Optional[Mapping[str, Any]]) -> str:
462 lines = [
463 "Layer X FEA load bundle",
464 "",
465 "Time is seconds from the Fire command. Pressures are absolute (psia, with Pa alongside where an FEA",
466 "deck wants SI); 1 psi = 6894.757293168361 Pa. Thrust in N. Heat flux in MW/m^2, temperatures in K,",
467 "axial position x in mm from the injector face along the chamber axis.",
468 "",
469 "pc_t.csv chamber pressure at the injector end, on the twin's firing steps. Source: " + pc_src + ".",
470 " The injector-end pressure is ~0.4 % above the nozzle stagnation pressure (Rayleigh loss);",
471 " it is the highest static pressure the chamber wall sees.",
472 "thrust_t.csv thrust on the same steps. Source: " + f_src + ".",
473 " Values between replay points are linear interpolations of EngineDesign solves spaced",
474 " 0.10-0.15 s apart: they do not resolve the start or shutdown transient, which is not modelled.",
475 ]
476 if "heatflux_xt.csv" in files:
477 lines += [
478 "heatflux_xt.csv long format, one row per (t, x): net gas-side heat flux at each wall station and",
479 " that station's surface temperature, at the erosion replay's points. Basis: "
480 + str((sidecar or {}).get("basis") or "the run's axial sidecar") + ".",
481 ]
482 if "heatflux_profile_xt.csv" in files:
483 lines += [
484 "heatflux_profile_xt.csv the chamber solve's whole-contour flux at one quasi-steady liner surface",
485 " temperature: where the gas loads the wall, not the transient wall (no T_wall column).",
486 ]
487 lines += [
488 "loads.json peak loads (value and time) with their basis: chamber pressure, thrust, tank and bottle",
489 " pressures, heat flux and wall temperature when the sidecar is present, water hammer when",
490 " diagnosed, and the run's config and drawing hashes.",
491 "",
492 "These are model outputs. Every input behind them carries its provenance in the run record; read it",
493 "before using a number for a margin.",
494 ]
495 return "\n".join(lines) + "\n"
#define max(a, b)
Definition Arduino.h:43
List[Any] _place(int n, Sequence[int] rows, Sequence[Any] values)
Definition export.py:97
bytes to_parquet(Mapping[str, Any] result)
Definition export.py:311
None _walk(str prefix, Any block, int n, List[Tuple[str, List[Any], Optional[str]]] out, List[str] skipped, int depth=0)
Definition export.py:78
Optional[List[int]] _rows_of(Sequence[Any] times, Sequence[float] t, float tol=1e-6)
Definition export.py:105
str to_csv(Mapping[str, Any] result)
Definition export.py:280
Optional[Dict[str, float]] _peak(Sequence[float] t, Sequence[Optional[float]] v)
Definition export.py:355
Tuple[List[Tuple[str, str, List[Any]]], List[str]] columns(Mapping[str, Any] result)
Definition export.py:120
str unit_of(str name)
Definition export.py:51
bytes fea_bundle(Mapping[str, Any] result, Optional[Mapping[str, Any]] sidecar=None)
Definition export.py:386
bool _is_column(Any v, int n)
Definition export.py:73
Optional[List[List[Any]]] _heatflux_rows(Mapping[str, Any] side, str qkey, Optional[str] tkey)
Definition export.py:371
str _csv(Iterable[Sequence[Any]] rows)
Definition export.py:363
Dict[str, Tuple[str, List[Optional[float]]]] read_csv(str text)
Definition export.py:297
str _header(str name, str unit)
Definition export.py:276
Any _cell(Any v)
Definition export.py:64
Tuple[List[float], List[Optional[float]], str] _engine_history(Mapping[str, Any] result, str key_delivered, str key_twin)
Definition export.py:339
None _diagnostic(str prefix, Any block, Sequence[float] t, Any add, List[str] skipped, Optional[List[int]] rows=None, int depth=0)
Definition export.py:226
str _readme(Mapping[str, str] files, str pc_src, str f_src, Optional[Mapping[str, Any]] sidecar)
Definition export.py:461