统一力值阈值处理并保留原始采样
移除自动显示基线,使逐轴阈值统一作用于曲线、读数与记录输出,标定拟合继续使用原始采样。 共享层全量测试和相关 Studio 测试通过。
This commit is contained in:
@@ -24,7 +24,7 @@ from orisys_app_shared.force.history import MetricHistory, PlotState, lowpass_fi
|
||||
from orisys_app_shared.force.presentation import (
|
||||
DisplayForce,
|
||||
ForcePresentation,
|
||||
ZeroEstimate,
|
||||
threshold_force,
|
||||
)
|
||||
from orisys_app_shared.force.session import (
|
||||
NA,
|
||||
@@ -55,7 +55,7 @@ __all__ = [
|
||||
"SessionSample",
|
||||
"SessionSlice",
|
||||
"SessionStore",
|
||||
"ZeroEstimate",
|
||||
"threshold_force",
|
||||
"build_force_calibration_v2",
|
||||
"describe_state",
|
||||
"effective_filters",
|
||||
|
||||
@@ -1,473 +1,76 @@
|
||||
"""Display-only force presentation: baseline, deadband, and formatting.
|
||||
|
||||
Never mutates recorded samples. The session store, ``forces.csv`` and the metric
|
||||
history keep the measurement; the value cards *and the curves* are drawn from
|
||||
this layer, which removes the idle bias and holds a resting channel at exactly
|
||||
zero.
|
||||
|
||||
The estimate is kept **per display unit**. A raw count and a newton reading of
|
||||
the same axis are different scales, so one deadband in the wrong unit would
|
||||
either hide a real contact or leave the noise outside it. Values arrive here
|
||||
already expressed in the unit the operator is looking at (``force_plots.push``
|
||||
passes the live triplet in its own unit), and the unit is part of the key.
|
||||
|
||||
The estimate is published in two stages, because waiting for the whole startup
|
||||
window leaves the operator staring at the device's raw bias:
|
||||
|
||||
* **provisional** — as soon as a few quiet samples exist (see
|
||||
:data:`DEFAULT_PROVISIONAL_SAMPLES`) an estimate is fitted from them and
|
||||
*applied*; it is re-fitted on every further quiet sample. The cards and the
|
||||
curves therefore read zero from the first tenths of a second of a session
|
||||
instead of after ``baseline_seconds``.
|
||||
* **frozen** — when the quiet window closes with quality ``ok`` the estimate is
|
||||
frozen exactly as before and stops chasing the data.
|
||||
|
||||
A window that closes *without* a trustworthy estimate (contaminated, unstable,
|
||||
insufficient samples) keeps the provisional estimate applied rather than
|
||||
reverting to the raw bias: `quality` on :class:`ZeroEstimate` and
|
||||
:attr:`ForcePresentation.display_quality` say where the numbers came from. A
|
||||
window in which *no* quiet sample was ever seen has nothing to estimate from, so
|
||||
nothing is invented — the measurement is displayed unchanged, which is how a
|
||||
contact present at session start stays visible instead of being zeroed away.
|
||||
"""
|
||||
"""Deterministic per-axis force threshold and numeric presentation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
#: Floor under the estimated deadband, per display unit. 50 raw counts is a
|
||||
#: meaningful noise floor for the uncalibrated device; 50 N would be a contact,
|
||||
#: so newton mode defaults to no floor at all and relies on the estimated
|
||||
#: percentile (see :func:`ForcePresentation.fit_zero`).
|
||||
DEFAULT_MIN_DEADBAND_RAW = 50.0
|
||||
DEFAULT_MIN_DEADBAND_N = 0.0
|
||||
|
||||
#: How many quiet samples are enough to publish a *provisional* estimate.
|
||||
#: Three is the smallest window ``fit_zero`` can take a median and a residual
|
||||
#: percentile from, and at the ~30 Hz force rate it is about a tenth of a
|
||||
#: second: the operator sees the display policy working almost immediately
|
||||
#: rather than watching the device's raw bias for the whole startup window.
|
||||
#: Every further quiet sample re-fits the estimate, so three is a starting
|
||||
#: point, not a decision — the frozen window still has the final say.
|
||||
DEFAULT_PROVISIONAL_SAMPLES = 3
|
||||
|
||||
|
||||
def normalise_unit(unit: str | None) -> str:
|
||||
"""``"N"`` or ``"raw"`` — the two units a force reading can be shown in."""
|
||||
return "N" if str(unit or "").upper().startswith("N") else "raw"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ZeroEstimate:
|
||||
"""Outcome of an idle-window bias / noise estimate."""
|
||||
def threshold_force(value: float, threshold: float) -> float:
|
||||
"""Return a processed force without turning an unavailable axis into zero."""
|
||||
value = float(value)
|
||||
return 0.0 if np.isfinite(value) and abs(value) <= threshold else value
|
||||
|
||||
offsets: tuple[float, float, float]
|
||||
deadbands: tuple[float, float, float]
|
||||
sample_count: int
|
||||
quality: str # "ok" | "insufficient" | "unstable" | "contaminated"
|
||||
message: str = ""
|
||||
|
||||
def threshold_values(values, threshold: float) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Threshold finite array elements and return values plus the changed mask."""
|
||||
block = np.array(values, dtype=np.float64, copy=True)
|
||||
suppressed = np.isfinite(block) & (0 < threshold) & (np.abs(block) <= threshold)
|
||||
block[suppressed] = 0.0
|
||||
return block, suppressed
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DisplayForce:
|
||||
"""One displayed triplet after offset + deadband (measurement unchanged)."""
|
||||
|
||||
values: tuple[float, float, float]
|
||||
suppressed: tuple[bool, bool, bool]
|
||||
unit: str
|
||||
zero_zone: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class _UnitEstimate:
|
||||
"""What was learned about one display unit's idle state."""
|
||||
|
||||
collecting: bool = False
|
||||
samples: list[tuple[float, float, float]] = field(default_factory=list)
|
||||
sample_times: list[float] = field(default_factory=list)
|
||||
t0: float | None = None
|
||||
#: Frozen estimate: set once the quiet window closes with quality ``ok``.
|
||||
offsets: tuple[float, float, float] | None = None
|
||||
deadbands: tuple[float, float, float] | None = None
|
||||
estimate: ZeroEstimate | None = None
|
||||
#: Provisional estimate, published from the first few quiet samples and
|
||||
#: re-fitted on each further one. It is what the display uses while the
|
||||
#: window is still open, and what it keeps if the window closes badly.
|
||||
provisional_offsets: tuple[float, float, float] | None = None
|
||||
provisional_deadbands: tuple[float, float, float] | None = None
|
||||
provisional_samples: int = 0
|
||||
#: A quiet sample was refused because a contact was present or the flow was
|
||||
#: too high: this window is not a trustworthy idle window any more.
|
||||
contaminated: bool = False
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
"""A *frozen* estimate: the startup window closed with quality ``ok``."""
|
||||
return self.offsets is not None and self.deadbands is not None
|
||||
|
||||
@property
|
||||
def display_offsets(self) -> tuple[float, float, float] | None:
|
||||
"""What the display is centred on: frozen if there is one, else provisional."""
|
||||
return self.offsets if self.offsets is not None else self.provisional_offsets
|
||||
|
||||
@property
|
||||
def display_deadbands(self) -> tuple[float, float, float] | None:
|
||||
"""The deadband the display is using (frozen if there is one)."""
|
||||
return (
|
||||
self.deadbands if self.deadbands is not None else self.provisional_deadbands
|
||||
)
|
||||
|
||||
@property
|
||||
def display_ready(self) -> bool:
|
||||
"""Whether *some* estimate — provisional or frozen — is being applied."""
|
||||
return (
|
||||
self.display_offsets is not None and self.display_deadbands is not None
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ForcePresentation:
|
||||
"""Stateful display policy for the force numbers *and* the force curves.
|
||||
|
||||
Startup auto-baseline collects quiet samples per display unit, freezes
|
||||
offsets and deadbands, then applies a symmetric hysteretic deadband to the
|
||||
display copy. It is enabled for every sensor type and both units: an idle
|
||||
channel that reads a raw count or a newton offset at rest is a display
|
||||
problem whichever device produced it.
|
||||
"""
|
||||
"""One pure post-process for live values, curves, replay and export."""
|
||||
|
||||
enabled: bool = True
|
||||
unit: str = "raw"
|
||||
baseline_seconds: float = 3.0
|
||||
min_samples: int = 30
|
||||
#: Quiet samples needed before a provisional estimate is applied (see
|
||||
#: :data:`DEFAULT_PROVISIONAL_SAMPLES`).
|
||||
provisional_samples: int = DEFAULT_PROVISIONAL_SAMPLES
|
||||
max_flow_p95: float = 0.4
|
||||
require_no_contact: bool = True
|
||||
#: Residual percentile used as the per-axis deadband after offset removal.
|
||||
deadband_percentile: float = 99.0
|
||||
#: Extra margin on top of the percentile residual.
|
||||
deadband_margin: float = 1.05
|
||||
#: Floor so tiny MAD windows do not produce a zero deadband (see above).
|
||||
min_deadband_raw: float = DEFAULT_MIN_DEADBAND_RAW
|
||||
min_deadband_n: float = DEFAULT_MIN_DEADBAND_N
|
||||
hysteresis_ratio: float = 0.8
|
||||
raw_significant_digits: int = 4
|
||||
newton_decimals: int = 3
|
||||
raw_decimals: int = 0
|
||||
newton_decimals: int = 2
|
||||
|
||||
#: One idle estimate per display unit ("raw" / "N"), because the floors and
|
||||
#: the values are in different scales.
|
||||
_states: dict[str, _UnitEstimate] = field(
|
||||
default_factory=dict, init=False, repr=False
|
||||
)
|
||||
_suppressed: tuple[bool, bool, bool] = field(
|
||||
default=(False, False, False), init=False, repr=False
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ policy
|
||||
def reset_session(self, *, unit: str = "raw") -> None:
|
||||
"""Start a new idle estimate for one display unit."""
|
||||
self.unit = normalise_unit(unit)
|
||||
state = self._state(self.unit)
|
||||
state.collecting = bool(self.enabled)
|
||||
state.samples = []
|
||||
state.sample_times = []
|
||||
state.t0 = None
|
||||
state.offsets = None
|
||||
state.deadbands = None
|
||||
state.estimate = None
|
||||
state.provisional_offsets = None
|
||||
state.provisional_deadbands = None
|
||||
state.provisional_samples = 0
|
||||
state.contaminated = False
|
||||
self._suppressed = (False, False, False)
|
||||
|
||||
def request_reestimate(self) -> None:
|
||||
"""Operator-triggered restart of the idle window."""
|
||||
self.reset_session(unit=self.unit)
|
||||
|
||||
def set_enabled(self, enabled: bool, *, unit: str | None = None) -> None:
|
||||
"""The master switch; switching it on restarts the quiet window."""
|
||||
self.enabled = bool(enabled)
|
||||
self.reset_session(unit=self.unit if unit is None else unit)
|
||||
|
||||
def min_deadband_for(self, unit: str | None = None) -> float:
|
||||
"""The floor under the estimated deadband, for one display unit."""
|
||||
chosen = self.unit if unit is None else unit
|
||||
return (
|
||||
float(self.min_deadband_n)
|
||||
if normalise_unit(chosen) == "N"
|
||||
else float(self.min_deadband_raw)
|
||||
)
|
||||
|
||||
def set_min_deadband(self, unit: str, floor: float) -> None:
|
||||
"""Move one unit's floor, and re-floor the estimate already applied.
|
||||
|
||||
The floor is a lower bound, never a replacement: the samples collected so
|
||||
far are re-fitted, so setting it back to 0 ("auto") restores the
|
||||
estimated band instead of leaving the raised one behind. Both the frozen
|
||||
estimate and the provisional one in use are re-fitted, because either can
|
||||
be what the cards and the curves are reading.
|
||||
"""
|
||||
floor = max(0.0, float(floor))
|
||||
if normalise_unit(unit) == "N":
|
||||
self.min_deadband_n = floor
|
||||
else:
|
||||
self.min_deadband_raw = floor
|
||||
state = self._states.get(normalise_unit(unit))
|
||||
if state is None or not state.samples:
|
||||
return # a window still being collected will use the new floor
|
||||
estimate = self.fit_zero(state.samples, unit=unit)
|
||||
if estimate.quality != "ok":
|
||||
return
|
||||
if state.collecting or not state.ready:
|
||||
# Nothing frozen yet: this is the provisional estimate on screen.
|
||||
state.provisional_offsets = estimate.offsets
|
||||
state.provisional_deadbands = estimate.deadbands
|
||||
state.provisional_samples = estimate.sample_count
|
||||
return
|
||||
state.offsets = estimate.offsets
|
||||
state.deadbands = estimate.deadbands
|
||||
state.estimate = estimate
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
"""A frozen estimate for the active unit (the startup window closed)."""
|
||||
state = self._states.get(self.unit)
|
||||
return bool(state is not None and state.ready)
|
||||
|
||||
@property
|
||||
def display_ready(self) -> bool:
|
||||
"""Whether the display policy is being applied to the active unit.
|
||||
|
||||
True from the first few quiet samples (provisional) and after the frozen
|
||||
window as well — this is what the cards and the curves ask, because both
|
||||
are drawn from the same applied estimate.
|
||||
"""
|
||||
state = self._states.get(self.unit)
|
||||
return bool(state is not None and state.display_ready)
|
||||
if unit is not None:
|
||||
self.unit = normalise_unit(unit)
|
||||
|
||||
@property
|
||||
def display_quality(self) -> str:
|
||||
"""Where the numbers on screen come from, for diagnostics and reporting.
|
||||
return "threshold" if self.enabled else "off"
|
||||
|
||||
``"off"`` — the aid is switched off; the measurement is shown.
|
||||
``"provisional"`` — a provisional estimate from the first quiet samples
|
||||
is applied; the startup window is still open.
|
||||
``"ok"`` — the full quiet window closed and was frozen.
|
||||
``"contaminated"`` / ``"unstable"`` / ``"insufficient"`` — the window
|
||||
closed without a trustworthy estimate: the
|
||||
provisional estimate stays applied (this is the
|
||||
state that used to fall back to the raw bias).
|
||||
``"none"`` — no quiet sample was ever seen, so nothing is
|
||||
zeroed; a contact at session start is shown as it
|
||||
is measured rather than hidden.
|
||||
"""
|
||||
if not self.enabled:
|
||||
return "off"
|
||||
state = self._states.get(self.unit)
|
||||
if state is None or not state.display_ready:
|
||||
return "none"
|
||||
if state.ready:
|
||||
return "ok"
|
||||
if not state.collecting and state.estimate is not None:
|
||||
return state.estimate.quality
|
||||
return "provisional"
|
||||
def min_deadband_for(self, unit: str | None = None) -> float:
|
||||
chosen = normalise_unit(self.unit if unit is None else unit)
|
||||
return self.min_deadband_n if chosen == "N" else self.min_deadband_raw
|
||||
|
||||
@property
|
||||
def last_estimate(self) -> ZeroEstimate | None:
|
||||
"""The window's outcome: the frozen estimate, the failed one, or the
|
||||
provisional estimate while the window is still open."""
|
||||
state = self._states.get(self.unit)
|
||||
if state is None:
|
||||
return None
|
||||
if state.estimate is not None:
|
||||
return state.estimate
|
||||
if state.provisional_offsets is None or state.provisional_deadbands is None:
|
||||
return None
|
||||
return ZeroEstimate(
|
||||
offsets=state.provisional_offsets,
|
||||
deadbands=state.provisional_deadbands,
|
||||
sample_count=state.provisional_samples,
|
||||
quality="provisional",
|
||||
message=f"provisional: {state.provisional_samples} quiet samples so far",
|
||||
)
|
||||
def set_min_deadband(self, unit: str, threshold: float) -> None:
|
||||
value = max(0.0, float(threshold))
|
||||
if normalise_unit(unit) == "N":
|
||||
self.min_deadband_n = value
|
||||
else:
|
||||
self.min_deadband_raw = value
|
||||
|
||||
def _state(self, unit: str) -> _UnitEstimate:
|
||||
"""The estimate for one unit, created on first use.
|
||||
|
||||
A unit that was never estimated — the operator switched mid-session —
|
||||
starts its own quiet window instead of borrowing the other unit's, which
|
||||
would be an offset in the wrong scale.
|
||||
"""
|
||||
key = normalise_unit(unit)
|
||||
state = self._states.get(key)
|
||||
if state is None:
|
||||
state = _UnitEstimate(collecting=bool(self.enabled))
|
||||
self._states[key] = state
|
||||
return state
|
||||
|
||||
# --------------------------------------------------------------- estimator
|
||||
def observe(
|
||||
self,
|
||||
values: Sequence[float],
|
||||
*,
|
||||
t_rel_s: float,
|
||||
valid: bool = True,
|
||||
flow_p95: float | None = None,
|
||||
contact_found: bool = False,
|
||||
unit: str | None = None,
|
||||
) -> ZeroEstimate | None:
|
||||
"""Feed one live sample into the startup estimator.
|
||||
|
||||
``values`` must already be expressed in ``unit`` (the displayed one).
|
||||
Returns a :class:`ZeroEstimate` when the window closes (ok or failed).
|
||||
"""
|
||||
key = normalise_unit(self.unit if unit is None else unit)
|
||||
state = self._state(key)
|
||||
if not state.collecting:
|
||||
return None
|
||||
if state.t0 is None:
|
||||
state.t0 = float(t_rel_s)
|
||||
if not valid:
|
||||
return None
|
||||
if self.require_no_contact and contact_found:
|
||||
# Contaminated window — the sample is not a quiet one, so it must not
|
||||
# enter the estimate (that is how a press at session start stays
|
||||
# visible). The window is marked so a close can report it honestly.
|
||||
state.contaminated = True
|
||||
return None
|
||||
if flow_p95 is not None and float(flow_p95) > self.max_flow_p95:
|
||||
state.contaminated = True
|
||||
return None
|
||||
sample = tuple(float(v) for v in values[:3])
|
||||
if len(sample) < 3 or not all(np.isfinite(sample)):
|
||||
return None
|
||||
state.samples.append(sample) # type: ignore[arg-type]
|
||||
state.sample_times.append(float(t_rel_s))
|
||||
# Publish or refine the provisional estimate *before* the window is
|
||||
# allowed to close, so the display policy is effective from the first
|
||||
# samples and a later contamination cannot drop it back to the raw bias.
|
||||
self._update_provisional(state, key)
|
||||
elapsed = float(t_rel_s) - float(state.t0)
|
||||
if elapsed < self.baseline_seconds and len(state.samples) < self.min_samples * 2:
|
||||
return None
|
||||
if len(state.samples) < self.min_samples:
|
||||
# The window closed before it was ever a full quiet window. It is a
|
||||
# *failure* of the startup estimate, and the honest thing to do is
|
||||
# say so — but the display keeps whatever the provisional estimate
|
||||
# already worked out, because falling back to the raw bias is the
|
||||
# defect this class exists to remove.
|
||||
quality = "contaminated" if state.contaminated else "insufficient"
|
||||
detail = (
|
||||
"window contaminated (contact or flow) after"
|
||||
if state.contaminated
|
||||
else "only"
|
||||
)
|
||||
estimate = ZeroEstimate(
|
||||
offsets=(0.0, 0.0, 0.0),
|
||||
deadbands=(0.0, 0.0, 0.0),
|
||||
sample_count=len(state.samples),
|
||||
quality=quality,
|
||||
message=f"{detail} {len(state.samples)} quiet samples in {elapsed:.2f}s",
|
||||
)
|
||||
state.estimate = estimate
|
||||
state.collecting = False
|
||||
return estimate
|
||||
estimate = self.fit_zero(state.samples, unit=key)
|
||||
state.estimate = estimate
|
||||
state.collecting = False
|
||||
if estimate.quality == "ok":
|
||||
state.offsets = estimate.offsets
|
||||
state.deadbands = estimate.deadbands
|
||||
return estimate
|
||||
|
||||
def _update_provisional(self, state: _UnitEstimate, unit: str) -> None:
|
||||
"""Fit and apply the display estimate from the quiet samples so far.
|
||||
|
||||
Called on every accepted quiet sample. The estimate is only published
|
||||
once there are enough samples to take a median (:data:`DEFAULT_PROVISIONAL_SAMPLES`);
|
||||
from then on each sample sharpens it until the frozen window closes. A
|
||||
sample set the fitter rejects (not enough finite rows, wildly unstable)
|
||||
leaves the previous provisional estimate in place rather than dropping
|
||||
the display back to the raw bias.
|
||||
"""
|
||||
if len(state.samples) < max(3, int(self.provisional_samples)):
|
||||
return
|
||||
estimate = self.fit_zero(state.samples, unit=unit)
|
||||
if estimate.quality != "ok":
|
||||
return
|
||||
state.provisional_offsets = estimate.offsets
|
||||
state.provisional_deadbands = estimate.deadbands
|
||||
state.provisional_samples = estimate.sample_count
|
||||
|
||||
def fit_zero(
|
||||
self, samples: Sequence[Sequence[float]], *, unit: str | None = None
|
||||
) -> ZeroEstimate:
|
||||
"""Estimate offsets and deadbands from quiet samples (does not apply them)."""
|
||||
floor = self.min_deadband_for(unit)
|
||||
block = np.asarray(samples, dtype=np.float64)
|
||||
if block.ndim != 2 or block.shape[1] < 3 or block.shape[0] < 3:
|
||||
return ZeroEstimate(
|
||||
offsets=(0.0, 0.0, 0.0),
|
||||
deadbands=(0.0, 0.0, 0.0),
|
||||
sample_count=int(block.shape[0]) if block.ndim == 2 else 0,
|
||||
quality="insufficient",
|
||||
message="not enough samples to estimate a baseline",
|
||||
)
|
||||
offsets: list[float] = []
|
||||
deadbands: list[float] = []
|
||||
for axis in range(3):
|
||||
col = block[:, axis]
|
||||
finite = col[np.isfinite(col)]
|
||||
if finite.size < 3:
|
||||
return ZeroEstimate(
|
||||
offsets=(0.0, 0.0, 0.0),
|
||||
deadbands=(0.0, 0.0, 0.0),
|
||||
sample_count=int(block.shape[0]),
|
||||
quality="insufficient",
|
||||
message=f"axis {axis} lacks finite samples",
|
||||
)
|
||||
med = float(np.median(finite))
|
||||
residual = np.abs(finite - med)
|
||||
p = float(np.percentile(residual, self.deadband_percentile))
|
||||
db = max(floor, p * self.deadband_margin)
|
||||
# Reject wildly unstable windows (p99 >> median magnitude scale).
|
||||
mad = float(np.median(residual))
|
||||
if mad > 0 and p > 20.0 * mad and p > 2000.0:
|
||||
return ZeroEstimate(
|
||||
offsets=(0.0, 0.0, 0.0),
|
||||
deadbands=(0.0, 0.0, 0.0),
|
||||
sample_count=int(block.shape[0]),
|
||||
quality="unstable",
|
||||
message=f"axis {axis} residual p{self.deadband_percentile:g}={p:.1f}",
|
||||
)
|
||||
offsets.append(med)
|
||||
deadbands.append(db)
|
||||
return ZeroEstimate(
|
||||
offsets=(offsets[0], offsets[1], offsets[2]),
|
||||
deadbands=(deadbands[0], deadbands[1], deadbands[2]),
|
||||
sample_count=int(block.shape[0]),
|
||||
quality="ok",
|
||||
)
|
||||
|
||||
def apply_estimate(
|
||||
self, estimate: ZeroEstimate, *, unit: str | None = None
|
||||
) -> None:
|
||||
"""Explicitly freeze an estimate (e.g. after user confirmation)."""
|
||||
if estimate.quality != "ok":
|
||||
return
|
||||
state = self._state(self.unit if unit is None else unit)
|
||||
state.offsets = estimate.offsets
|
||||
state.deadbands = estimate.deadbands
|
||||
state.collecting = False
|
||||
state.estimate = estimate
|
||||
|
||||
# ------------------------------------------------------------------ display
|
||||
def accept(
|
||||
self,
|
||||
values: Sequence[float],
|
||||
@@ -475,116 +78,34 @@ class ForcePresentation:
|
||||
unit: str | None = None,
|
||||
available: tuple[bool, bool, bool] = (True, True, True),
|
||||
) -> DisplayForce:
|
||||
"""Return a display copy of one live sample; never mutates ``values``.
|
||||
|
||||
The deadband is hysteretic: an axis already held at zero stays there
|
||||
until it leaves the full band, so a reading on the edge does not flicker
|
||||
between a number and zero.
|
||||
|
||||
The offset it subtracts is the frozen estimate once the startup window
|
||||
has closed cleanly, and the provisional one before (or instead of) that
|
||||
— never "nothing", which is what used to leave the raw bias on the cards
|
||||
for the first seconds of a session.
|
||||
"""
|
||||
unit = normalise_unit(self.unit if unit is None else unit)
|
||||
raw = [float(v) for v in values[:3]]
|
||||
while len(raw) < 3:
|
||||
raw.append(float("nan"))
|
||||
state = self._states.get(unit)
|
||||
if not self.enabled or state is None or not state.display_ready:
|
||||
return DisplayForce(
|
||||
values=(raw[0], raw[1], raw[2]),
|
||||
suppressed=(False, False, False),
|
||||
unit=unit,
|
||||
zero_zone=False,
|
||||
)
|
||||
|
||||
out: list[float] = []
|
||||
suppressed: list[bool] = []
|
||||
prev = self._suppressed
|
||||
for i in range(3):
|
||||
if not available[i] or not np.isfinite(raw[i]):
|
||||
out.append(raw[i])
|
||||
suppressed.append(False)
|
||||
continue
|
||||
centered = raw[i] - state.display_offsets[i] # type: ignore[index]
|
||||
on = state.display_deadbands[i] # type: ignore[index]
|
||||
off = on * self.hysteresis_ratio
|
||||
was = prev[i]
|
||||
if was:
|
||||
quiet = abs(centered) <= on
|
||||
else:
|
||||
quiet = abs(centered) <= off
|
||||
if quiet:
|
||||
out.append(0.0)
|
||||
suppressed.append(True)
|
||||
else:
|
||||
out.append(centered)
|
||||
suppressed.append(False)
|
||||
self._suppressed = (suppressed[0], suppressed[1], suppressed[2])
|
||||
return DisplayForce(
|
||||
values=(out[0], out[1], out[2]),
|
||||
suppressed=self._suppressed,
|
||||
unit=unit,
|
||||
zero_zone=any(self._suppressed),
|
||||
chosen = normalise_unit(self.unit if unit is None else unit)
|
||||
raw = tuple(float(values[i]) if i < len(values) else float("nan") for i in range(3))
|
||||
processed, mask = self.present([raw], unit=chosen)
|
||||
result = tuple(
|
||||
float(processed[0, i]) if available[i] else raw[i] for i in range(3)
|
||||
)
|
||||
suppressed = tuple(bool(mask[0, i] and available[i]) for i in range(3))
|
||||
return DisplayForce(result, suppressed, chosen, any(suppressed))
|
||||
|
||||
def present(
|
||||
self, values, *, unit: str | None = None
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Present a whole block of readings: ``(display copy, suppressed mask)``.
|
||||
|
||||
One row per sample, for the curves — the live buffer draws a rolling
|
||||
window, not one sample at a time. Vectorised and *stateless*: the
|
||||
hysteresis latch in :meth:`accept` belongs to the live sample stream, and
|
||||
a curve redrawn from a window must not depend on which frame drew it.
|
||||
|
||||
Non-finite readings stay non-finite — an axis with no value in this unit,
|
||||
or a failed readout, is a gap and never a zero.
|
||||
|
||||
It reads the same applied estimate as :meth:`accept` (provisional while
|
||||
the startup window is open), so a card and its own curve can never
|
||||
disagree about what the same sample is worth.
|
||||
"""
|
||||
unit = normalise_unit(self.unit if unit is None else unit)
|
||||
"""Apply the same absolute threshold independently to each finite axis."""
|
||||
block = np.array(values, dtype=np.float64, copy=True, ndmin=2)[:, :3]
|
||||
if block.shape[1] < 3:
|
||||
pad = np.full((block.shape[0], 3 - block.shape[1]), np.nan)
|
||||
block = np.hstack([block, pad])
|
||||
block = np.pad(
|
||||
block, ((0, 0), (0, 3 - block.shape[1])), constant_values=np.nan
|
||||
)
|
||||
suppressed = np.zeros(block.shape, dtype=bool)
|
||||
state = self._states.get(unit)
|
||||
if not self.enabled or state is None or not state.display_ready:
|
||||
return block, suppressed
|
||||
offsets = state.display_offsets
|
||||
deadbands = state.display_deadbands
|
||||
for axis in range(3):
|
||||
column = block[:, axis]
|
||||
finite = np.isfinite(column)
|
||||
if not finite.any():
|
||||
continue
|
||||
centered = column[finite] - offsets[axis] # type: ignore[index]
|
||||
quiet = np.abs(centered) <= deadbands[axis] # type: ignore[index]
|
||||
column[finite] = np.where(quiet, 0.0, centered)
|
||||
suppressed[finite, axis] = quiet
|
||||
if self.enabled:
|
||||
threshold = self.min_deadband_for(unit)
|
||||
block, suppressed = threshold_values(block, threshold)
|
||||
return block, suppressed
|
||||
|
||||
def format_force(self, value: float, unit: str | None = None) -> str:
|
||||
"""Stable digit formatting for value cards."""
|
||||
unit = normalise_unit(self.unit if unit is None else unit)
|
||||
if not np.isfinite(value):
|
||||
return "—"
|
||||
# Avoid signed zero after deadband.
|
||||
if value == 0.0 or value == -0.0:
|
||||
value = 0.0
|
||||
if unit == "N":
|
||||
return f"{value:.{self.newton_decimals}f}"
|
||||
digits = max(2, int(self.raw_significant_digits))
|
||||
abs_v = abs(value)
|
||||
if abs_v >= 1000:
|
||||
return f"{value:.0f}"
|
||||
if abs_v >= 100:
|
||||
return f"{value:.1f}"
|
||||
if abs_v >= 10:
|
||||
return f"{value:.2f}"
|
||||
# Significant figures for small residual readings.
|
||||
return f"{value:.{digits}g}"
|
||||
chosen = normalise_unit(self.unit if unit is None else unit)
|
||||
decimals = self.newton_decimals if chosen == "N" else self.raw_decimals
|
||||
decimals = max(0, int(decimals))
|
||||
return f"{0.0 if round(value, decimals) == 0 else value:.{decimals}f}"
|
||||
|
||||
@@ -35,7 +35,7 @@ from orisys_app_shared.record.sample import plottable_axis_mask
|
||||
NA = float("nan")
|
||||
|
||||
DEFAULT_BLOCK_SIZE = 8192
|
||||
#: ~4.5 h at 30 Hz of three raw + three calibrated float64 channels.
|
||||
#: ~4.5 h at 30 Hz; each sample keeps original and processed force channels.
|
||||
DEFAULT_MAX_SAMPLES = 500_000
|
||||
|
||||
# Column layout of the packed store.
|
||||
@@ -48,7 +48,13 @@ _COL_FN_N = 5
|
||||
_COL_FX_N = 6
|
||||
_COL_FY_N = 7
|
||||
_COL_VALID = 8
|
||||
_COLUMNS = 9
|
||||
_COL_FN_OUTPUT_RAW = 9
|
||||
_COL_FX_OUTPUT_RAW = 10
|
||||
_COL_FY_OUTPUT_RAW = 11
|
||||
_COL_FN_OUTPUT_N = 12
|
||||
_COL_FX_OUTPUT_N = 13
|
||||
_COL_FY_OUTPUT_N = 14
|
||||
_COLUMNS = 15
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -69,6 +75,12 @@ class SessionSample:
|
||||
#: Axes that actually have a calibration curve. An axis outside this set has
|
||||
#: no newton value, which the UI shows as a dash rather than as a number.
|
||||
calibrated_axes: tuple[str, ...] = ()
|
||||
fn_raw_processed: float = NA
|
||||
fx_raw_processed: float = NA
|
||||
fy_raw_processed: float = NA
|
||||
fn_n_processed: float = NA
|
||||
fx_n_processed: float = NA
|
||||
fy_n_processed: float = NA
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record) -> SessionSample:
|
||||
@@ -87,6 +99,12 @@ class SessionSample:
|
||||
fn_n=float(record.fn_n),
|
||||
fx_n=float(record.fx_n),
|
||||
fy_n=float(record.fy_n),
|
||||
fn_raw_processed=float(record.fn_raw_processed),
|
||||
fx_raw_processed=float(record.fx_raw_processed),
|
||||
fy_raw_processed=float(record.fy_raw_processed),
|
||||
fn_n_processed=float(record.fn_n_processed),
|
||||
fx_n_processed=float(record.fx_n_processed),
|
||||
fy_n_processed=float(record.fy_n_processed),
|
||||
valid=bool(record.valid),
|
||||
calibration_id=str(record.calibration_version or ""),
|
||||
error=str(record.invalid_reason or ""),
|
||||
@@ -116,6 +134,8 @@ class SessionSlice:
|
||||
valid: np.ndarray
|
||||
frame_ids: np.ndarray
|
||||
truncated: bool = False
|
||||
output_raw: np.ndarray | None = None
|
||||
output_newtons: np.ndarray | None = None
|
||||
|
||||
def __len__(self) -> int:
|
||||
return int(self.times.size)
|
||||
@@ -126,6 +146,11 @@ class SessionSlice:
|
||||
return self.newtons
|
||||
return self.raw
|
||||
|
||||
def output_for_unit(self, unit: str) -> np.ndarray:
|
||||
if str(unit).upper().startswith("N"):
|
||||
return self.output_newtons if self.output_newtons is not None else self.newtons
|
||||
return self.output_raw if self.output_raw is not None else self.raw
|
||||
|
||||
def plottable(self, unit: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""``(times, values, mask)`` for drawing, under one shared rule.
|
||||
|
||||
@@ -156,7 +181,9 @@ class SelectionStats:
|
||||
stds: tuple[float, float, float] = (NA, NA, NA)
|
||||
|
||||
|
||||
def _decimate_preserving_peaks(rows: np.ndarray, max_points: int, unit: str = "raw") -> np.ndarray:
|
||||
def _decimate_preserving_peaks(
|
||||
rows: np.ndarray, max_points: int, unit: str = "raw", *, processed: bool = False
|
||||
) -> np.ndarray:
|
||||
"""Reduce ``rows`` to about ``max_points`` while keeping every spike.
|
||||
|
||||
Each bucket contributes the extreme sample of **every plotted axis** in the
|
||||
@@ -167,11 +194,16 @@ def _decimate_preserving_peaks(rows: np.ndarray, max_points: int, unit: str = "r
|
||||
total = rows.shape[0]
|
||||
if max_points < 4 or total <= max_points:
|
||||
return rows
|
||||
columns = (
|
||||
(_COL_FN_N, _COL_FX_N, _COL_FY_N)
|
||||
if str(unit).upper().startswith("N")
|
||||
else (_COL_FN_RAW, _COL_FX_RAW, _COL_FY_RAW)
|
||||
)
|
||||
if str(unit).upper().startswith("N"):
|
||||
columns = (
|
||||
(_COL_FN_OUTPUT_N, _COL_FX_OUTPUT_N, _COL_FY_OUTPUT_N)
|
||||
if processed else (_COL_FN_N, _COL_FX_N, _COL_FY_N)
|
||||
)
|
||||
else:
|
||||
columns = (
|
||||
(_COL_FN_OUTPUT_RAW, _COL_FX_OUTPUT_RAW, _COL_FY_OUTPUT_RAW)
|
||||
if processed else (_COL_FN_RAW, _COL_FX_RAW, _COL_FY_RAW)
|
||||
)
|
||||
bucket = max(2, total // max(2, max_points // (2 * len(columns))))
|
||||
usable = (total // bucket) * bucket
|
||||
head = rows[:usable].reshape(-1, bucket, rows.shape[1])
|
||||
@@ -261,6 +293,12 @@ class SessionStore:
|
||||
float(sample.fx_n),
|
||||
float(sample.fy_n),
|
||||
valid,
|
||||
float(sample.fn_raw_processed) if np.isfinite(sample.fn_raw_processed) else float(sample.fn_raw),
|
||||
float(sample.fx_raw_processed) if np.isfinite(sample.fx_raw_processed) else float(sample.fx_raw),
|
||||
float(sample.fy_raw_processed) if np.isfinite(sample.fy_raw_processed) else float(sample.fy_raw),
|
||||
float(sample.fn_n_processed) if np.isfinite(sample.fn_n_processed) else float(sample.fn_n),
|
||||
float(sample.fx_n_processed) if np.isfinite(sample.fx_n_processed) else float(sample.fx_n),
|
||||
float(sample.fy_n_processed) if np.isfinite(sample.fy_n_processed) else float(sample.fy_n),
|
||||
)
|
||||
self._write_row(row)
|
||||
self._last_t = float(sample.t_rel_s)
|
||||
@@ -378,7 +416,8 @@ class SessionStore:
|
||||
return (float(block[0, _COL_T]), float(block[rows - 1, _COL_T]))
|
||||
|
||||
def visible(
|
||||
self, t0: float, t1: float, max_points: int = 2000, *, unit: str = "raw"
|
||||
self, t0: float, t1: float, max_points: int = 2000, *,
|
||||
unit: str = "raw", processed: bool = False,
|
||||
) -> SessionSlice:
|
||||
"""Decimated slice for drawing, keeping peaks on every plotted axis.
|
||||
|
||||
@@ -395,7 +434,7 @@ class SessionStore:
|
||||
rows = self._packed_window(t0, t1)
|
||||
truncated = self._dropped > 0
|
||||
if max_points and rows.shape[0] > max_points:
|
||||
rows = _decimate_preserving_peaks(rows, max_points, unit)
|
||||
rows = _decimate_preserving_peaks(rows, max_points, unit, processed=processed)
|
||||
return replace(self._slice_from(rows), truncated=truncated)
|
||||
|
||||
def _packed_window(self, t0: float, t1: float) -> np.ndarray:
|
||||
@@ -421,7 +460,9 @@ class SessionStore:
|
||||
return np.empty((0, _COLUMNS), dtype=np.float64)
|
||||
return np.vstack(selected) if len(selected) > 1 else selected[0]
|
||||
|
||||
def statistics(self, t0: float, t1: float, unit: str = "raw") -> SelectionStats:
|
||||
def statistics(
|
||||
self, t0: float, t1: float, unit: str = "raw", *, processed: bool = False
|
||||
) -> SelectionStats:
|
||||
"""Duration, peak, mean and std over a selection, per channel."""
|
||||
t0 = float(t0)
|
||||
t1 = float(t1)
|
||||
@@ -438,9 +479,9 @@ class SessionStore:
|
||||
if valid_rows.shape[0] == 0:
|
||||
return SelectionStats(duration_s=duration, samples=0)
|
||||
columns = (
|
||||
(_COL_FN_N, _COL_FX_N, _COL_FY_N)
|
||||
((_COL_FN_OUTPUT_N, _COL_FX_OUTPUT_N, _COL_FY_OUTPUT_N) if processed else (_COL_FN_N, _COL_FX_N, _COL_FY_N))
|
||||
if str(unit).upper().startswith("N")
|
||||
else (_COL_FN_RAW, _COL_FX_RAW, _COL_FY_RAW)
|
||||
else ((_COL_FN_OUTPUT_RAW, _COL_FX_OUTPUT_RAW, _COL_FY_OUTPUT_RAW) if processed else (_COL_FN_RAW, _COL_FX_RAW, _COL_FY_RAW))
|
||||
)
|
||||
peaks: list[float] = []
|
||||
means: list[float] = []
|
||||
@@ -478,6 +519,8 @@ class SessionStore:
|
||||
times=packed[:, _COL_T].copy(),
|
||||
raw=packed[:, [_COL_FN_RAW, _COL_FX_RAW, _COL_FY_RAW]].copy(),
|
||||
newtons=packed[:, [_COL_FN_N, _COL_FX_N, _COL_FY_N]].copy(),
|
||||
output_raw=packed[:, [_COL_FN_OUTPUT_RAW, _COL_FX_OUTPUT_RAW, _COL_FY_OUTPUT_RAW]].copy(),
|
||||
output_newtons=packed[:, [_COL_FN_OUTPUT_N, _COL_FX_OUTPUT_N, _COL_FY_OUTPUT_N]].copy(),
|
||||
valid=packed[:, _COL_VALID] > 0.5,
|
||||
frame_ids=packed[:, _COL_FRAME_ID].astype(np.int64, copy=True),
|
||||
truncated=self._dropped > 0,
|
||||
|
||||
@@ -123,7 +123,7 @@ class StoreCurveSource:
|
||||
frame_ids=np.empty(0, dtype=np.int64),
|
||||
)
|
||||
return self.store.visible(
|
||||
t0, t1, max_points=max_points or 0, unit=unit
|
||||
t0, t1, max_points=max_points or 0, unit=unit, processed=True
|
||||
)
|
||||
|
||||
def window(
|
||||
@@ -137,7 +137,7 @@ class StoreCurveSource:
|
||||
slice_ = self._slice(t0, t1, max_points, unit)
|
||||
return SourceWindow(
|
||||
times=slice_.times,
|
||||
values=slice_.value_for_unit(unit),
|
||||
values=slice_.output_for_unit(unit),
|
||||
valid=slice_.valid,
|
||||
frame_ids=slice_.frame_ids,
|
||||
unit=unit,
|
||||
@@ -154,7 +154,7 @@ class StoreCurveSource:
|
||||
def statistics(self, t0: float, t1: float, *, unit: str = "raw") -> SourceStats:
|
||||
if self.store is None:
|
||||
return SourceStats()
|
||||
stats = self.store.statistics(t0, t1, unit=unit)
|
||||
stats = self.store.statistics(t0, t1, unit=unit, processed=True)
|
||||
return SourceStats(
|
||||
duration_s=stats.duration_s,
|
||||
samples=stats.samples,
|
||||
|
||||
@@ -63,8 +63,9 @@ def select_display_forces(*, units_pref: str, sample) -> DisplayForces:
|
||||
show: there is no second interpretation of what a value means.
|
||||
"""
|
||||
want_newton = str(units_pref or "newton").lower().startswith("n")
|
||||
raw = tuple(sample.raw_for(axis) for axis in AXES)
|
||||
newton = tuple(sample.newton_for(axis) for axis in AXES)
|
||||
processed = getattr(sample, "processed_for", None)
|
||||
raw = tuple(processed(axis, "raw") if callable(processed) else sample.raw_for(axis) for axis in AXES)
|
||||
newton = tuple(processed(axis, "N") if callable(processed) else sample.newton_for(axis) for axis in AXES)
|
||||
requested = "N" if want_newton else "raw"
|
||||
|
||||
if not want_newton:
|
||||
|
||||
@@ -56,11 +56,11 @@ _DEFAULTS: dict[str, Any] = {
|
||||
"plot_fn_raw_min": 0.0,
|
||||
"plot_fn_raw_max": 1000000.0,
|
||||
"plot_shear_raw_max": 500000.0,
|
||||
# Display deadband floors, per display unit. 0 = auto: the noise estimated
|
||||
# from the quiet startup window (raw keeps its 50-count floor under it).
|
||||
# Display only — the session store and forces.csv keep the measurement.
|
||||
# Absolute force-output thresholds. Original measurements are retained.
|
||||
"plot_deadband_n": 0.0,
|
||||
"plot_deadband_raw": 0.0,
|
||||
"plot_deadband_raw": 50.0,
|
||||
"plot_newton_decimals": 2,
|
||||
"plot_raw_decimals": 0,
|
||||
"image_view_mode": "arrows_pure",
|
||||
"arrow_rotation_deg": 90,
|
||||
#: How the finger's 2D canvas is presented. A display convention (the unrolled
|
||||
@@ -69,7 +69,6 @@ _DEFAULTS: dict[str, Any] = {
|
||||
#: the stored flow and mask are never reshaped.
|
||||
"finger_2d_orientation": "rotate180",
|
||||
"show_fps": False,
|
||||
"raw_auto_zero": True,
|
||||
#: The GL profile the 3D view is started with. One per process: the surface
|
||||
#: format is chosen before ``QApplication`` exists and cannot be switched later.
|
||||
"gl_profile": "desktop33",
|
||||
|
||||
@@ -46,7 +46,7 @@ from orisys_app_shared.record.video_writer import VideoSegments
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RECORD_FORMAT_VERSION = "1.1"
|
||||
RECORD_FORMAT_VERSION = "1.2"
|
||||
|
||||
# Lifecycle states. ``failed`` and ``stopped`` are terminal; a recorder that has
|
||||
# not reached a terminal state must not be reused.
|
||||
@@ -71,6 +71,12 @@ FORCES_HEADER = (
|
||||
"FY_N",
|
||||
"valid",
|
||||
"calibration_id",
|
||||
"FN_output_raw",
|
||||
"FX_output_raw",
|
||||
"FY_output_raw",
|
||||
"FN_output_N",
|
||||
"FX_output_N",
|
||||
"FY_output_N",
|
||||
)
|
||||
FRAMES_HEADER = (
|
||||
"frame_id",
|
||||
@@ -513,7 +519,7 @@ class ExperimentRecorder:
|
||||
self._enqueue(("event", self._next_seq(), event))
|
||||
|
||||
# ----------------------------------------------------------- sink interface
|
||||
def on_packet(self, packet) -> None:
|
||||
def on_packet(self, packet, *, sample: RecordSample | None = None) -> None:
|
||||
"""Called on the acquisition thread: enqueue, never do I/O here.
|
||||
|
||||
A data row is small and a frame is large, so video is what gives way —
|
||||
@@ -532,9 +538,10 @@ class ExperimentRecorder:
|
||||
if self._state == STATE_FAILED:
|
||||
self._stats.rejected_after_failure += 1
|
||||
return
|
||||
sample = to_record_sample(
|
||||
packet, calibration_version=self._calibration_version
|
||||
)
|
||||
if sample is None:
|
||||
sample = to_record_sample(
|
||||
packet, calibration_version=self._calibration_version
|
||||
)
|
||||
if self._t0 is None:
|
||||
self._t0 = sample.mono_t
|
||||
with self._lock:
|
||||
@@ -1107,6 +1114,12 @@ class _SessionWriter:
|
||||
format_value(sample.fy_n),
|
||||
1 if sample.valid else 0,
|
||||
sample.calibration_version or self.calibration_id,
|
||||
format_value(sample.fn_raw_processed),
|
||||
format_value(sample.fx_raw_processed),
|
||||
format_value(sample.fy_raw_processed),
|
||||
format_value(sample.fn_n_processed),
|
||||
format_value(sample.fx_n_processed),
|
||||
format_value(sample.fy_n_processed),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -35,6 +35,10 @@ CHECKPOINT_ROWS = 4096
|
||||
DEFAULT_BUCKETS = 1200
|
||||
|
||||
_FORCE_COLUMNS = ("FN_raw", "FX_raw", "FY_raw", "FN_N", "FX_N", "FY_N")
|
||||
_OUTPUT_COLUMNS = (
|
||||
"FN_output_raw", "FX_output_raw", "FY_output_raw",
|
||||
"FN_output_N", "FX_output_N", "FY_output_N",
|
||||
)
|
||||
|
||||
|
||||
def _parse(text: str) -> float:
|
||||
@@ -235,7 +239,12 @@ class RecordingIndex:
|
||||
timestamp = float(cell("t_rel_s"))
|
||||
if not np.isfinite(timestamp) or not -(2**63) <= frame_id < 2**63:
|
||||
return None
|
||||
values = tuple(_parse(cell(name)) for name in _FORCE_COLUMNS)
|
||||
columns = (
|
||||
_OUTPUT_COLUMNS
|
||||
if all(name in self._columns for name in _OUTPUT_COLUMNS)
|
||||
else _FORCE_COLUMNS
|
||||
)
|
||||
values = tuple(_parse(cell(name)) for name in columns)
|
||||
return frame_id, timestamp, values[:3], values[3:], cell("valid").strip() == "1"
|
||||
except (KeyError, IndexError, ValueError, StopIteration, csv.Error):
|
||||
return None
|
||||
|
||||
@@ -26,7 +26,7 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING: # numpy is imported lazily, by the one function that needs it
|
||||
@@ -68,6 +68,12 @@ class RecordSample:
|
||||
fy_n: float = float("nan")
|
||||
calibrated_axes: tuple[str, ...] = ()
|
||||
calibration_version: str = ""
|
||||
fn_raw_processed: float = float("nan")
|
||||
fx_raw_processed: float = float("nan")
|
||||
fy_raw_processed: float = float("nan")
|
||||
fn_n_processed: float = float("nan")
|
||||
fx_n_processed: float = float("nan")
|
||||
fy_n_processed: float = float("nan")
|
||||
|
||||
# ------------------------------------------------------------- accessors
|
||||
def raw_triplet(self) -> tuple[float, float, float]:
|
||||
@@ -76,6 +82,14 @@ class RecordSample:
|
||||
def newton_triplet(self) -> tuple[float, float, float]:
|
||||
return (self.fn_n, self.fx_n, self.fy_n)
|
||||
|
||||
def processed_triplet(self, unit: str) -> tuple[float, float, float]:
|
||||
if str(unit).upper().startswith("N"):
|
||||
return (self.fn_n_processed, self.fx_n_processed, self.fy_n_processed)
|
||||
return (self.fn_raw_processed, self.fx_raw_processed, self.fy_raw_processed)
|
||||
|
||||
def processed_for(self, axis: str, unit: str) -> float:
|
||||
return dict(zip(AXES, self.processed_triplet(unit))).get(axis, float("nan"))
|
||||
|
||||
def raw_for(self, axis: str) -> float:
|
||||
return {"fn": self.fn_raw, "fx": self.fx_raw, "fy": self.fy_raw}.get(
|
||||
axis, float("nan")
|
||||
@@ -203,11 +217,36 @@ def to_record_sample(packet, *, calibration_version: str = "") -> RecordSample:
|
||||
fn_n=newton[0],
|
||||
fx_n=newton[1],
|
||||
fy_n=newton[2],
|
||||
fn_raw_processed=raw[0],
|
||||
fx_raw_processed=raw[1],
|
||||
fy_raw_processed=raw[2],
|
||||
fn_n_processed=newton[0],
|
||||
fx_n_processed=newton[1],
|
||||
fy_n_processed=newton[2],
|
||||
calibrated_axes=axes,
|
||||
calibration_version=version,
|
||||
)
|
||||
|
||||
|
||||
def process_record_sample(
|
||||
sample: RecordSample, *, raw_threshold: float, newton_threshold: float
|
||||
) -> RecordSample:
|
||||
"""Keep acquired values and attach the same per-axis output used by Studio."""
|
||||
from orisys_app_shared.force.presentation import threshold_force
|
||||
|
||||
raw = tuple(threshold_force(v, raw_threshold) for v in sample.raw_triplet())
|
||||
newton = tuple(threshold_force(v, newton_threshold) for v in sample.newton_triplet())
|
||||
return replace(
|
||||
sample,
|
||||
fn_raw_processed=raw[0],
|
||||
fx_raw_processed=raw[1],
|
||||
fy_raw_processed=raw[2],
|
||||
fn_n_processed=newton[0],
|
||||
fx_n_processed=newton[1],
|
||||
fy_n_processed=newton[2],
|
||||
)
|
||||
|
||||
|
||||
def axes_without_newton(sample: RecordSample) -> tuple[str, ...]:
|
||||
"""Axes that produced no newton value — used by the UI to explain blanks."""
|
||||
return tuple(axis for axis in AXES if not sample.has_newton(axis))
|
||||
|
||||
Reference in New Issue
Block a user