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

移除自动显示基线,使逐轴阈值统一作用于曲线、读数与记录输出,标定拟合继续使用原始采样。
共享层全量测试和相关 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
+26 -7
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import numpy as np
from orisys_app_shared.force.presentation import threshold_force
from orisys_app_shared.force.calibration_io import (
ChannelKey,
)
@@ -77,6 +78,8 @@ class CalPanel(QFrame):
self._live_fn = 0.0
self._live_fx = 0.0
self._live_fy = 0.0
self._raw_threshold = 50.0
self._raw_decimals = 0
self._sensor_type = "box"
self._config_ref: str | None = None
self._mono = _mono_font()
@@ -305,6 +308,12 @@ class CalPanel(QFrame):
self._live_fy = float(fy)
self._render_live_label()
def set_output_settings(self, *, raw_threshold: float, raw_decimals: int) -> None:
self._raw_threshold = max(0.0, float(raw_threshold))
self._raw_decimals = max(0, int(raw_decimals))
self._render_live_label()
self._refresh_table()
def set_progress(self, fraction: float, elapsed_s: float, total_s: float) -> None:
self.progress.setVisible(True)
self.progress.setValue(max(0, min(1000, int(fraction * 1000))))
@@ -435,9 +444,10 @@ class CalPanel(QFrame):
("fx", self._live_fx),
("fy", self._live_fy),
):
rendered = f"{value:.{self._raw_decimals}f}" if np.isfinite(value) else "—"
parts.append(
f"<span style='color:{colors[key]}; font-weight:600'>{key.upper()}</span>"
f"&nbsp;{value:.3f}"
f"&nbsp;{rendered}"
)
self.live_label.setText(
f"<span style='color:{color(self._theme, 'text_secondary')}'>"
@@ -529,15 +539,21 @@ class CalPanel(QFrame):
self.empty_hint.setVisible(not points)
self._refresh_fit_label()
@staticmethod
def _axis_cell(point, channel: str) -> str:
value = point.axis_mean(channel)
def _axis_cell(self, point, channel: str) -> str:
column = {"fn": 2, "fx": 3, "fy": 4}[channel]
values = [
threshold_force(sample[column], self._raw_threshold)
for sample in point.samples if bool(sample[8]) and np.isfinite(sample[column])
]
value = float(np.mean(values)) if values else threshold_force(
point.axis_mean(channel), self._raw_threshold
)
if not np.isfinite(value):
return "—"
if channel == point.channel and np.isfinite(point.residual_n):
# Residual of the fit on the same cell as the value it came from.
return f"{value:.4f} ({point.residual_n:+.4f})"
return f"{value:.4f}"
return f"{value:.{self._raw_decimals}f} ({point.residual_n:+.4f})"
return f"{value:.{self._raw_decimals}f}"
@staticmethod
def _stability_cell(point) -> str:
@@ -598,7 +614,10 @@ class CalPanel(QFrame):
restore_diagnostics(point)
if not point.trace:
return None
return CalibrationPointDialog(point, theme=self._theme, parent=self)
return CalibrationPointDialog(
point, theme=self._theme, parent=self,
raw_threshold=self._raw_threshold, raw_decimals=self._raw_decimals,
)
def _on_row_activated(self) -> None:
"""Double click a row to inspect that point's three-axis curve."""
@@ -7,6 +7,7 @@ the three-panel layout, while a single point can still be examined in full.
from __future__ import annotations
import numpy as np
from orisys_app_shared.force.presentation import threshold_force, threshold_values
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
from PySide6.QtGui import QFont, QFontDatabase
@@ -40,10 +41,15 @@ def _mono_font(point_size: int = 9) -> QFont:
class CalibrationPointDialog(QDialog):
"""One recorded point: all three raw traces plus its fit residual."""
def __init__(self, point, *, theme: str = "dark", parent=None) -> None:
def __init__(
self, point, *, theme: str = "dark", parent=None,
raw_threshold: float = 0.0, raw_decimals: int = 0,
) -> None:
super().__init__(parent)
self._point = point
self._theme = theme
self._raw_threshold = max(0.0, float(raw_threshold))
self._raw_decimals = max(0, int(raw_decimals))
self.setWindowTitle(
t(
"cal.point_title",
@@ -82,7 +88,15 @@ class CalibrationPointDialog(QDialog):
if axis is None or not np.isfinite(axis.mean):
stats.append(f"{key.upper()} —")
continue
stats.append(f"{key.upper()} {axis.mean:.4f} ± {axis.std:.4f}")
values = np.asarray(point.trace.get(key, ()), dtype=float)
if values.size:
values, _ = threshold_values(values, self._raw_threshold)
stats.append(
f"{key.upper()} {np.nanmean(values):.{self._raw_decimals}f}"
f" ± {np.nanstd(values):.{self._raw_decimals}f}"
)
else:
stats.append(f"{key.upper()} {threshold_force(axis.mean, self._raw_threshold):.{self._raw_decimals}f}")
value = QLabel(" ".join(stats))
value.setFont(_mono_font())
self._add_row(form, "cal.point_axes", "")
@@ -137,8 +151,10 @@ class CalibrationPointDialog(QDialog):
continue
step = self._point.trace_dt_s or 0.01
times = np.arange(len(values), dtype=float) * step
values = np.asarray(values, dtype=float)
values, _ = threshold_values(values, self._raw_threshold)
plot.plot(
times,
np.asarray(values, dtype=float),
values,
pen=pg.mkPen(color(self._theme, TRACE_TOKENS[row]), width=2),
)
+51 -124
View File
@@ -10,8 +10,6 @@ from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
from orisys_app_shared.force import MetricHistory
from orisys_app_shared.force.history import DEFAULT_MAX_FPS
from orisys_app_shared.force.presentation import (
DEFAULT_MIN_DEADBAND_N,
DEFAULT_MIN_DEADBAND_RAW,
ForcePresentation,
)
from orisys_app_shared.force.source import StoreCurveSource
@@ -108,8 +106,8 @@ def format_force(
if presentation is not None:
return presentation.format_force(value, unit=unit)
if unit == "N":
return f"{value:.3f}"
return f"{value:.1f}"
return f"{value:.2f}"
return f"{value:.0f}"
def unit_display(unit: str) -> str:
@@ -176,13 +174,10 @@ class ForcePlotsPanel(QFrame):
self._last_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
#: Per axis: does ``_last_values`` hold a real value in the active unit?
self._available: tuple[bool, bool, bool] = (True, True, True)
#: Display-only baseline / deadband for the value cards *and* the curves.
self.presentation = ForcePresentation(enabled=False)
#: The operator's master switch for that baseline. It is owned by
#: ``display_prefs`` (the ``raw_auto_zero`` preference) and kept here so a
#: caller that does not know it cannot switch the aid off (see
#: ``configure_presentation``).
self._presentation_enabled = False
#: Shared absolute threshold and numeric precision for values and curves.
self.presentation = ForcePresentation(enabled=True)
#: Kept for callers that explicitly configure presentation state.
self._presentation_enabled = True
self._sensor_type = "box"
self._display_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
self._display_suppressed: tuple[bool, bool, bool] = (False, False, False)
@@ -193,6 +188,7 @@ class ForcePlotsPanel(QFrame):
#: True once a source is attached (either kind). ``_store is None`` was
#: the old proxy for this and could not express a disk source.
self._source_ready = False
self._source_values_processed = False
#: Non-empty when there is no trace to draw *and that is a fact about the input*:
#: a bare video whose pass has not run, or a profile whose chain produced no force
#: for it. The mode line states it rather than leaving an empty plot under wording
@@ -557,31 +553,24 @@ class ForcePlotsPanel(QFrame):
self._apply_axis_mode()
def set_deadband_floors(self, *, newton: float | None = None, raw: float | None = None) -> None:
"""The operator's display deadband floors; 0 means "auto (estimated)".
A floor, not a replacement: the estimate from the quiet window still
applies underneath, so this is how an operator widens the zero zone (or
pins one when the calibration data is suspect) without re-fitting.
"""
"""Set absolute per-unit output thresholds; zero disables suppression."""
if newton is not None:
value = max(0.0, float(newton))
self.presentation.set_min_deadband(
"N", value if value > 0.0 else DEFAULT_MIN_DEADBAND_N
)
self.presentation.set_min_deadband("N", value)
if raw is not None:
value = max(0.0, float(raw))
self.presentation.set_min_deadband(
"raw", value if value > 0.0 else DEFAULT_MIN_DEADBAND_RAW
)
self.presentation.set_min_deadband("raw", value)
self._render_value_cards()
def set_value_decimals(self, *, newton: int, raw: int) -> None:
self.presentation.newton_decimals = max(0, int(newton))
self.presentation.raw_decimals = max(0, int(raw))
self._render_value_cards()
def set_presentation_enabled(self, enabled: bool) -> None:
"""The operator's master switch for the display baseline and deadband."""
"""Enable or disable thresholding for a standalone chart source."""
self._presentation_enabled = bool(enabled)
self.presentation.set_enabled(self._presentation_enabled, unit=self._unit)
# Switching the aid off must show the measurement again, and switching it
# on must show the policy — from the same values the curve is drawn with,
# or a card and its own curve would disagree for a frame.
self._refresh_display_values()
self._render_value_cards()
@@ -590,13 +579,7 @@ class ForcePlotsPanel(QFrame):
self._apply_axis_mode()
def reset(self) -> None:
"""Back to the view defaults: empty curves, follow mode, unit ``raw``.
The display baseline is deliberately *kept*: a view reset is not a
statement about the device, and the operator's zero estimate (or its
absence) survives it. A new session restarts the estimate through
``configure_presentation``.
"""
"""Restore view defaults while retaining the configured threshold."""
self.history.reset()
self._clear_curves()
self._last_values = (0.0, 0.0, 0.0)
@@ -626,57 +609,13 @@ class ForcePlotsPanel(QFrame):
unit: str,
enabled: bool | None = None,
) -> None:
"""Point the display baseline at the sensor and unit now being shown.
The aid is for every sensor type and both units — an idle channel that
reads a raw count or an offset in newtons is a display problem whichever
device produced it — so the sensor type only names what is drawn.
``enabled`` sets the master switch directly when given; the operator's
``raw_auto_zero`` preference is what the run-start call site passes.
Omitting it leaves the current switch alone.
"""
"""Configure the chart's unit and optional threshold switch."""
self._sensor_type = str(sensor_type or "box")
if enabled is not None:
self._presentation_enabled = bool(enabled)
self.presentation.enabled = bool(self._presentation_enabled)
self.presentation.reset_session(unit=unit)
def request_presentation_reestimate(self) -> None:
"""Restart the idle baseline window (display only: cards and curves)."""
self.presentation.request_reestimate()
# A restarted window starts from nothing, so the cards go back to the
# measurement for the couple of samples it takes to re-learn the offset
# — derived by the same call the curve uses, never left stale.
self._refresh_display_values()
self._render_value_cards()
def observe_for_baseline(
self,
values: tuple[float, float, float],
*,
t_rel_s: float,
valid: bool = True,
flow_p95: float | None = None,
contact_found: bool = False,
unit: str | None = None,
) -> None:
"""Feed the startup estimator in the unit these values are expressed in.
The curve and the record keep the raw measurement; the estimate is only
ever a display copy.
"""
if not self.presentation.enabled:
return
self.presentation.observe(
values,
t_rel_s=t_rel_s,
valid=valid,
flow_p95=flow_p95,
contact_found=contact_found,
unit=unit,
)
def push(
self,
t_rel_s: float,
@@ -712,16 +651,6 @@ class ForcePlotsPanel(QFrame):
self._last_values = (float(fn), float(fx), float(fy))
if available is not None:
self._available = tuple(bool(flag) for flag in available)
self.observe_for_baseline(
self._last_values,
t_rel_s=float(t_rel_s),
valid=bool(forces_valid),
flow_p95=flow_p95,
contact_found=contact_found,
# The values are in ``unit``, and the estimate is kept per unit, so
# the sample must be filed under the unit it was measured in.
unit=unit,
)
displayed = self.presentation.accept(
self._last_values, unit=unit, available=self._available
)
@@ -816,8 +745,6 @@ class ForcePlotsPanel(QFrame):
if unit == self._unit:
return
self._unit = unit
# The display policy keeps one estimate per unit, so it has to know which
# one is on screen: a stale "raw" is ready does not mean newtons are.
self.presentation.unit = unit
# ``clear`` keeps the buffer's time origin on purpose: the rebuilt history
# is in the same session-relative base, so the X axis must not restart.
@@ -852,6 +779,7 @@ class ForcePlotsPanel(QFrame):
self._live_store = store
self._source = StoreCurveSource(store)
self._source_ready = store is not None
self._source_values_processed = True
self._source_absent_reason = ""
self._on_source_changed()
@@ -861,6 +789,13 @@ class ForcePlotsPanel(QFrame):
self._source = RecordingCurveSource(reader)
self._source_ready = True
columns = getattr(reader.index(), "_columns", {})
self._source_values_processed = all(
name in columns for name in (
"FN_output_raw", "FX_output_raw", "FY_output_raw",
"FN_output_N", "FX_output_N", "FY_output_N",
)
)
self._source_absent_reason = ""
self._on_source_changed()
@@ -876,6 +811,7 @@ class ForcePlotsPanel(QFrame):
``source is None`` with a ``reason`` is a state, not an empty chart: the mode line
states that reason instead of leaving "following live" over a flat plot.
"""
self._source_values_processed = False
if source is None:
self._source = StoreCurveSource(self._live_store)
self._source_ready = False
@@ -1149,8 +1085,10 @@ class ForcePlotsPanel(QFrame):
# The cards describe this point, so they carry the display copy of it —
# including while browsing or replaying, where the values did not come
# from the live sample the last ``push`` presented.
presented, suppressed = self.presentation.present(
np.asarray([values], dtype=float), unit=self._unit
presented, suppressed = (
(np.asarray([values], dtype=float), np.zeros((1, 3), dtype=bool))
if self._source_values_processed
else self.presentation.present(np.asarray([values], dtype=float), unit=self._unit)
)
self._display_values = tuple(float(value) for value in presented[0])
self._display_suppressed = tuple(bool(flag) for flag in suppressed[0])
@@ -1248,9 +1186,7 @@ class ForcePlotsPanel(QFrame):
buckets=self._overview_points(), unit=self._unit
)
values = np.asarray(window.values, dtype=float)
# The envelope is drawn from the same store measurement as the window
# above, so it goes through the same presentation: the overview thumb
# must not show a jittering baseline the main chart has flattened.
# The overview uses the same processed values as the main curves.
values = self._curve_values(values)
column = values[:, 0]
finite = np.isfinite(column)
@@ -1397,7 +1333,7 @@ class ForcePlotsPanel(QFrame):
Raw mode used to auto-range, which collapsed the axis onto the idle
jitter and then rescaled on every contact. A fixed raw range keeps the
baseline visually flat: the raw maxima are sized so the idle jitter is
near-zero region visually flat: the raw maxima are sized so idle jitter is
well under one pixel.
"""
if self._unit == "N":
@@ -1460,29 +1396,22 @@ class ForcePlotsPanel(QFrame):
zero_line.setVisible(bool(ymin <= 0.0 <= ymax))
def _curve_values(self, values) -> np.ndarray:
"""The values the curves draw: the measurement through the display policy.
``history``, the session store and ``forces.csv`` keep the measurement;
the baseline and deadband are applied here, on the way to the plot only.
That is what makes a resting channel a flat line at exactly zero without
the record being edited. Browsing and a replay read the store, so they go
through this same presentation — the curves show the operator's display
policy, the store shows what was measured.
"""
displayed, _suppressed = self.presentation.present(values, unit=self._unit)
"""Use stored output values, or threshold a legacy unprocessed source."""
displayed, _suppressed = (
(np.asarray(values, dtype=float), None)
if self._source_values_processed
else self.presentation.present(values, unit=self._unit)
)
return displayed
def _refresh_display_values(self) -> None:
"""Re-derive the card values from the policy the curves are drawn with.
``push`` fills these from :meth:`ForcePresentation.accept`, whose
hysteresis is what keeps a *live* reading steady. Every other path — a
re-estimate, the master switch, a unit switch — goes through the same
stateless :meth:`ForcePresentation.present` the curve uses, so a card can
never state a value its own curve does not carry.
"""
displayed, suppressed = self.presentation.present(
np.asarray([self._last_values], dtype=float), unit=self._unit
"""Refresh cards from the same values that the curves draw."""
displayed, suppressed = (
(np.asarray([self._last_values], dtype=float), np.zeros((1, 3), dtype=bool))
if self._source_values_processed
else self.presentation.present(
np.asarray([self._last_values], dtype=float), unit=self._unit
)
)
self._display_values = tuple(float(value) for value in displayed[0])
self._display_suppressed = tuple(bool(flag) for flag in suppressed[0])
@@ -1499,7 +1428,11 @@ class ForcePlotsPanel(QFrame):
a single long line under the charts, which could not fit and pushed the
panel wider.
"""
fmt = "{value:.3f}" if self._unit == "N" else "{value:.1f}"
decimals = (
self.presentation.newton_decimals
if self._unit == "N" else self.presentation.raw_decimals
)
fmt = "{value:." + str(decimals) + "f}"
unavailable = t("chart.value_unavailable")
selecting = self._selection
for index, (value_label, unit_label, detail_label, value) in enumerate(
@@ -1516,12 +1449,6 @@ class ForcePlotsPanel(QFrame):
available = bool(np.isfinite(peak))
value = peak
elif self.presentation.enabled:
# The policy is *live from the first samples*: there is no
# separate "the estimate is ready" gate here. Such a gate is what
# let a card read FN 1118 raw while its own curve was already flat
# on zero, because the card fell back to the raw measurement and
# the curve did not. Both are ``_display_values`` / ``present()``
# of the same applied estimate.
value = self._display_values[index]
if not available:
# An axis with no curve in this unit: a dash, never a raw count
@@ -48,12 +48,11 @@ DEFAULT_PLOT_SHEAR_MAX = 20.0
DEFAULT_PLOT_FN_RAW_MIN = 0.0
DEFAULT_PLOT_FN_RAW_MAX = 1000000.0
DEFAULT_PLOT_SHEAR_RAW_MAX = 500000.0
#: Display deadband floors ("归零阈值") in the unit each applies to. 0 means
#: "auto": the percentile the presentation layer estimates from the quiet
#: startup window. Non-zero forces a floor under that estimate, so an operator
#: can widen the zero zone (or pin one when calibration data is suspect).
#: Absolute output thresholds in each unit; zero disables suppression.
DEFAULT_PLOT_DEADBAND_N = 0.0
DEFAULT_PLOT_DEADBAND_RAW = 0.0
DEFAULT_PLOT_DEADBAND_RAW = 50.0
DEFAULT_PLOT_NEWTON_DECIMALS = 2
DEFAULT_PLOT_RAW_DECIMALS = 0
class PlotSettingsDialog(QDialog):
@@ -70,6 +69,8 @@ class PlotSettingsDialog(QDialog):
shear_raw_max: float = DEFAULT_PLOT_SHEAR_RAW_MAX,
deadband_n: float = DEFAULT_PLOT_DEADBAND_N,
deadband_raw: float = DEFAULT_PLOT_DEADBAND_RAW,
newton_decimals: int = DEFAULT_PLOT_NEWTON_DECIMALS,
raw_decimals: int = DEFAULT_PLOT_RAW_DECIMALS,
units: str = "newton",
filters: FilterSettings | None = None,
filters_locked: bool = False,
@@ -160,6 +161,17 @@ class PlotSettingsDialog(QDialog):
self.deadband_raw_spin.setSingleStep(5000.0)
self.deadband_raw_spin.setDecimals(0)
self.deadband_raw_spin.setValue(float(deadband_raw))
self.newton_decimals_spin = QSpinBox()
self.newton_decimals_spin.setRange(0, 6)
self.newton_decimals_spin.setValue(int(newton_decimals))
self.raw_decimals_spin = QSpinBox()
self.raw_decimals_spin.setRange(0, 6)
self.raw_decimals_spin.setValue(int(raw_decimals))
precision_row = QHBoxLayout()
precision_row.addWidget(QLabel("N"))
precision_row.addWidget(self.newton_decimals_spin)
precision_row.addWidget(QLabel("raw"))
precision_row.addWidget(self.raw_decimals_spin)
# How the finger's 2D canvas is turned for display. A *presentation* choice, so
# it lives with the other display ones (this dialog), not with the acquisition
@@ -186,6 +198,8 @@ class PlotSettingsDialog(QDialog):
form.addRow(self.shear_raw_max_label, self.shear_raw_max_spin)
form.addRow(self.deadband_n_label, self.deadband_n_spin)
form.addRow(self.deadband_raw_label, self.deadband_raw_spin)
self.precision_label = QLabel()
form.addRow(self.precision_label, precision_row)
root.addLayout(form)
self.orientation_group = QGroupBox()
@@ -438,7 +452,7 @@ class PlotSettingsDialog(QDialog):
}
def deadbands(self) -> dict[str, float]:
"""The per-unit display deadband floors. 0 means "auto" (estimated)."""
"""The per-unit absolute output thresholds."""
return {
"deadband_n": float(self.deadband_n_spin.value()),
"deadband_raw": float(self.deadband_raw_spin.value()),
@@ -446,7 +460,11 @@ class PlotSettingsDialog(QDialog):
def settings(self) -> dict[str, float]:
"""Everything the plot half of the dialog decides, in one mapping."""
return {**self.ranges(), **self.deadbands()}
return {
**self.ranges(), **self.deadbands(),
"newton_decimals": int(self.newton_decimals_spin.value()),
"raw_decimals": int(self.raw_decimals_spin.value()),
}
def orientation(self) -> str:
"""The 2D presentation the dialog is showing (see ``display_orientation``)."""
@@ -464,10 +482,10 @@ class PlotSettingsDialog(QDialog):
self.orientation_combo.setCurrentIndex(
max(self.orientation_combo.findData(DEFAULT_FINGER_ORIENTATION), 0)
)
# "Auto" is the shipped answer for the deadband: the estimate from the
# quiet startup window, with the layer's own floor under it.
self.deadband_n_spin.setValue(DEFAULT_PLOT_DEADBAND_N)
self.deadband_raw_spin.setValue(DEFAULT_PLOT_DEADBAND_RAW)
self.newton_decimals_spin.setValue(DEFAULT_PLOT_NEWTON_DECIMALS)
self.raw_decimals_spin.setValue(DEFAULT_PLOT_RAW_DECIMALS)
# "Reset" means the shipped defaults, and those filter nothing — the same
# answer the dialog gives for a preference file that says nothing.
self.flow_ema_spin.setValue(1.0)
@@ -498,6 +516,7 @@ class PlotSettingsDialog(QDialog):
self.deadband_n_spin.setToolTip(t("dialog.plot_deadband_n_tip"))
self.deadband_raw_label.setText(t("dialog.plot_deadband_raw"))
self.deadband_raw_spin.setToolTip(t("dialog.plot_deadband_raw_tip"))
self.precision_label.setText(t("dialog.plot_value_decimals"))
self.btn_reset.setText(t("dialog.reset_defaults"))
self.orientation_group.setTitle(t("dialog.plot_orientation_group"))
self.orientation_label.setText(t("dialog.plot_orientation"))
+1 -2
View File
@@ -835,8 +835,7 @@ class ToolbarWidget(QWidget):
The operator's complaint was that 重置 read as "reset the charts" when it zeroes
the *sensor tracker* — and that in the replay view, where it is the display that
can be reset, it was unavailable. The label therefore names the target and the
tip states the side effect that is real but invisible: zeroing the tracker also
restarts the display baseline (the curve history is kept).
tip identifies the target so a tracker reset is not mistaken for a chart reset.
"""
if self.reset_target() == "display":
self.btn_reset.setText(t("toolbar.reset_display"))