统一力值阈值处理并保留原始采样

移除自动显示基线,使逐轴阈值统一作用于曲线、读数与记录输出,标定拟合继续使用原始采样。
共享层全量测试和相关 Studio 测试通过。
This commit is contained in:
ykcheungab
2026-10-07 12:25:12 +08:00
parent 6194778a6f
commit 86996694b4
27 changed files with 507 additions and 1512 deletions
@@ -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}"
+56 -13
View File
@@ -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,
+3 -3
View File
@@ -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,
+3 -2
View File
@@ -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:
+4 -5
View File
@@ -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),
)
)
+10 -1
View File
@@ -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
+40 -1
View File
@@ -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))