STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
watch.py
Go to the documentation of this file.
1"""The watcher: laws a correct stand cannot break, checked every tick.
2
3The solver tab says whether the arithmetic closed. This says whether the answer
4it closed on is one the world allows -- conservation and equilibrium, not the
5sim checked against itself (docs/PHYSICS-BENCHMARK.md). Each check is a thing a
6bug has done here:
7
8* **Gas made in a sealed tank.** A tank with no open path to the air gained
9 gas from its ullage floor (``TankSim.floored_kg``). The ullage floor did it as a sealed fuel tank
10 drained (2026-10-11).
11* **A line that never moved.** A converged solve left a branch at the solve's
12 starting flow (:data:`feedtwin.solve.steady.INITIAL_FLOW`) to the gram:
13 Newton took no step there. A 0.45 psi drain beside a 6,000 psi bottle did.
14* **Pressure held against an open path.** A vessel more than a psi off
15 atmosphere, with a path to the air that every valve on the drawing leaves
16 open, and nothing flowing for two seconds.
17* **Liquid from nowhere.** The network delivering liquid to a tank's outlet
18 that no tank feeding it sent (``TankSim.refused_kg``): the chamber pushing
19 back, or air drawn in through an open main, which one fluid per line cannot
20 carry.
21* **Not converging**, three ticks running; **at the edge of tolerance**, five
22 seconds running above half of it.
23* **Mass not accounted for**: what the vessels hold against what crossed the
24 boundary, less what the clamps booked.
25
26A flag opens when its condition holds (for as long as the check asks) and
27closes when it stops; one flag per episode, not one per tick. Read-only:
28nothing here changes a step, and a check that raises is reported as a flag,
29never as a stopped stand.
30"""
31
32from __future__ import annotations
33
34from collections import deque
35from dataclasses import dataclass
36from typing import TYPE_CHECKING, Deque, Iterator
37
38from feedtwin.session.diagnostics import SolverRecord
39from feedtwin.session.gauge import ATMOSPHERE, PSI, say
40from feedtwin.solve.steady import INITIAL_FLOW
41
42if TYPE_CHECKING: # pragma: no cover
43 from feedtwin.session.core import Session
44
45#: What a sealed tank's floor may add in a tick and still be round-off [kg].
46SEALED_GAIN_KG = 1.0e-9
47#: Off atmosphere by more than this, with an open path, is held pressure [Pa].
48HELD_DP = 1.0 * PSI
49#: ...and nothing flowing means less than this at the vessel's node [kg/s].
50STILL_FLOW = 1.0e-5
51#: ...for this long [s] (a valve slews; a line takes a moment to start).
52HELD_FOR_S = 2.0
53#: A fixed node within this of atmosphere is the air [Pa].
54AIR_BAND = 0.2 * PSI
55#: Liquid arriving at an outlet must be at least this to count [kg/s].
56LIQUID_IN_MIN = 1.0e-4
57#: Unconverged ticks in a row before it is a flag.
58UNCONVERGED_TICKS = 3
59#: Residual above this share of the tolerance, for :data:`EDGE_FOR_S`.
60EDGE_SHARE = 0.5
61EDGE_FOR_S = 5.0
62#: Mass nobody booked: absolute [kg], plus a share of what moved.
63UNEXPLAINED_KG = 1.0e-6
64UNEXPLAINED_SHARE = 1.0e-6
65#: A flag closes once its condition has been gone this long [s], so a check
66#: that holds every other tick is one episode, not twenty.
67CLOSE_AFTER_S = 1.0
68#: Flags kept per session, newest last.
69FLAGS_KEPT = 200
70
71#: What each check is called on the page.
72TITLES = {
73 "sealed_gain": "Gas made in a sealed tank",
74 "stuck_guess": "A line never left the solver's starting guess",
75 "held_open": "Pressure held against an open path",
76 "liquid_from_nowhere": "Liquid from nowhere",
77 "unconverged": "Not converging",
78 "edge": "At the edge of the solver's tolerance",
79 "unexplained": "Mass not accounted for",
80 "watcher": "The watcher itself failed",
81}
82
83
84@dataclass
85class Flag:
86 """One episode of one check on one thing."""
87
88 check: str
89 subject: str
90 started: float
91 last: float
92 detail: str
93 open: bool = True
94
95 @property
96 def title(self) -> str:
97 return TITLES.get(self.check, self.check)
98
99 def to_dict(self) -> dict[str, object]:
100 return {
101 "check": self.check,
102 "title": self.title,
103 "subject": self.subject,
104 "started": self.started,
105 "last": self.last,
106 "detail": self.detail,
107 "open": self.open,
108 }
109
110
111@dataclass(frozen=True)
112class _Seen:
113 check: str
114 subject: str
115 detail: str
116 hold: float = 0.0
117 """How long the condition must hold before it is a flag [s]."""
118
119
121 """Checks a session's tick; keeps its flags."""
122
123 def __init__(self) -> None:
124 self.flags: Deque[Flag] = deque(maxlen=FLAGS_KEPT)
125 self._open: dict[tuple[str, str], Flag] = {}
126 self._since: dict[tuple[str, str], float] = {}
127 self._floored: dict[str, float] = {}
128 self._refused: dict[str, float] = {}
129 self._t: float | None = None
131 self._edge_since: float | None = None
132
133 @property
134 def open_flags(self) -> list[Flag]:
135 return [f for f in self.flags if f.open]
136
137 def observe(self, session: "Session", record: SolverRecord) -> None:
138 try:
139 seen = list(self._checks(session, record))
140 except Exception as exc: # noqa: BLE001 - a watcher never stops a stand
141 seen = [_Seen("watcher", "watcher", f"{type(exc).__name__}: {exc}")]
142 self._update(record.t, seen)
143
144 # ------------------------------------------------------------------ book
145
146 def _update(self, t: float, seen: list[_Seen]) -> None:
147 now = {(s.check, s.subject): s for s in seen}
148 for key in list(self._since):
149 if key not in now:
150 del self._since[key]
151 for key, flag in list(self._open.items()):
152 if key not in now and t - flag.last >= CLOSE_AFTER_S:
153 flag.open = False
154 del self._open[key]
155 for key, s in now.items():
156 since = self._since.setdefault(key, t)
157 current = self._open.get(key)
158 if current is not None:
159 current.last, current.detail = t, s.detail
160 continue
161 if t - since + 1e-9 < s.hold:
162 continue
163 flag = Flag(s.check, s.subject, since, t, s.detail)
164 self._open[key] = flag
165 self.flags.append(flag)
166
167 # ---------------------------------------------------------------- checks
168
169 def _checks(self, session: "Session", record: SolverRecord) -> Iterator[_Seen]:
170 net = session.model.built.network
171 labels = {n.id: n.label or n.id for n in session.model.diagram.nodes}
172
173 def name(item: str) -> str:
174 return labels.get(item) or labels.get(item.split(".")[0]) or item
175
176 flows = session._last_flows
177 isolated = session._last_isolated
178
179 # Gas made in a sealed tank.
180 for sim in session.tanks.values():
181 before = self._floored.get(sim.id, sim.floored_kg)
182 self._floored[sim.id] = sim.floored_kg
183 made = sim.floored_kg - before
184 if sim.sealed and made > SEALED_GAIN_KG:
185 yield _Seen(
186 "sealed_gain",
187 sim.label,
188 f"the ullage floor put in {made * 1e3:.3g} g of gas this tick "
189 "with no open path to the air",
190 )
191
192 # A line that never moved.
193 if record.converged:
194 for branch_id, flow in flows.items():
195 if branch_id in isolated:
196 continue
197 if abs(flow - INITIAL_FLOW) < 1e-15:
198 yield _Seen(
199 "stuck_guess",
200 name(branch_id),
201 "converged with this branch at the solve's 1 g/s "
202 "starting flow: Newton took no step on it",
203 )
204
205 # Pressure held against an open path.
206 vessels = {s.ullage_node for s in session.tanks.values()}
207 vessels |= {s.outlet_node for s in session.tanks.values()}
208 vessels |= {b.node for b in session.bottles.values()}
209 dry = session._dry_branches()
210 shut_by_valve = frozenset(isolated - dry)
211 for sim in session.tanks.values():
212 for node, side in ((sim.ullage_node, "gas"), (sim.outlet_node, "liquid")):
213 pressure = net.nodes[node].pressure
214 if pressure is None or abs(pressure - ATMOSPHERE) <= HELD_DP:
215 continue
216 if side == "liquid" and sim.state.liquid_mass <= 1e-3:
217 continue
218 drawn = _path_to_air(net, node, shut_by_valve, vessels)
219 if drawn is None:
220 continue
221 gas_in, gas_out = session._split_at(node, flows)
222 if gas_in + gas_out > STILL_FLOW:
223 continue
224 solved = _path_to_air(net, node, isolated, vessels)
225 cut = [b for b in drawn if b in isolated]
226 detail = (
227 f"{say(pressure, 1)} ({(pressure - ATMOSPHERE) / PSI:+.1f} psi "
228 f"off atmosphere) at the {side} side, "
229 f"open to the air through {', '.join(name(b) for b in drawn[:6])}"
230 )
231 if solved is None and cut:
232 detail += f"; the solve shut {', '.join(name(b) for b in cut)}"
233 else:
234 detail += "; nothing flows"
235 yield _Seen("held_open", f"{sim.label} ({side})", detail, HELD_FOR_S)
236
237 # Liquid from nowhere: what the network delivered to a tank's outlet
238 # that no tank feeding it sent, which the tank refused.
239 for sim in session.tanks.values():
240 before = self._refused.get(sim.id, sim.refused_kg)
241 self._refused[sim.id] = sim.refused_kg
242 dt = record.t - self._t if self._t is not None else 0.0
243 rate = (sim.refused_kg - before) / dt if dt > 0.0 else 0.0
244 if rate > LIQUID_IN_MIN:
245 yield _Seen(
246 "liquid_from_nowhere",
247 sim.label,
248 f"the network sent {rate * 1e3:.3g} g/s into its outlet that no "
249 "tank gave; refused, and booked as a clamp",
250 )
251 self._t = record.t
252
253 # Not converging; at the edge.
254 self._unconverged = 0 if record.converged else self._unconverged + 1
255 if self._unconverged >= UNCONVERGED_TICKS:
256 yield _Seen(
257 "unconverged",
258 "network",
259 f"{self._unconverged} ticks in a row; residual {record.residual:.2e}",
260 )
261 tol = float(getattr(session.setup, "network_tolerance", 0.0) or 0.0)
262 if tol > 0.0 and record.residual > EDGE_SHARE * tol:
263 if self._edge_since is None:
264 self._edge_since = record.t
265 else:
266 self._edge_since = None
267 if self._edge_since is not None and record.t - self._edge_since >= EDGE_FOR_S:
268 yield _Seen(
269 "edge",
270 "network",
271 f"residual above {EDGE_SHARE:.0%} of the tolerance ({tol:g}) since "
272 f"{self._edge_since:.1f} s; now {record.residual:.2e}",
273 )
274
275 # Mass not accounted for.
276 unexplained = record.mass_error_kg - record.guard_kg
277 scale = max(record.throughput_kg, record.inventory_kg)
278 if abs(unexplained) > UNEXPLAINED_KG + UNEXPLAINED_SHARE * scale:
279 yield _Seen(
280 "unexplained",
281 "stand",
282 f"{unexplained * 1e3:+.3g} g that no vessel booked and no boundary "
283 "carried",
284 )
285
286
288 net: object, start: str, shut: frozenset[str], vessels: set[str]
289) -> list[str] | None:
290 """The branches of a path from ``start`` to the air (a fixed node at
291 atmosphere that is not a vessel), avoiding ``shut`` -- or None."""
292 links: dict[str, list[tuple[str, str]]] = {}
293 for branch_id, branch in net.branches.items(): # type: ignore[attr-defined]
294 if branch_id in shut:
295 continue
296 links.setdefault(branch.upstream, []).append((branch.downstream, branch_id))
297 links.setdefault(branch.downstream, []).append((branch.upstream, branch_id))
298 came: dict[str, tuple[str, str]] = {}
299 frontier = [start]
300 seen = {start}
301 while frontier:
302 here = frontier.pop(0)
303 for there, branch_id in links.get(here, ()):
304 if there in seen or there in vessels:
305 continue
306 seen.add(there)
307 came[there] = (here, branch_id)
308 node = net.nodes[there] # type: ignore[attr-defined]
309 if node.pressure is not None:
310 if abs(node.pressure - ATMOSPHERE) <= AIR_BAND:
311 path = []
312 at = there
313 while at != start:
314 at, branch = came[at]
315 path.append(branch)
316 return path[::-1]
317 continue
318 frontier.append(there)
319 return None
#define max(a, b)
Definition Arduino.h:43
dict[str, object] to_dict(self)
Definition watch.py:99
list[Flag] open_flags(self)
Definition watch.py:134
None observe(self, "Session" session, SolverRecord record)
Definition watch.py:137
None _update(self, float t, list[_Seen] seen)
Definition watch.py:146
Iterator[_Seen] _checks(self, "Session" session, SolverRecord record)
Definition watch.py:169
TOML_ALWAYS_INLINE path & append(const path &p)
Definition toml.hpp:3310
list[str]|None _path_to_air(object net, str start, frozenset[str] shut, set[str] vessels)
Definition watch.py:289