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

移除自动显示基线,使逐轴阈值统一作用于曲线、读数与记录输出,标定拟合继续使用原始采样。
共享层全量测试和相关 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
@@ -11,18 +11,13 @@ of the 2D panel is decided here once (``replay_owns_image``) and nowhere else.
from __future__ import annotations
import logging
from typing import Any, Protocol
import numpy as np
from orisys_app_shared.force import select_display_forces
from orisys_app_shared.i18n import t
from orisys_app_shared.record.sample import to_record_sample
from orisys_app_shared.record.sample import process_record_sample, to_record_sample
from PySide6.QtCore import QObject
logger = logging.getLogger(__name__)
class AcquirePresentHost(Protocol):
"""What the presenter needs from the shell around a live packet."""
@@ -104,12 +99,9 @@ class AcquirePresenter(QObject):
if is_new and not display_paused:
self._last_plot_packet_key = packet_key
self._push_forces(packet)
self._cal_panel.set_live(
packet.normal_force_raw,
packet.shear_x_raw,
packet.shear_y_raw,
)
record = self._push_forces(packet)
if record is not None:
self._cal_panel.set_live(*record.processed_triplet("raw"))
# Finalise calibration sampling on the UI thread once its deadline passes.
self._cal_recorder.tick()
@@ -139,14 +131,19 @@ class AcquirePresenter(QObject):
self._host.refresh_status_rail()
self._host.refresh_record_state()
def _push_forces(self, packet: Any) -> None:
def _push_forces(self, packet: Any):
record = to_record_sample(packet)
if not record.valid:
self._host.session.last_sample_valid = False
self._host.show_status(
self._host.session.invalid_sample_message(record.invalid_reason)
)
return
return None
record = process_record_sample(
record,
raw_threshold=self._charts.presentation.min_deadband_for("raw"),
newton_threshold=self._charts.presentation.min_deadband_for("N"),
)
display = select_display_forces(
units_pref=self._host.toolbar.units(),
sample=record,
@@ -160,9 +157,6 @@ class AcquirePresenter(QObject):
self._host.session.last_sample_valid = True
self.last_display_fallback = bool(display.using_raw_fallback)
self.last_display_available = tuple(bool(flag) for flag in display.available)
flow_p95 = _flow_p95(packet)
contact = getattr(packet, "contact", None)
contact_found = bool(getattr(contact, "found", False)) if contact else False
self._charts.push(
self._host.session_time(packet),
display.values[0],
@@ -170,29 +164,6 @@ class AcquirePresenter(QObject):
display.values[2],
unit=display.unit,
available=display.available,
flow_p95=flow_p95,
contact_found=contact_found,
forces_valid=bool(getattr(packet, "forces_valid", True)),
)
def _flow_p95(packet: Any) -> float | None:
flow = getattr(packet, "flow", None)
if flow is None:
return None
try:
arr = np.asarray(flow, dtype=np.float64)
if arr.ndim != 3 or arr.shape[-1] != 2:
return None
mag = np.linalg.norm(arr, axis=-1)
mask = getattr(packet, "valid_mask", None)
if mask is not None:
m = np.asarray(mask, dtype=bool)
vals = mag[m & np.isfinite(mag)] if m.shape == mag.shape else mag[np.isfinite(mag)]
else:
vals = mag[np.isfinite(mag)]
if vals.size:
return float(np.percentile(vals, 95))
except Exception: # noqa: BLE001
logger.debug("flow p95 skipped", exc_info=True)
return None
return record
+8 -61
View File
@@ -592,7 +592,7 @@ class StudioMainWindow(QMainWindow):
self._render_run_image(packet)
def replay_owns_image(self) -> bool:
return self._replay_owns_image()
return self.replay_ctl.owns_image()
def refresh_record_state(self) -> None:
self._refresh_record_state()
@@ -937,30 +937,6 @@ class StudioMainWindow(QMainWindow):
"""The charts' shared cursor scrubs the recording (through the controller)."""
self.replay_ctl.on_cursor_moved(t_rel_s)
# -------------------------------------------------------- replay (forwards)
# The replay policy lives in ``ReplayController``. These forward so the call sites
# still in this file — and the tests / ``tools/ui_check`` / ``tools/ui_snapshot``,
# which drive the window — keep working while the rest of the split lands. Each is
# a one-line delegation; they are deleted as the call sites move to
# ``self.replay_ctl.*``.
def _load_recording(self, directory: Path) -> bool:
return self.replay_ctl.load_recording(directory)
def _load_replay_video(self, video: Path) -> bool:
return self.replay_ctl.load_video(video)
def _render_replay_frame(self) -> None:
self.replay_ctl.render_frame()
def _start_playback(self) -> None:
self.replay_ctl.start_playback()
def _on_play_shortcut(self) -> None:
self.replay_ctl.on_play_shortcut()
def _replay_owns_image(self) -> bool:
return self.replay_ctl.owns_image()
def _drop_render_marker(self) -> None:
"""A recorded frame was on the panel: the next live packet must repaint it."""
if self.acquire_presenter is not None:
@@ -1118,15 +1094,6 @@ class StudioMainWindow(QMainWindow):
)
self.view_menu.addAction(self.act_show_diagnostics)
self.act_raw_auto_zero = QAction(self)
self.act_raw_auto_zero.setCheckable(True)
self.act_raw_auto_zero.setChecked(bool(self.prefs.get("raw_auto_zero", True)))
self.act_raw_auto_zero.toggled.connect(self.display_ctl.on_raw_auto_zero_toggled)
self.view_menu.addAction(self.act_raw_auto_zero)
self.act_raw_reestimate = QAction(self)
self.act_raw_reestimate.triggered.connect(self.display_ctl.request_raw_reestimate)
self.view_menu.addAction(self.act_raw_reestimate)
self.act_settings = QAction(self)
self.act_settings.setCheckable(True)
@@ -1174,7 +1141,7 @@ class StudioMainWindow(QMainWindow):
self.view_menu.addAction(self.act_reset)
# The graphics half of the old 重置: curves, window, cursor and the 3D camera.
# Separate because wanting a fresh device baseline is not the same intention as
# Separate because resetting the device tracker is not the same intention as
# wanting the views back, and only the first one belongs to the device.
self.act_reset_view = QAction(self)
self.act_reset_view.setShortcut("F8")
@@ -1415,10 +1382,6 @@ class StudioMainWindow(QMainWindow):
self.act_scene3d_settings.setText(t("menu.scene3d_settings"))
self.act_show_fps.setText(t("menu.show_fps"))
self.act_show_diagnostics.setText(t("menu.show_diagnostics"))
self.act_raw_auto_zero.setText(t("menu.raw_auto_zero"))
self.act_raw_auto_zero.setToolTip(t("menu.raw_auto_zero_tip"))
self.act_raw_reestimate.setText(t("menu.raw_reestimate"))
self.act_raw_reestimate.setToolTip(t("menu.raw_reestimate_tip"))
self.act_settings.setText(t("menu.settings"))
self.act_plot_settings.setText(t("menu.plot_settings"))
self.act_rotate_arrows.setText(t("menu.rotate_arrows"))
@@ -1819,6 +1782,8 @@ class StudioMainWindow(QMainWindow):
# Display-only zeroing floors; 0 means "use the estimated band".
deadband_n=settings["deadband_n"],
deadband_raw=settings["deadband_raw"],
newton_decimals=int(settings["newton_decimals"]),
raw_decimals=int(settings["raw_decimals"]),
units=settings["units"],
filters=self._filter_settings(),
# Asked *now*: the answer is a property of this moment, and the
@@ -2241,7 +2206,7 @@ class StudioMainWindow(QMainWindow):
self.statusBar().showMessage(t("status.preview_stopped"))
def _on_preview_tick(self) -> None:
if self._preview is None or self._replay_owns_image():
if self._preview is None or self.replay_ctl.owns_image():
return
frame = self._preview.read()
if frame is None:
@@ -2321,7 +2286,7 @@ class StudioMainWindow(QMainWindow):
# both units, which is the point of it (an offset reads as an offset in newtons
# too). This used to be a ``finger and raw`` conjunction, which silently disabled
# it for every other product.
enabled=bool(self.prefs.get("raw_auto_zero", True)),
enabled=True,
)
if self.acquire_presenter is not None:
self.acquire_presenter.clear_plot_marker()
@@ -2457,21 +2422,7 @@ class StudioMainWindow(QMainWindow):
self.statusBar().showMessage(t("status.run_stopped"))
def _on_reset(self) -> None:
"""Zero the *sensor tracker* — or, where there is no device, reset the display.
This used to call ``force_plots.reset()`` as well, so wanting a fresh baseline
also threw away the history, the window and the cursor on screen — two different
operator intentions on one button (and only one of them recorded, see
``note_reset_applied``). The display baseline is re-estimated instead of wiped:
a new device zero means the old idle estimate is stale, not that the operator
wants to lose what they were looking at.
Which of the two this button means is the *view's* answer, and the label says
it (``toolbar.reset_target``): a device session can be zeroed, a replay cannot
— its display can, and that is what the operator reached for. Neither branch
adds a second meaning: the live one is the tracker reset it always was, and the
replay one is the display reset ``_reset_display`` states in full.
"""
"""Reset the live sensor tracker or the replay display, as labeled."""
if self.toolbar.reset_target() == "display":
self._reset_display()
return
@@ -2481,10 +2432,6 @@ class StudioMainWindow(QMainWindow):
# A request, not the zeroing itself: the acquisition thread applies it
# between two frames and reports the frame and time it took effect.
self._active_worker.reset()
# The side effect the tooltip states: a new zero makes the old idle
# estimate stale, so the display baseline is re-learned from the samples
# that follow. The curve history itself is kept.
self.force_plots.request_presentation_reestimate()
if self.acquire_presenter is not None:
self.acquire_presenter.clear_plot_marker()
self.statusBar().showMessage(t("status.reset_pending"))
@@ -2794,7 +2741,7 @@ class StudioMainWindow(QMainWindow):
def _render_run_image(self, packet) -> None:
"""Live / recompute entry into the shared 2D→3D deliver path."""
if packet is None or self._replay_owns_image():
if packet is None or self.replay_ctl.owns_image():
return
self._deliver_measurement(packet, advance="tick")
+30 -42
View File
@@ -29,6 +29,8 @@ from orisys_studio.shell_ports import DisplayShell
from orisys_studio.ui.plot_settings_dialog import (
DEFAULT_PLOT_DEADBAND_N,
DEFAULT_PLOT_DEADBAND_RAW,
DEFAULT_PLOT_NEWTON_DECIMALS,
DEFAULT_PLOT_RAW_DECIMALS,
DEFAULT_PLOT_FN_MAX,
DEFAULT_PLOT_FN_MIN,
DEFAULT_PLOT_FN_RAW_MAX,
@@ -51,12 +53,15 @@ PLOT_RANGE_DEFAULTS = {
"fn_raw_max": DEFAULT_PLOT_FN_RAW_MAX,
"shear_raw_max": DEFAULT_PLOT_SHEAR_RAW_MAX,
}
#: Display deadband floors, per unit. 0 = auto (the estimate from the quiet
#: startup window, with the presentation layer's own floor under it).
#: Absolute output thresholds per unit. Zero disables suppression.
PLOT_DEADBAND_DEFAULTS = {
"deadband_n": DEFAULT_PLOT_DEADBAND_N,
"deadband_raw": DEFAULT_PLOT_DEADBAND_RAW,
}
PLOT_PRECISION_DEFAULTS = {
"newton_decimals": DEFAULT_PLOT_NEWTON_DECIMALS,
"raw_decimals": DEFAULT_PLOT_RAW_DECIMALS,
}
# Kept as an alias so older imports and tests that name ``DisplayHost`` keep working.
DisplayHost = DisplayShell
@@ -99,10 +104,6 @@ class DisplayPrefsController(QObject):
#: Whether the panels explain the pipeline around the picture (see
#: ``on_show_diagnostics_toggled``). Off: each panel shows what the picture *is*.
self._show_diagnostics = bool(host.prefs.get("show_diagnostics", False))
#: The display baseline / deadband (``raw_auto_zero``): a display aid
#: that holds an idle channel at zero, for every sensor type and both
#: units. It never reaches the session store or forces.csv.
self._raw_auto_zero = bool(host.prefs.get("raw_auto_zero", True))
self._apply_diagnostics()
self.apply_presentation_prefs()
@@ -166,7 +167,9 @@ class DisplayPrefsController(QObject):
key: self._number(
f"plot_{key}", self._plot_defaults.get(key, fallback)
)
for key, fallback in {**PLOT_RANGE_DEFAULTS, **PLOT_DEADBAND_DEFAULTS}.items()
for key, fallback in {
**PLOT_RANGE_DEFAULTS, **PLOT_DEADBAND_DEFAULTS, **PLOT_PRECISION_DEFAULTS
}.items()
}
settings["units"] = self._toolbar.units()
return settings
@@ -191,7 +194,7 @@ class DisplayPrefsController(QObject):
A caller that knows only some of the keys leaves the rest as stored, so
this can never silently reset a range it was not told about.
"""
for key in (*PLOT_RANGE_DEFAULTS, *PLOT_DEADBAND_DEFAULTS):
for key in (*PLOT_RANGE_DEFAULTS, *PLOT_DEADBAND_DEFAULTS, *PLOT_PRECISION_DEFAULTS):
if values.get(key) is not None:
self._host.prefs[f"plot_{key}"] = values[key]
settings = self.plot_settings()
@@ -217,11 +220,28 @@ class DisplayPrefsController(QObject):
shear_raw_max=float(settings["shear_raw_max"]),
)
self._push_deadband_floors(settings["deadband_n"], settings["deadband_raw"])
decimals = getattr(self._charts, "set_value_decimals", None)
if callable(decimals):
decimals(
newton=int(settings["newton_decimals"]),
raw=int(settings["raw_decimals"]),
)
cal_panel = getattr(self._host, "cal_panel", None)
cal_settings = getattr(cal_panel, "set_output_settings", None)
if callable(cal_settings):
cal_settings(
raw_threshold=float(settings["deadband_raw"]),
raw_decimals=int(settings["raw_decimals"]),
)
def _push_deadband_floors(self, newton: float, raw: float) -> None:
floors = getattr(self._charts, "set_deadband_floors", None)
if callable(floors):
floors(newton=float(newton), raw=float(raw))
recording = getattr(self._host, "recording", None)
setter = getattr(recording, "set_force_thresholds", None)
if callable(setter):
setter(newton=float(newton), raw=float(raw))
def device_sample_rate(self) -> float:
"""The frame rate the low-pass coefficients would be designed for."""
@@ -301,32 +321,8 @@ class DisplayPrefsController(QObject):
if callable(setter):
setter(self._show_diagnostics)
# ------------------------------------------------------- raw display zeroing
@property
def raw_auto_zero(self) -> bool:
return bool(self._raw_auto_zero)
def set_raw_auto_zero(self, enabled: bool) -> None:
"""Persist whether the display baseline / deadband is on at all.
The preference keeps its name; it is no longer Finger-raw-only (see
:meth:`apply_presentation_prefs`).
"""
self._raw_auto_zero = bool(enabled)
self._host.prefs["raw_auto_zero"] = self._raw_auto_zero
self._host.persist_prefs()
self.apply_presentation_prefs()
def on_raw_auto_zero_toggled(self, enabled: bool) -> None:
self.set_raw_auto_zero(enabled)
def apply_presentation_prefs(self) -> None:
"""Push the display baseline policy onto the charts.
Every sensor type and both units. The aid holds an idle channel at zero:
a newton offset left in the numbers is the same display problem as a raw
count, so which device produced the reading is not what decides it.
"""
"""Push the deterministic per-unit threshold onto the charts."""
configure = getattr(self._charts, "configure_presentation", None)
sensor = (
self._picker.sensor_type()
@@ -338,16 +334,8 @@ class DisplayPrefsController(QObject):
configure(sensor_type=sensor, unit=unit)
enable = getattr(self._charts, "set_presentation_enabled", None)
if callable(enable):
enable(bool(self._raw_auto_zero))
enable(True)
self._push_deadband_floors(
self._number("plot_deadband_n", PLOT_DEADBAND_DEFAULTS["deadband_n"]),
self._number("plot_deadband_raw", PLOT_DEADBAND_DEFAULTS["deadband_raw"]),
)
def request_raw_reestimate(self) -> None:
"""Operator entry: restart the idle baseline window on the value cards."""
request = getattr(self._charts, "request_presentation_reestimate", None)
if callable(request):
request()
else:
self.apply_presentation_prefs()
+24 -3
View File
@@ -22,7 +22,7 @@ from typing import Any
from orisys_app_shared.record import ExperimentRecorder, RecordConfig, RecordReport
from orisys_app_shared.record.calibration_snapshot import store_calibration_snapshot
from orisys_app_shared.record.sample import to_record_sample
from orisys_app_shared.record.sample import process_record_sample, to_record_sample
logger = logging.getLogger(__name__)
@@ -51,6 +51,7 @@ class RecordingCoordinator:
self.last_report: RecordReport | None = None
self._active_calibration_id = ""
self._record_fps = 30.0
self._force_thresholds = (50.0, 0.0)
self.recorder.set_alarm_handler(self._on_alarm)
# ------------------------------------------------------------- acquisition
@@ -65,18 +66,32 @@ class RecordingCoordinator:
record = to_record_sample(
packet, calibration_version=self._active_calibration_id
)
raw_threshold, newton_threshold = self._force_thresholds
record = process_record_sample(
record, raw_threshold=raw_threshold, newton_threshold=newton_threshold
)
from orisys_app_shared.force.session import SessionSample
sample = SessionSample.from_record(record)
self.session_store.append(sample)
self.cal_recorder.feed(sample)
# Video + CSV + metadata share this one record path.
self.recorder.on_packet(packet)
self.recorder.on_packet(packet, sample=record)
fps = float(getattr(packet, "fps", 0.0) or 0.0)
if fps > 0.0:
self._record_fps = fps
return record
def set_force_thresholds(self, *, raw: float, newton: float) -> None:
values = (max(0.0, float(raw)), max(0.0, float(newton)))
if values == self._force_thresholds:
return
self._force_thresholds = values
if self.recorder.is_recording:
self.recorder.log_event(
"force_threshold_changed", f"raw={values[0]:g}, N={values[1]:g}"
)
def take_preroll(self, worker) -> list:
"""Frame history from the active worker, if it keeps one."""
take = getattr(worker, "take_preroll", None)
@@ -202,7 +217,13 @@ class RecordingCoordinator:
root=root,
config=config,
session_id=int(self.session_store.session_id),
metadata=metadata,
metadata={
**metadata,
"force_output_thresholds": {
"raw": self._force_thresholds[0],
"N": self._force_thresholds[1],
},
},
calibration_id=calibration_id,
calibration_version=calibration_version,
preroll=preroll,
+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"))