STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
lc_tare.py
Go to the documentation of this file.
1"""Load-cell tares recorded during a run, and the tared series derived from them.
2
3Elodin records ABSOLUTE force, always and forever — `LC<n>_Cal.CH<c>.force_kg` means the same
4thing in every run ever archived, and this feature did not change that. A tare is display state:
5the backend subtracts it on the way to the browser and appends what it did to
6`<run_dir>/lc_tare.jsonl`, so what the operator was looking at stays reconstructible afterwards.
7
8This module replays that record. It exposes the reconstruction as a synthetic component,
9`…force_kg_tared`, rather than as a toggle on the real one. A toggle would be a query parameter
10threaded through three independent read paths (series_json, long_csv_rows, wide_csv_rows), and
11missing one means the exported CSV disagrees with the plot the operator was looking at. As a
12component name there is a single choke point in load_series, and series, both CSV shapes and the
13download all follow for free.
14
15The sidecar is append-only, one JSON object per line:
16
17 {"entity":"LC2_Cal.CH1","uid":4201,"event":"set","offsetKg":20.13,
18 "adcAtTare":8412331,"setAtMs":...,"appliedAtMs":1757800123456}
19
20`appliedAtMs` is the instant the published stream changed, not when a button was pressed — the
21backend writes the line from the same code that changes the subtraction. `event` is "set" (a new
22tare), "recal" (a re-fit moved the offset with no operator action) or "clear". A recal line is
23load-bearing: ignore it and every reconstruction is wrong from the re-fit onward.
24
25There is no terminal line at session stop, so a tare with no following "clear" was in effect to
26the end of the run.
27"""
28
29from __future__ import annotations
30
31import json
32from pathlib import Path
33
34import numpy as np
35
36from . import config
37
38#: The component this module synthesises, and the real one it derives from.
39TARED_SUFFIX = ".force_kg_tared"
40GROSS_FIELD = "force_kg"
41
42
43def sidecar_path(run_id: str) -> Path:
44 """Inside the run dir, beside the DB — not a sibling like <run_id>.toml.
45
46 That file is a sibling only because it is written before elodin-db creates the directory.
47 This one is written mid-run when the directory certainly exists, and living inside it means
48 the run's own deletion takes it too, with no orphan class to clean up.
49 """
50 return config.ELODIN_DIR / run_id / "lc_tare.jsonl"
51
52
53def load(run_id: str) -> dict[str, list[tuple[float, float]]]:
54 """entity -> [(applied_at_seconds, offset_kg), ...] ascending.
55
56 Missing or malformed → {}. A viewer must never fail to open a run over its metadata, and a
57 partially-written last line is expected: the file is appended to while the run is live.
58 """
59 p = sidecar_path(run_id)
60 try:
61 text = p.read_text()
62 except OSError:
63 return {}
64
65 events: dict[str, list[tuple[float, float]]] = {}
66 for line in text.splitlines():
67 line = line.strip()
68 if not line:
69 continue
70 try:
71 rec = json.loads(line)
72 except json.JSONDecodeError:
73 # A torn final line is normal mid-run; earlier lines stay usable.
74 continue
75 entity = rec.get("entity")
76 applied = rec.get("appliedAtMs")
77 if not isinstance(entity, str) or not isinstance(applied, (int, float)):
78 continue
79 offset = 0.0 if rec.get("event") == "clear" else rec.get("offsetKg")
80 if not isinstance(offset, (int, float)) or not np.isfinite(float(offset)):
81 continue
82 events.setdefault(entity, []).append((float(applied) / 1000.0, float(offset)))
83
84 for ev in events.values():
85 ev.sort(key=lambda e: e[0])
86 return events
87
88
89def tared_components(run_id: str, components: list[dict]) -> list[dict]:
90 """The synthetic component entries to add to an index, one per tared LC channel.
91
92 Only channels the sidecar actually names get one. Synthesising a tared twin for every load
93 cell would make an untared run indistinguishable from a tared one whose offsets happened to
94 be zero — the toggle would be lying about whether a tare existed.
95 """
96 events = load(run_id)
97 if not events:
98 return []
99 out = []
100 for comp in components:
101 if comp.get("field") != GROSS_FIELD:
102 continue
103 entity = comp.get("entity", "")
104 if entity not in events:
105 continue
106 d = dict(comp)
107 d["name"] = f"{entity}{TARED_SUFFIX}"
108 d["field"] = "force_kg_tared"
109 out.append(d)
110 return out
111
112
113def apply(run_id: str, entity: str, t: np.ndarray, v: np.ndarray) -> np.ndarray:
114 """Subtract the offset that was in effect at each sample time.
115
116 A step function, not one offset across the whole array: a tare set partway through a run must
117 leave everything before it absolute, or the pre-tare portion of every run reads wrong.
118 """
119 events = load(run_id).get(entity)
120 if not events or len(t) == 0:
121 return v
122 times = np.array([e[0] for e in events], dtype=float)
123 offsets = np.array([e[1] for e in events], dtype=float)
124 # side="right": a sample exactly at the applied instant already carried the new offset.
125 idx = np.searchsorted(times, t, side="right") - 1
126 applied = np.where(idx >= 0, offsets[np.clip(idx, 0, len(offsets) - 1)], 0.0)
127 return v - applied
dict[str, list[tuple[float, float]]] load(str run_id)
Definition lc_tare.py:53
np.ndarray apply(str run_id, str entity, np.ndarray t, np.ndarray v)
Definition lc_tare.py:113
list[dict] tared_components(str run_id, list[dict] components)
Definition lc_tare.py:89
Path sidecar_path(str run_id)
Definition lc_tare.py:43