STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
species.py
Go to the documentation of this file.
1"""Which fluids exist, declared as data.
2
3The species table lives in ``species.toml``, not in Python. Adding a propellant
4is an edit to a data file; it is never a new branch in a function. That is the
5difference between a simulator that supports four fluids and one that supports
6whatever you point it at, and it is cheap only if it is done on day one --
7retrofitting a fluid registry after fifty ``if fluid == "lox"`` sites have
8accumulated is the expensive version of this decision.
9
10Anything the equation of state already knows -- molar mass, critical point,
11triple point -- is deliberately *not* declared here. Duplicating it would create
12a second source of truth that silently disagrees with the first.
13
14Runtime registration is supported too, so a project can add a species without
15editing this package::
16
17 from feedtwin.props import register_species, SpeciesSpec
18
19 register_species(SpeciesSpec(name="nitrous", backend_fluid="NitrousOxide"))
20"""
21
22from __future__ import annotations
23
24import tomllib
25from dataclasses import dataclass, field
26from pathlib import Path
27from typing import Any
28
29from feedtwin.props.errors import UnknownFluid
30
31#: Backends tried in order when a species does not name its own chain.
32#:
33#: Tabulated bicubic first because it is 10-30x faster than evaluating the
34#: Helmholtz energy directly; the equation of state behind it because the tables
35#: cover a bounded region and refuse everything outside it. Ordinary states are
36#: fast, unusual states are correct, and no caller has to know which happened.
37DEFAULT_CHAIN: tuple[str, ...] = ("bicubic", "heos")
38
39_SPECIES_FILE = Path(__file__).with_name("species.toml")
40
41
42@dataclass(frozen=True, slots=True)
44 """One declared fluid.
45
46 Attributes:
47 name: Canonical lowercase identifier, e.g. ``"nitrogen"``.
48 backend_fluid: What the equation-of-state library calls it, e.g.
49 ``"Nitrogen"``. Separate from ``name`` because the library's
50 spelling is its business, not ours.
51 aliases: Other names that resolve here -- ``"lox"`` for oxygen, ``"gn2"``
52 for nitrogen. Matched case-insensitively.
53 roles: What the fluid is used as: ``oxidizer``, ``fuel``, ``pressurant``.
54 Advisory metadata for UIs and validation, not physics.
55 chain: Backends to try in order. Defaults to :data:`DEFAULT_CHAIN`.
56 options: Free-form settings passed through to backends, so a backend can
57 carry per-species configuration without this class growing a field
58 for every backend that ever exists.
59 """
60
61 name: str
62 backend_fluid: str
63 aliases: tuple[str, ...] = ()
64 roles: tuple[str, ...] = ()
65 chain: tuple[str, ...] = DEFAULT_CHAIN
66 options: dict[str, Any] = field(default_factory=dict)
67
68
69_SPECIES: dict[str, SpeciesSpec] = {}
70_ALIASES: dict[str, str] = {}
71
72#: Whether the shipped table has been read. An explicit flag, not "is _SPECIES
73#: empty" -- that test looks equivalent and is not. A program whose first call
74#: is register_species() would make the registry non-empty, the shipped table
75#: would then look already-loaded, and every fluid in species.toml would be
76#: unknown for the rest of the process. Nothing would raise until something
77#: asked for oxygen.
78_LOADED = False
79
80
81def register_species(spec: SpeciesSpec) -> SpeciesSpec:
82 """Add or replace a species. Aliases are indexed case-insensitively.
83
84 Registrations layer *on top of* the shipped table: it is loaded first if it
85 has not been already, so adding a species never removes one.
86 """
88 return _register(spec)
89
90
91def _register(spec: SpeciesSpec) -> SpeciesSpec:
92 """Register without loading the shipped table -- used by the loader itself."""
93 _SPECIES[spec.name] = spec
94 _ALIASES[spec.name.lower()] = spec.name
95 for alias in spec.aliases:
96 _ALIASES[alias.lower()] = spec.name
97 return spec
98
99
100def get_species(name: str) -> SpeciesSpec:
101 """Resolve a name or alias to its species. Case-insensitive."""
103 key = _ALIASES.get(name.strip().lower())
104 if key is None:
105 raise UnknownFluid(name, list(_SPECIES))
106 return _SPECIES[key]
107
108
109def registered_species() -> list[str]:
110 """Canonical names of every declared species, sorted."""
112 return sorted(_SPECIES)
113
114
115def _ensure_loaded() -> None:
116 global _LOADED
117 if _LOADED:
118 return
119 # Set before loading, not after: load_species_file registers as it goes,
120 # and a flag set afterwards would let a re-entrant call start the load a
121 # second time.
122 _LOADED = True
123 load_species_file(_SPECIES_FILE)
124
125
126def load_species_file(path: Path) -> list[SpeciesSpec]:
127 """Load a species table from a TOML file and register everything in it.
128
129 Public so a project can layer its own table on top of the shipped one --
130 later definitions replace earlier ones by name.
131 """
132 with path.open("rb") as handle:
133 raw = tomllib.load(handle)
134
135 loaded: list[SpeciesSpec] = []
136 for name, body in raw.items():
137 if not isinstance(body, dict):
138 raise ValueError(
139 f"{path}: [{name}] must be a table, got {type(body).__name__}"
140 )
141 loaded.append(_register(_spec_from_toml(name, body)))
142 return loaded
143
144
145def _spec_from_toml(name: str, body: dict[str, Any]) -> SpeciesSpec:
146 backend_fluid = body.get("backend_fluid")
147 if not isinstance(backend_fluid, str):
148 raise ValueError(f"species [{name}] needs a string 'backend_fluid'")
149
150 chain = body.get("chain", list(DEFAULT_CHAIN))
151 options = body.get("options", {})
152 if not isinstance(options, dict):
153 raise ValueError(f"species [{name}]: 'options' must be a table")
154
155 return SpeciesSpec(
156 name=name.lower(),
157 backend_fluid=backend_fluid,
158 aliases=_str_tuple(name, "aliases", body.get("aliases", [])),
159 roles=_str_tuple(name, "roles", body.get("roles", [])),
160 chain=_str_tuple(name, "chain", chain),
161 options=options,
162 )
163
164
165def _str_tuple(species: str, key: str, value: Any) -> tuple[str, ...]:
166 if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
167 raise ValueError(f"species [{species}]: '{key}' must be a list of strings")
168 return tuple(str(v) for v in value)
list[str] registered_species()
Definition species.py:109
SpeciesSpec _spec_from_toml(str name, dict[str, Any] body)
Definition species.py:145
tuple[str,...] _str_tuple(str species, str key, Any value)
Definition species.py:165
SpeciesSpec _register(SpeciesSpec spec)
Definition species.py:91
SpeciesSpec get_species(str name)
Definition species.py:100
list[SpeciesSpec] load_species_file(Path path)
Definition species.py:126
SpeciesSpec register_species(SpeciesSpec spec)
Definition species.py:81