统一力值阈值处理并保留原始采样
移除自动显示基线,使逐轴阈值统一作用于曲线、读数与记录输出,标定拟合继续使用原始采样。 共享层全量测试和相关 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))
|
||||
|
||||
@@ -1,394 +1,35 @@
|
||||
"""Display-only force baseline / deadband (value cards and curves)."""
|
||||
|
||||
from __future__ import annotations
|
||||
"""Absolute force thresholds preserve unavailable axes and original values."""
|
||||
|
||||
import numpy as np
|
||||
from orisys_app_shared.force.presentation import ForcePresentation, ZeroEstimate
|
||||
|
||||
#: The idle triplet from the customer's screenshot at run start: the numbers the
|
||||
#: cards were showing because the estimate was not applied yet.
|
||||
REPORTED_RAW_BIAS = (1118.0, 34.16, -186.1)
|
||||
REPORTED_NEWTON_BIAS = (0.4, -0.25, 0.1)
|
||||
from orisys_app_shared.force.presentation import ForcePresentation, threshold_force
|
||||
|
||||
|
||||
def test_fit_zero_rejects_insufficient_samples() -> None:
|
||||
presentation = ForcePresentation(enabled=True, min_samples=30)
|
||||
estimate = presentation.fit_zero([(1.0, 2.0, 3.0), (1.1, 2.1, 3.1)])
|
||||
assert estimate.quality == "insufficient"
|
||||
def test_threshold_is_absolute_and_independent_per_axis():
|
||||
policy = ForcePresentation(min_deadband_raw=50.0, min_deadband_n=0.5)
|
||||
displayed = policy.accept((49.0, -50.0, 51.0), unit="raw")
|
||||
assert displayed.values == (0.0, 0.0, 51.0)
|
||||
assert displayed.suppressed == (True, True, False)
|
||||
newtons = policy.accept((0.4, -0.6, float("nan")), unit="N")
|
||||
assert newtons.values[:2] == (0.0, -0.6)
|
||||
assert np.isnan(newtons.values[2])
|
||||
|
||||
|
||||
def test_fit_zero_and_accept_deadband() -> None:
|
||||
rng = np.random.default_rng(0)
|
||||
bias = np.array([1262.0, -11.0, -353.0])
|
||||
noise = rng.normal(0.0, 20.0, size=(80, 3))
|
||||
samples = (bias + noise).tolist()
|
||||
presentation = ForcePresentation(
|
||||
enabled=True,
|
||||
min_samples=30,
|
||||
baseline_seconds=0.0,
|
||||
min_deadband_raw=50.0,
|
||||
deadband_percentile=99.0,
|
||||
)
|
||||
estimate = presentation.fit_zero(samples)
|
||||
assert estimate.quality == "ok"
|
||||
presentation.apply_estimate(estimate)
|
||||
idle = presentation.accept(tuple(bias), unit="raw")
|
||||
assert idle.zero_zone
|
||||
assert idle.values == (0.0, 0.0, 0.0)
|
||||
# Curves / CSV keep the measurement: accept never mutates the input tuple.
|
||||
measured = (1262.0, -11.0, -353.0)
|
||||
presentation.accept(measured, unit="raw")
|
||||
assert measured == (1262.0, -11.0, -353.0)
|
||||
def test_scalar_and_array_paths_agree_without_mutating_input():
|
||||
source = np.array([[49.0, -51.0, np.nan]])
|
||||
policy = ForcePresentation(min_deadband_raw=50.0)
|
||||
output, mask = policy.present(source, unit="raw")
|
||||
assert output[0, :2].tolist() == [0.0, -51.0]
|
||||
assert mask.tolist() == [[True, False, False]]
|
||||
assert source[0, 0] == 49.0
|
||||
assert threshold_force(49.0, 50.0) == output[0, 0]
|
||||
|
||||
|
||||
def test_observe_skips_contact_contamination() -> None:
|
||||
presentation = ForcePresentation(
|
||||
enabled=True,
|
||||
min_samples=5,
|
||||
baseline_seconds=0.1,
|
||||
require_no_contact=True,
|
||||
)
|
||||
for i in range(10):
|
||||
out = presentation.observe(
|
||||
(1000.0, 0.0, 0.0),
|
||||
t_rel_s=i * 0.05,
|
||||
contact_found=True,
|
||||
flow_p95=0.1,
|
||||
)
|
||||
assert out is None
|
||||
assert not presentation.ready
|
||||
|
||||
|
||||
def test_observe_freezes_after_quiet_window() -> None:
|
||||
presentation = ForcePresentation(
|
||||
enabled=True,
|
||||
min_samples=10,
|
||||
baseline_seconds=0.5,
|
||||
min_deadband_raw=10.0,
|
||||
)
|
||||
estimate: ZeroEstimate | None = None
|
||||
for i in range(40):
|
||||
estimate = presentation.observe(
|
||||
(100.0 + (i % 3), -5.0, 2.0),
|
||||
t_rel_s=i * 0.02,
|
||||
flow_p95=0.1,
|
||||
contact_found=False,
|
||||
)
|
||||
if estimate is not None:
|
||||
break
|
||||
assert estimate is not None
|
||||
assert estimate.quality == "ok"
|
||||
assert presentation.ready
|
||||
# Further observes do not chase a new zero once frozen.
|
||||
later = presentation.observe((5000.0, 0.0, 0.0), t_rel_s=10.0, flow_p95=0.0)
|
||||
assert later is None
|
||||
|
||||
|
||||
def test_the_estimate_is_kept_per_display_unit() -> None:
|
||||
"""A newton baseline and a raw baseline are different scales, not one number."""
|
||||
presentation = ForcePresentation(enabled=True, min_samples=5, baseline_seconds=1.0)
|
||||
for i in range(12):
|
||||
presentation.observe(
|
||||
(1000.0 + i * 0.1, 0.0, 0.0), t_rel_s=i * 0.02, unit="raw"
|
||||
)
|
||||
assert presentation.unit == "raw"
|
||||
assert presentation.ready
|
||||
assert presentation.accept((1000.55, 0.0, 0.0), unit="raw").values[0] == 0.0
|
||||
|
||||
# The other unit has no estimate of its own and must show what was measured.
|
||||
assert presentation.accept((1000.55, 0.0, 0.0), unit="N").values == (
|
||||
1000.55,
|
||||
0.0,
|
||||
0.0,
|
||||
)
|
||||
presentation.unit = "N"
|
||||
assert not presentation.ready, "the raw estimate made newtons ready"
|
||||
presentation.unit = "raw"
|
||||
|
||||
# ...and it collects its own window, in its own scale.
|
||||
for i in range(12):
|
||||
presentation.observe((0.5 + i * 0.01, 0.0, 0.0), t_rel_s=i * 0.02, unit="N")
|
||||
presentation.unit = "N"
|
||||
assert presentation.ready
|
||||
assert presentation.accept((0.555, 0.0, 0.0), unit="N").values[0] == 0.0
|
||||
|
||||
|
||||
def test_the_newton_floor_is_not_the_raw_floor() -> None:
|
||||
"""50 raw counts is a noise floor; 50 N would be a contact."""
|
||||
presentation = ForcePresentation(enabled=True)
|
||||
assert presentation.min_deadband_for("raw") == 50.0
|
||||
assert presentation.min_deadband_for("N") == 0.0
|
||||
|
||||
rng = np.random.default_rng(3)
|
||||
quiet = (0.5 + rng.normal(0.0, 0.002, size=(60, 3))).tolist()
|
||||
estimate = presentation.fit_zero(quiet, unit="N")
|
||||
assert estimate.quality == "ok"
|
||||
assert max(estimate.deadbands) < 0.01, estimate.deadbands
|
||||
|
||||
|
||||
def test_present_holds_a_resting_block_at_zero_and_keeps_an_excursion() -> None:
|
||||
rng = np.random.default_rng(7)
|
||||
bias = np.array([9555.0, -13368.0, -353.0])
|
||||
quiet = (bias + rng.normal(0.0, 20.0, size=(80, 3))).tolist()
|
||||
presentation = ForcePresentation(
|
||||
enabled=True, min_samples=30, baseline_seconds=0.0, deadband_percentile=99.0
|
||||
)
|
||||
estimate = presentation.fit_zero(quiet)
|
||||
assert estimate.quality == "ok"
|
||||
presentation.apply_estimate(estimate)
|
||||
|
||||
block = np.asarray(
|
||||
[bias, bias + 5.0, bias + 4000.0, [np.nan, np.nan, np.nan]], dtype=float
|
||||
)
|
||||
displayed, suppressed = presentation.present(block, unit="raw")
|
||||
assert displayed[0].tolist() == [0.0, 0.0, 0.0], "a resting sample drifted"
|
||||
assert suppressed[0].all()
|
||||
assert displayed[1].tolist() == [0.0, 0.0, 0.0], "noise outside the deadband?"
|
||||
# A real excursion survives, centred on the estimated offset.
|
||||
assert 3900.0 < displayed[2][0] < 4100.0, displayed[2]
|
||||
assert not suppressed[2].any()
|
||||
# A gap is a gap: an unavailable axis must never become a zero.
|
||||
assert np.isnan(displayed[3]).all()
|
||||
assert not suppressed[3].any()
|
||||
|
||||
|
||||
def test_present_is_a_display_copy_and_never_touches_the_input() -> None:
|
||||
presentation = ForcePresentation(enabled=True, min_samples=5, baseline_seconds=1.0)
|
||||
for i in range(12):
|
||||
presentation.observe((100.0 + i * 0.01, 0.0, 0.0), t_rel_s=i * 0.02)
|
||||
source = np.array([[100.055, 0.0, 0.0]])
|
||||
displayed, _suppressed = presentation.present(source, unit="raw")
|
||||
assert source.tolist() == [[100.055, 0.0, 0.0]], "present mutated the measurement"
|
||||
assert displayed[0][0] == 0.0
|
||||
|
||||
|
||||
def test_the_operator_floor_moves_the_frozen_deadband() -> None:
|
||||
presentation = ForcePresentation(enabled=True, min_samples=5, baseline_seconds=1.0)
|
||||
for i in range(12):
|
||||
presentation.observe((100.0 + i * 0.01, 0.0, 0.0), t_rel_s=i * 0.02, unit="raw")
|
||||
assert presentation.ready
|
||||
|
||||
presentation.set_min_deadband("raw", 500.0)
|
||||
assert presentation.accept((300.0, 0.0, 0.0), unit="raw").values[0] == 0.0, (
|
||||
"200 raw counts above the device's own band but inside the operator's floor"
|
||||
)
|
||||
# Back to auto: the floor is a floor, so the estimated band returns.
|
||||
presentation.set_min_deadband("raw", 0.0)
|
||||
assert presentation.min_deadband_for("raw") == 0.0
|
||||
assert presentation.accept((300.0, 0.0, 0.0), unit="raw").values[0] != 0.0
|
||||
|
||||
|
||||
def test_format_force_stable_digits() -> None:
|
||||
presentation = ForcePresentation(enabled=True)
|
||||
assert presentation.format_force(0.0, unit="raw") == "0"
|
||||
assert presentation.format_force(1262.4, unit="raw") == "1262"
|
||||
assert presentation.format_force(1.2345, unit="N") == "1.234"
|
||||
|
||||
|
||||
# ------------------------------------------------------- the reported defect
|
||||
def _quiet_until_zeroed(
|
||||
presentation: ForcePresentation,
|
||||
bias: tuple[float, float, float],
|
||||
*,
|
||||
unit: str,
|
||||
limit: int = 29,
|
||||
) -> int:
|
||||
"""Feed quiet samples until ``accept`` reads zero; return the sample count.
|
||||
|
||||
Returns ``limit + 1`` (i.e. "never within the limit") if it did not zero.
|
||||
"""
|
||||
for index in range(limit):
|
||||
presentation.observe(bias, t_rel_s=index / 30.0, flow_p95=0.1, unit=unit)
|
||||
if presentation.accept(bias, unit=unit).values == (0.0, 0.0, 0.0):
|
||||
return index + 1
|
||||
return limit + 1
|
||||
|
||||
|
||||
def test_a_resting_raw_channel_reads_zero_from_the_first_samples() -> None:
|
||||
"""FN 1118 / FX 34.16 / FY -186.1 must be zero long before 30 samples / 3 s.
|
||||
|
||||
This is the reported defect: the cards kept the device's raw bias for the
|
||||
whole startup window, so a session that started at rest looked like it was
|
||||
under load.
|
||||
"""
|
||||
presentation = ForcePresentation(enabled=True, min_samples=30, baseline_seconds=3.0)
|
||||
zeroed_at = _quiet_until_zeroed(presentation, REPORTED_RAW_BIAS, unit="raw")
|
||||
|
||||
assert zeroed_at <= 3, f"the raw bias was still on the cards after {zeroed_at} samples"
|
||||
assert not presentation.ready, "zeroing should not have needed the whole quiet window"
|
||||
assert presentation.display_ready, "the display policy was not applied while collecting"
|
||||
assert presentation.display_quality == "provisional"
|
||||
# ...and no invented zero: the offset is the one the quiet samples measured.
|
||||
offsets = presentation.last_estimate.offsets
|
||||
assert offsets[0] == REPORTED_RAW_BIAS[0]
|
||||
assert offsets[1] == REPORTED_RAW_BIAS[1]
|
||||
assert offsets[2] == REPORTED_RAW_BIAS[2]
|
||||
|
||||
|
||||
def test_a_resting_newton_channel_reads_zero_from_the_first_samples() -> None:
|
||||
"""The same aid in newtons — the estimate is per display unit."""
|
||||
presentation = ForcePresentation(
|
||||
enabled=True, unit="N", min_samples=30, baseline_seconds=3.0
|
||||
)
|
||||
zeroed_at = _quiet_until_zeroed(presentation, REPORTED_NEWTON_BIAS, unit="N")
|
||||
|
||||
assert zeroed_at <= 3, f"the newton bias survived {zeroed_at} samples"
|
||||
assert not presentation.ready
|
||||
assert presentation.display_quality == "provisional"
|
||||
|
||||
|
||||
def test_a_noisy_channel_is_zeroed_from_the_first_samples_too() -> None:
|
||||
"""The provisional band covers the samples it was fitted from.
|
||||
|
||||
With a handful of samples the p99 residual is essentially the largest one, so
|
||||
the newest sample of the window is inside the band: the first tenths of a
|
||||
second are zeroed, and the *first* sample is not claimed to be zero because
|
||||
nothing has been estimated from it yet.
|
||||
"""
|
||||
rng = np.random.default_rng(11)
|
||||
bias = np.array(REPORTED_RAW_BIAS)
|
||||
presentation = ForcePresentation(enabled=True, min_samples=30, baseline_seconds=3.0)
|
||||
for index in range(8):
|
||||
sample = bias + rng.normal(0.0, 30.0, size=3)
|
||||
presentation.observe(tuple(sample), t_rel_s=index / 30.0, flow_p95=0.1)
|
||||
if index < 2:
|
||||
# Nothing has been estimated from two samples: those frames honestly
|
||||
# show the measurement (and are asserted to do so elsewhere).
|
||||
continue
|
||||
displayed = presentation.accept(tuple(sample), unit="raw")
|
||||
assert displayed.values == (0.0, 0.0, 0.0), (
|
||||
f"sample {index} of a resting noisy channel was drawn"
|
||||
)
|
||||
|
||||
|
||||
def test_a_contact_present_at_session_start_is_never_zeroed() -> None:
|
||||
"""A press at run start must stay visible: no estimate may be invented for it.
|
||||
|
||||
The contact guard decides what feeds the estimate, so a window that has only
|
||||
ever seen a contact has nothing to subtract and displays the measurement.
|
||||
"""
|
||||
presentation = ForcePresentation(enabled=True, min_samples=30, baseline_seconds=3.0)
|
||||
pressed = (6000.0, 40.0, -180.0)
|
||||
for index in range(20):
|
||||
assert (
|
||||
presentation.observe(
|
||||
pressed, t_rel_s=index / 30.0, contact_found=True, flow_p95=0.1
|
||||
)
|
||||
is None
|
||||
)
|
||||
displayed = presentation.accept(pressed, unit="raw")
|
||||
assert displayed.values[0] == pressed[0], "a contact at startup was zeroed away"
|
||||
assert not displayed.suppressed[0]
|
||||
assert not presentation.display_ready
|
||||
assert presentation.display_quality == "none"
|
||||
|
||||
|
||||
def test_a_contaminated_window_never_falls_back_to_the_raw_bias() -> None:
|
||||
"""A window that closes badly keeps the estimate it already worked out.
|
||||
|
||||
Falling back to the raw offset is the reported defect. Both ways a startup
|
||||
window can be spoiled — a contact, or too much flow — must leave the display
|
||||
zeroed and say so through ``display_quality``.
|
||||
"""
|
||||
bias = REPORTED_RAW_BIAS
|
||||
pressed = (6000.0, 40.0, -180.0)
|
||||
for spoiled_by in ("contact", "flow"):
|
||||
presentation = ForcePresentation(
|
||||
enabled=True, min_samples=30, baseline_seconds=3.0
|
||||
)
|
||||
for index in range(3):
|
||||
presentation.observe(bias, t_rel_s=index / 30.0, flow_p95=0.1)
|
||||
assert presentation.display_ready and not presentation.ready
|
||||
|
||||
for index in range(90): # the window is spoiled before it can close
|
||||
presentation.observe(
|
||||
pressed,
|
||||
t_rel_s=0.5 + index / 30.0,
|
||||
contact_found=(spoiled_by == "contact"),
|
||||
flow_p95=0.9 if spoiled_by == "flow" else 0.1,
|
||||
)
|
||||
estimate = presentation.observe(bias, t_rel_s=4.0, flow_p95=0.1)
|
||||
assert estimate is not None, spoiled_by
|
||||
assert estimate.quality == "contaminated", (spoiled_by, estimate)
|
||||
assert not presentation.ready, spoiled_by
|
||||
assert presentation.display_quality == "contaminated", spoiled_by
|
||||
assert presentation.accept(bias, unit="raw").values == (0.0, 0.0, 0.0), spoiled_by
|
||||
|
||||
|
||||
def test_a_window_that_closed_short_still_applies_what_it_learned() -> None:
|
||||
"""A session shorter than the quiet window is not a reason to show the bias."""
|
||||
presentation = ForcePresentation(enabled=True, min_samples=30, baseline_seconds=0.5)
|
||||
for index in range(3):
|
||||
presentation.observe(REPORTED_RAW_BIAS, t_rel_s=index / 30.0, flow_p95=0.1)
|
||||
|
||||
estimate = presentation.observe(REPORTED_RAW_BIAS, t_rel_s=0.6, flow_p95=0.1)
|
||||
assert estimate is not None and estimate.quality == "insufficient"
|
||||
assert presentation.display_quality == "insufficient"
|
||||
assert presentation.accept(REPORTED_RAW_BIAS, unit="raw").values == (0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
def test_a_frozen_window_stops_chasing_the_data() -> None:
|
||||
"""The provisional phase is a start, not a licence to re-zero a press."""
|
||||
presentation = ForcePresentation(
|
||||
enabled=True, min_samples=5, baseline_seconds=3.0, min_deadband_raw=50.0
|
||||
)
|
||||
for index in range(10):
|
||||
presentation.observe(REPORTED_RAW_BIAS, t_rel_s=index / 30.0, flow_p95=0.1)
|
||||
assert presentation.ready
|
||||
assert presentation.display_quality == "ok"
|
||||
# A held press after the window is a reading, not a new zero.
|
||||
assert presentation.accept((5000.0, 34.16, -186.1), unit="raw").values[0] > 3000.0
|
||||
|
||||
|
||||
def test_the_cards_and_the_curves_are_the_same_presentation() -> None:
|
||||
"""What ``accept`` puts on a card is the row ``present`` draws on its curve.
|
||||
|
||||
This is what the extra "the estimate is ready" gate in the value cards broke:
|
||||
the card kept the raw measurement while the curve was already drawn from the
|
||||
applied estimate, so the two disagreed about the same sample.
|
||||
"""
|
||||
bias = REPORTED_RAW_BIAS
|
||||
#: The samples the acquisition would flag as a contact: they must not feed
|
||||
#: the idle estimate, or the display would zero the press away.
|
||||
presses = {
|
||||
3: (bias[0] + 4000.0, bias[1] + 10.0, bias[2] - 10.0),
|
||||
5: (bias[0] - 4000.0, bias[1], bias[2]),
|
||||
}
|
||||
stream = [
|
||||
bias,
|
||||
bias,
|
||||
bias,
|
||||
presses[3],
|
||||
bias,
|
||||
presses[5],
|
||||
bias,
|
||||
]
|
||||
presentation = ForcePresentation(enabled=True, min_samples=30, baseline_seconds=3.0)
|
||||
for index, sample in enumerate(stream):
|
||||
presentation.observe(
|
||||
sample,
|
||||
t_rel_s=index / 30.0,
|
||||
flow_p95=0.1,
|
||||
contact_found=index in presses,
|
||||
)
|
||||
card = presentation.accept(sample, unit="raw")
|
||||
curve, suppressed = presentation.present([sample], unit="raw")
|
||||
assert card.values == tuple(float(value) for value in curve[0]), index
|
||||
assert card.suppressed == tuple(bool(flag) for flag in suppressed[0]), index
|
||||
# The assertion is not vacuous: the excursion really is on both.
|
||||
assert presentation.accept(stream[3], unit="raw").values[0] > 3000.0
|
||||
|
||||
|
||||
def test_present_uses_the_provisional_estimate_before_the_window_closes() -> None:
|
||||
"""The curves get the same early zero as the cards, not only after 3 s."""
|
||||
presentation = ForcePresentation(enabled=True, min_samples=30, baseline_seconds=3.0)
|
||||
block = np.asarray([REPORTED_RAW_BIAS] * 4, dtype=float)
|
||||
raw, _suppressed = presentation.present(block, unit="raw")
|
||||
assert raw[0].tolist() == list(REPORTED_RAW_BIAS), "nothing was learned yet"
|
||||
|
||||
for index in range(3):
|
||||
presentation.observe(REPORTED_RAW_BIAS, t_rel_s=index / 30.0, flow_p95=0.1)
|
||||
displayed, suppressed = presentation.present(block, unit="raw")
|
||||
assert displayed.tolist() == [[0.0, 0.0, 0.0]] * 4
|
||||
assert suppressed.all()
|
||||
assert not presentation.ready, "the whole quiet window was not needed"
|
||||
def test_precision_is_per_unit_and_does_not_change_threshold():
|
||||
policy = ForcePresentation(min_deadband_raw=50.0, min_deadband_n=0.5)
|
||||
assert policy.format_force(50.6, "raw") == "51"
|
||||
assert policy.format_force(0.506, "N") == "0.51"
|
||||
assert policy.format_force(-0.004, "N") == "0.00"
|
||||
assert policy.format_force(float("nan"), "raw") == "—"
|
||||
policy.raw_decimals = 1
|
||||
assert policy.format_force(50.6, "raw") == "50.6"
|
||||
|
||||
@@ -16,7 +16,7 @@ import numpy as np
|
||||
import pytest
|
||||
from orisys_app_shared.acquisition.sdk_worker import SdkPacket
|
||||
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
|
||||
from orisys_app_shared.record.sample import STALE_REASON, to_record_sample
|
||||
from orisys_app_shared.record.sample import STALE_REASON, process_record_sample, to_record_sample
|
||||
|
||||
SCRATCH = Path(__file__).resolve().parent / "_scratch"
|
||||
|
||||
@@ -73,6 +73,29 @@ def test_uncalibrated_sensor_writes_no_newton_values():
|
||||
assert rows[0]["FY_N"] == ""
|
||||
|
||||
|
||||
def test_export_preserves_original_and_processed_columns(tmp_path):
|
||||
packet = _packet(
|
||||
0, force_calibrated=True, calibrated_axes=("fn", "fx", "fy"),
|
||||
normal_force_raw=49.0, shear_x_raw=-51.0, shear_y_raw=20.0,
|
||||
normal_force=0.4, shear_x=-0.6, shear_y=0.1,
|
||||
)
|
||||
sample = process_record_sample(
|
||||
to_record_sample(packet), raw_threshold=50.0, newton_threshold=0.5
|
||||
)
|
||||
recorder = ExperimentRecorder(RecordConfig(video=False))
|
||||
directory = recorder.start(root=tmp_path, session_id=1, metadata={})
|
||||
recorder.on_packet(packet, sample=sample)
|
||||
recorder.stop()
|
||||
with (directory / "forces.csv").open(encoding="utf-8", newline="") as handle:
|
||||
row = next(csv.DictReader(handle))
|
||||
assert row["FN_raw"] == "49.0"
|
||||
assert row["FN_output_raw"] == "0.0"
|
||||
assert row["FX_raw"] == "-51.0"
|
||||
assert row["FX_output_raw"] == "-51.0"
|
||||
assert row["FN_N"] == "0.4"
|
||||
assert row["FN_output_N"] == "0.0"
|
||||
|
||||
|
||||
def test_partially_calibrated_sensor_fills_only_the_calibrated_axes():
|
||||
rows = _record(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user