Browse a long recording from disk instead of loading it into memory

What
- record/index.py: RecordingIndex streams forces.csv once and keeps only the
  small things — one time, one validity flag, one frame id per row plus a byte
  offset every 4096 rows (~6.5 MB per 500k rows). row_range() bisects the times,
  read_rows() seeks to the nearest checkpoint and scans forward, and overview()
  folds every row into its bucket during the same pass to build a per-axis
  min/max envelope.
- read_rows(max_points, unit) decimates **while streaming**: each bucket keeps
  the extreme row of every axis in the requested unit, so a long interval never
  has to exist in memory in full and a shear spike survives a flat normal channel.
- RecordingReader: info() decimates above max_rows (default 200k) and marks
  itself decimated, with total_rows and a duration taken from the frame index
  rather than from the view; window()/window_all()/overview()/statistics() read
  the interval being asked about. list/window semantics unchanged for small
  recordings.
- force/source.py: StoreCurveSource (live memory) and RecordingCurveSource (disk
  interval + cached envelope) implement one interface — extent, window,
  envelope, statistics, duration_s — so ForcePlotsPanel asks a *source* instead
  of knowing where the data lives.
- force_plots: reads through the source (window, envelope, statistics, extent),
  which also replaces its last direct store accesses.
- playback_dialog: draws the whole-session view through window_all() in the unit
  being shown (bounded by CHART_MAX_POINTS), and says so when the view is
  decimated ("显示 N / 共 M 行") instead of letting the operator count points.
- experiment._run: a failure that lands while the files are opening can no longer
  be overwritten by the writer's own transition to "recording" (found by a flaky
  test; the state check now covers STATE_FAILED).

Why
The store releases old blocks above ~4.6 h and the replay loaded every CSV row
into Python lists before drawing anything, so neither the live browser nor the
replay could show an earlier interval of a long recording. The index makes the
disk the data source and memory a cache of what is on screen.

Verification
- shared/tests/test_history_source.py (new, 8 cases) over a 12 000-row recording:
  the index reports its rows and reads a bounded interval; a large recording is
  marked decimated with its true duration and total rows; a small one is not; an
  interval read sees the exact rows including a single peak; a decimated window
  keeps the peak; the envelope fits the whole session in ≤200 points, keeps the
  spike and is cached; the disk and memory sources agree on extent, values and
  statistics; the whole session can be read without holding it all. Shared
  suites: 146 tests, 0 failures (three consecutive runs).
- apps/studio/tools/ui_check.py: the replay section now checks the index row
  count, that an interval read spans only the interval, the envelope size and
  total_rows. 324 checks, 0 failures.
- apps/studio/tests/test_ui_smoke.py: 43 tests, 36 pass; the 7 failures are the
  unchanged sandbox/harness artifacts.
- D:/orisys_ws/_review_phase12/reproduce.py: all ten lines unchanged;
  compileall, locale and prefs parity clean.
- apps/studio/docs/record-channel.md §8 documents the index, the reader API and
  the unified source, and lists what is still hardware work.

Not in this change
- Maximum RSS / backlog / loss measurements at real resolution on the target ARM
  board (needs the hardware), and range CSV export.
This commit is contained in:
ykcheungab
2026-09-13 16:54:26 +08:00
parent 22cc59c402
commit 73592f085f
40 changed files with 6398 additions and 1070 deletions
+228 -103
View File
@@ -34,10 +34,11 @@ from orisys_app_shared.acquisition.constants import (
)
from orisys_app_shared.force import select_display_forces
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
from orisys_app_shared.force.session import SessionSample, SessionStore
from orisys_app_shared.force.session import SessionStore
from orisys_app_shared.paths import recordings_dir
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
from orisys_app_shared.record import RecordConfig
from orisys_app_shared.record.sample import to_record_sample
from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs
from orisys_app_shared.i18n import set_locale, t
from orisys_app_shared.logging import report_error
from orisys_app_shared.paths import logo_path, sensor_json_path
@@ -133,20 +134,20 @@ class StudioMainWindow(QMainWindow):
on_finished=self._on_calibration_point,
on_cancelled=self._on_calibration_cancelled,
)
self.recorder = ExperimentRecorder()
self.recorder.set_alarm_handler(self._on_record_alarm)
self._record_alarms: list[str] = []
self._record_last_report = None
self._record_fps = 30.0
# The record channel's mechanics (recorder lifecycle, metadata, the
# per-packet fan-out, confirmed events) live in the coordinator; this
# window keeps the buttons, the menus and the status messages.
self.recording = RecordingCoordinator(
session_store=self.session_store,
cal_recorder=self.cal_recorder,
)
self.recorder = self.recording.recorder
self._session_counter = 0
self._playback = None
self._record_error_count = 0
self._dropped_reported = 0
self._active_calibration_id = ""
#: Last coefficients file written by the calibration panel (not applied).
self._last_calibration_export = ""
#: Confirmed calibration changes, handed over from the acquisition thread.
self._pending_calibration_events: list[tuple[str, str, int]] = []
self._depth_range_value: tuple[float, float] | None = None
self._last_depth_frame = None
@@ -249,14 +250,28 @@ class StudioMainWindow(QMainWindow):
worker.add_sink(self._on_packet_recorded)
# ------------------------------------------------------- experiment record
def _on_record_alarm(self, message: str) -> None:
"""Called from the writer thread. Queue it; never touch Qt here."""
logger.error("Recording alarm: %s", message)
self._record_alarms.append(str(message))
@property
def _record_fps(self) -> float:
return self.recording.record_fps
@property
def _active_calibration_id(self) -> str:
return self.recording.active_calibration
@_active_calibration_id.setter
def _active_calibration_id(self, value: str) -> None:
self.recording.set_active_calibration(value)
@property
def _record_last_report(self):
return self.recording.last_report
@property
def _record_report_pending(self) -> bool:
return self.recording.report_pending
def _drain_record_alarms(self) -> None:
while self._record_alarms:
message = self._record_alarms.pop(0)
for message in self.recording.drain_alarms():
self._set_phase("error")
self.statusBar().showMessage(t("record.alarm", detail=message))
@@ -274,16 +289,13 @@ class StudioMainWindow(QMainWindow):
self.statusBar().showMessage(t("record.requires_run"))
return
mode = self.toolbar.record_mode()
config = RecordConfig(
video=(mode == "video_data"),
lossless_frames=bool(self.prefs.get("record_lossless_frames", False)),
fps=float(self._record_fps),
segment_seconds=float(self.prefs.get("record_segment_seconds", 300.0)),
config = record_config_from_prefs(
self.prefs, mode=mode, fps=float(self._record_fps)
)
try:
directory = self.recorder.start(
directory = self.recording.start(
root=recordings_dir(),
session_id=int(self.session_store.session_id),
config=config,
metadata=self._build_session_metadata(config),
calibration_id=self._active_calibration_id,
calibration_version=self._active_calibration_id,
@@ -297,21 +309,41 @@ class StudioMainWindow(QMainWindow):
self.statusBar().showMessage(t("record.start_failed", error=str(exc)))
logger.exception("Could not start recording")
return
self._record_last_report = None
self.recorder.log_event("record_started", f"mode={mode}")
self.toolbar.set_record_status(Path(directory).name)
self.statusBar().showMessage(t("record.started", mode=t(f"record.mode_{mode}")))
logger.info("Recording started: %s (mode=%s)", directory, mode)
def _stop_recording(self, *, wait: bool = True) -> None:
if not self.recorder.is_recording:
"""Stop the recording and publish what actually happened.
A recording that already failed on its own is **not** a no-op: its report
still has to be consumed, or the button keeps claiming a recording is
running and the failure is never shown.
``wait=False`` returns as soon as the stop is requested; the writer owns
the completion and the UI timer publishes it (see ``_poll_record_report``).
"""
if self.recorder.directory is None and self.recorder.report is None:
# Nothing was ever started: keep the widget state consistent anyway.
self.toolbar.set_record_status("")
self.toolbar.set_record_checked(False)
return
self.recorder.log_event("record_stopped", "")
report = self.recorder.stop(wait=wait)
self._record_last_report = report
self._publish_record_report(self.recording.stop(wait=wait))
def _publish_record_report(self, report) -> None:
"""Update the button and the summary from a recording report."""
self.toolbar.set_record_status("")
self.toolbar.set_record_checked(False)
if report is None:
return
if not report.completed:
# The writer is still closing its files. Never present that as a
# finished experiment; the timer publishes the real report.
self.statusBar().showMessage(t("record.closing"))
return
if report.stats.failed:
self._set_phase("error")
if report.problems:
self.statusBar().showMessage(
t("record.finished_with_problems", detail="; ".join(report.problems))
@@ -326,6 +358,12 @@ class StudioMainWindow(QMainWindow):
)
)
def _poll_record_report(self) -> None:
"""Publish the terminal report once the writer has closed the files."""
report = self.recording.poll()
if report is not None:
self._publish_record_report(report)
def _take_preroll(self) -> list:
"""Frame history from the active worker, if it keeps one.
@@ -333,15 +371,17 @@ class StudioMainWindow(QMainWindow):
recording follows, so a stale history can never be attached to a later
recording.
"""
worker = self._active_worker
take = getattr(worker, "take_preroll", None)
if not callable(take):
return []
try:
return list(take())
except Exception: # noqa: BLE001 - the history is optional, not the record
logger.exception("Could not take the pre-roll buffer")
return []
return self.recording.take_preroll(self._active_worker)
@staticmethod
def _device_serial(info) -> str:
"""The sensor's own serial number, when the SDK exposes one."""
for attribute in ("sn", "serial", "serial_number"):
value = getattr(info, attribute, None)
if value:
return str(value)
serial = getattr(info, "info", None)
return str(getattr(serial, "SN", "") or "")
def _build_session_metadata(self, config: RecordConfig) -> dict:
from orisys_app_shared import API_VERSION, __version__ as shared_version
@@ -379,6 +419,10 @@ class StudioMainWindow(QMainWindow):
# filled in by the acquisition thread's confirmation, so it can
# be empty here; the CSV's per-row version is authoritative.
"version": self._active_calibration_id,
# The operator-entered serial of the *calibrated* device, as the
# calibration panel has it (the device SN when the config has one).
"sn": self.cal_panel.sn_edit.text().strip(),
"device_sn": self._device_serial(info),
},
# Depth is deliberately NOT stored (decided with the team): it is a
# function of the frame and the sensor configuration, so replay
@@ -395,7 +439,10 @@ class StudioMainWindow(QMainWindow):
"conventions": {
"axis_order": ["FN", "FX", "FY"],
"force_unit": "N when calibrated, otherwise raw counts",
"time_base": "t_rel_s is relative to the first recorded frame",
"time_base": (
"t_rel_s is relative to the first recorded frame; an event "
"that predates it has no relative base and is left empty"
),
"frame_id": "monotonic per acquisition session",
},
"filter": {
@@ -432,25 +479,10 @@ class StudioMainWindow(QMainWindow):
def _on_packet_recorded(self, packet) -> None:
"""Runs on the **acquisition thread**. Must stay cheap and Qt-free.
The plots are allowed to drop frames; this path is not. It is also the
reason calibration sampling no longer depends on the calibration panel
being visible.
The packet is converted exactly once, by the same function the CSV writer
uses, so the store and the file can never disagree about whether a value
is a measurement.
The record channel's mechanics are the coordinator's; all this keeps is
the UI's own bookkeeping (how many readouts failed).
"""
record = to_record_sample(
packet, calibration_version=self._active_calibration_id
)
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)
fps = float(getattr(packet, "fps", 0.0) or 0.0)
if fps > 0.0:
self._record_fps = fps
record = self.recording.on_packet(packet)
if not record.valid:
self._record_error_count += 1
@@ -919,11 +951,64 @@ class StudioMainWindow(QMainWindow):
self.recorder.log_event("calibration_exported", str(path))
self.statusBar().showMessage(t("cal.export_only", path=Path(path).name))
def calibration_identity(self, path: str) -> dict:
"""What device a coefficient file says it belongs to.
Read with the SDK's own parser so the check sees the same document the
device would load. A file that cannot be read yields no identity, and the
caller then applies it without a mismatch claim.
"""
try:
import orisys.calibration as sdk_calibration
document = sdk_calibration.load(path, strict=True)
except Exception: # noqa: BLE001 - reported by the apply path itself
logger.exception("Could not read calibration identity from %s", path)
return {}
identity = dict(getattr(document, "identity", {}) or {})
pipeline = dict(getattr(document, "pipeline", {}) or {})
return {
"sensor_type": str(identity.get("sensor_type") or ""),
"sn": str(identity.get("sn") or ""),
"config_ref": str(pipeline.get("config_ref") or ""),
}
@staticmethod
def _same_path(first: str, second: str) -> bool:
if not first or not second:
return True # nothing to compare: not a mismatch
if first == second:
return True
try:
return Path(first).resolve() == Path(second).resolve()
except Exception: # noqa: BLE001 - a non-path value simply differs
return False
def calibration_target_mismatch(
self, path: str, *, sensor_type: str, config_ref: str
) -> list[str]:
"""Reasons this calibration does not obviously belong to this device.
Applying coefficients that were measured on a different sensor silently
changes what every recorded newton value means, so the mismatch is stated
before the confirmation, not discovered later.
"""
recorded = self.calibration_identity(path)
reasons: list[str] = []
recorded_type = recorded.get("sensor_type") or ""
if recorded_type and sensor_type and recorded_type != sensor_type:
reasons.append(f"sensor_type: {recorded_type} != {sensor_type}")
recorded_config = recorded.get("config_ref") or ""
if recorded_config and not self._same_path(recorded_config, config_ref):
reasons.append(f"config_ref: {recorded_config} != {config_ref}")
return reasons
def _on_calibration_apply(self, path: str) -> None:
"""Push freshly exported coefficients into the device — on confirmation.
The user is shown the exact target device first, and only the matching
worker is updated.
worker is updated. A calibration that names a *different* device or
configuration is called out explicitly and defaults to "no".
"""
path = str(path or self._last_calibration_export)
if not path:
@@ -931,6 +1016,29 @@ class StudioMainWindow(QMainWindow):
return
sensor_type = self.sensor_picker.sensor_type()
config_ref = self._resolve_config_ref() or ""
reasons = self.calibration_target_mismatch(
path, sensor_type=sensor_type, config_ref=config_ref
)
if reasons:
answer = QMessageBox.question(
self,
t("cal.apply_mismatch_title"),
t(
"cal.apply_mismatch",
device=self.calibration_identity(path).get("sensor_type") or "?",
detail="; ".join(reasons),
sensor_type=sensor_type,
config=config_ref,
),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer != QMessageBox.Yes:
self.recorder.log_event(
"calibration_apply_blocked", "; ".join(reasons)
)
self.statusBar().showMessage(t("cal.apply_mismatch_skipped"))
return
answer = QMessageBox.question(
self,
t("cal.apply_title"),
@@ -952,30 +1060,32 @@ class StudioMainWindow(QMainWindow):
t("cal.apply_pending", sensor_type=sensor_type)
)
def _on_calibration_applied(self, path: str, version: str, frame_id: int) -> None:
def _on_calibration_applied(
self, path: str, version: str, frame_id: int, mono_t: float, session: int
) -> None:
"""Called from the acquisition thread once new coefficients are loaded."""
# The acquisition thread must not touch Qt, so only plain state moves here.
self._pending_calibration_events.append(
(str(path), str(version), int(frame_id))
)
self.recording.note_calibration_applied(path, version, frame_id, mono_t, session)
def _on_reset_applied(self, frame_id: int, mono_t: float, session: int) -> None:
"""Called from the acquisition thread once a zeroing took effect."""
self.recording.note_reset(frame_id, mono_t, session)
def _drain_calibration_events(self) -> None:
"""Publish confirmed calibration changes on the UI thread."""
while self._pending_calibration_events:
path, version, frame_id = self._pending_calibration_events.pop(0)
# The version is the calibration's content hash: a path can be
# re-exported with different coefficients, a hash cannot.
self._active_calibration_id = version or path
self.session_store.set_calibration_id(self._active_calibration_id)
self.recorder.set_calibration_version(self._active_calibration_id)
self.recorder.log_event(
"calibration_applied",
f"{path} ({self._active_calibration_id[:12] or 'unhashed'})",
frame_id=frame_id,
)
self.statusBar().showMessage(
t("cal.applied", version=self._active_calibration_id[:12])
)
for event in self.recording.drain_calibration_events():
version = event["version"]
if version:
self.statusBar().showMessage(t("cal.applied", version=version[:12]))
else:
# An empty version is the honest answer for a device with no
# calibration; it must not read as "the last one is still on".
self.statusBar().showMessage(t("cal.applied_none"))
def _drain_reset_events(self) -> None:
"""Publish confirmed zeroings on the UI thread."""
for _event in self.recording.drain_reset_events():
self.statusBar().showMessage(t("status.reset_ok"))
def _worker_for(self, sensor_type: str):
self._ensure_workers()
@@ -1228,7 +1338,6 @@ class StudioMainWindow(QMainWindow):
# starts empty and is filled from that confirmation — a path is not a
# version, and the same path can hold different numbers.
self._active_calibration_id = ""
self._pending_calibration_events.clear()
self._record_error_count = 0
self._dropped_reported = 0
self._depth_range_value = None
@@ -1318,18 +1427,12 @@ class StudioMainWindow(QMainWindow):
if self._session_phase != "running" or self._active_worker is None:
return
try:
# 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()
self.force_plots.reset()
self._last_plot_packet_key = None
# Recorded with the frame it happened at: a reset clears the SDK's
# tracker, so an offline depth replay can start here instead of
# guessing at the algorithm's earlier state.
self.recorder.log_event(
"zeroing",
"tracker reset",
frame_id=self._current_frame_id(),
)
self.statusBar().showMessage(t("status.reset_ok"))
self.statusBar().showMessage(t("status.reset_pending"))
except Exception as exc:
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
@@ -1351,6 +1454,11 @@ class StudioMainWindow(QMainWindow):
def _on_run_tick(self) -> None:
if self._active_worker is None:
return
# Record bookkeeping happens before anything that can return early: a
# recording that failed while the device was quiet still has to update
# the button and the summary.
self._drain_record_alarms()
self._poll_record_report()
if self._last_session_error:
err = self._last_session_error
self._last_session_error = ""
@@ -1405,31 +1513,31 @@ class StudioMainWindow(QMainWindow):
if is_new:
self._last_plot_packet_key = packet_key
# Invalid readouts must not be drawn as if they were data, and they
# must not be written into the plot buffer: NaN would poison the
# auto-range and a zero would be indistinguishable from a real one.
if packet.valid:
# The live view consumes the *same* standardised measurement as the
# CSV and the session store: one conversion decides what a value
# means, so the chart cannot show something the record does not
# contain (or vice versa).
record = to_record_sample(packet)
if record.valid:
display = select_display_forces(
units_pref=self.toolbar.units(),
force_calibrated=packet.force_calibrated,
fn=packet.normal_force,
fx=packet.shear_x,
fy=packet.shear_y,
fn_raw=packet.normal_force_raw,
fx_raw=packet.shear_x_raw,
fy_raw=packet.shear_y_raw,
sample=record,
)
if display.using_raw_fallback:
self.statusBar().showMessage(t("status.units_raw_fallback"))
self.force_plots.push(
packet.timestamp,
display.fn,
display.fx,
display.fy,
# Session-relative time, the same base the store and
# ``forces.csv`` use. The wall clock must never reach the
# buffer: it would put the live axis on a different scale.
self._session_time(packet),
display.values[0],
display.values[1],
display.values[2],
unit=display.unit,
available=display.available,
)
else:
self._report_invalid_sample(packet.invalid_reason)
self._report_invalid_sample(record.invalid_reason)
# The calibration panel is a *view*: sampling is driven by the
# record channel, so hiding the panel can no longer stall it.
self.cal_panel.set_live(
@@ -1441,8 +1549,8 @@ class StudioMainWindow(QMainWindow):
# Finalise calibration sampling on the UI thread once its deadline passes.
self.cal_recorder.tick()
self.cal_panel.poll_recorder()
self._drain_record_alarms()
self._drain_calibration_events()
self._drain_reset_events()
self._report_dropped_samples()
mode = self.arrow_panel.view_mode()
@@ -1451,6 +1559,23 @@ class StudioMainWindow(QMainWindow):
if self._show_fps and is_new:
self.arrow_panel.set_fps(packet.fps)
def _session_time(self, packet) -> float:
"""Session-relative acquisition time for the live view.
The session store already defines the origin (its first appended sample),
and ``forces.csv`` uses the same base. Deriving the live time from that
one origin is what keeps the live buffer, a rebuilt history and the replay
on a single X axis.
"""
origin = self.session_store.origin_time()
absolute = getattr(packet, "mono_t", None)
if absolute is None:
absolute = getattr(packet, "timestamp", 0.0)
try:
return float(absolute) - float(origin)
except (TypeError, ValueError):
return 0.0
def _report_invalid_sample(self, reason: str) -> None:
"""Surface failed readouts instead of letting them pass as data.
@@ -0,0 +1,130 @@
"""Calibration service: the panel's operations, without the panel.
The panel shows a table and emits status messages; everything that touches the
file system or a device identity happens here:
* preparing the session once (store, diagnostics and export share one model);
* saving the audit record, reopening one, and exporting the coefficients;
* keeping a reopened record's **own** device identity instead of restamping it
with whatever device happens to be connected.
This is deliberately Qt-free so it can be tested without a display.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, Optional
from orisys_app_shared.force.calibration_io import (
CHANNEL_KEYS,
CalSessionStore,
export_force_calibration_v2,
meta_from_sensor_json,
)
from orisys_app_shared.force.calibration_session import (
CalibrationSessionRecord,
read_calibration_session,
session_filename,
write_calibration_session,
)
from orisys_app_shared.paths import calibration_export_dir
logger = logging.getLogger(__name__)
class CalibrationService:
"""Owns the calibration session's files and its prepared model."""
def __init__(self, *, store: Optional[CalSessionStore] = None) -> None:
self.store = store or CalSessionStore()
#: The one prepared model the panel displays and exports.
self.prepared: Any = None
#: Device identity of a reopened record, while it is open.
self.record_identity: Optional[dict] = None
#: The record currently open, if any.
self.open_record: Optional[CalibrationSessionRecord] = None
# --------------------------------------------------------------- identity
def export_meta(
self,
*,
sensor_type: str,
config_ref: str | None,
sn: str = "",
duration_s: float = 0.0,
) -> dict:
"""The identity/pipeline metadata an export would carry.
While a saved record is open its **own** device identity wins: reopening
device A's calibration on a machine connected to device B must keep saying
A, or the evidence is silently restamped with B's serial number.
"""
meta = meta_from_sensor_json(config_ref)
meta["sensor_type"] = str(sensor_type or "box")
meta["sn"] = str(sn or "")
meta["record_duration_s"] = float(duration_s or 0.0)
identity = self.record_identity
if identity:
meta["sensor_type"] = identity.get("sensor_type") or meta["sensor_type"]
meta["sn"] = identity.get("sn") or meta["sn"]
meta["config_ref"] = identity.get("config_ref") or meta.get("config_ref")
meta["source_record"] = str(identity.get("path") or "")
meta["source_created_at"] = str(identity.get("created_at") or "")
return meta
def clear_record_identity(self) -> None:
"""A new run is the live device's context again."""
self.record_identity = None
self.open_record = None
# ------------------------------------------------------------ preparation
def prepare(self, recorder, meta: dict) -> Any:
"""Prepare the session once: store, diagnostics and export share it."""
self.prepared = None
self.store.clear()
if recorder is None:
return None
prepared = recorder.prepared(meta=meta)
self.prepared = prepared
for key in CHANNEL_KEYS:
self.store.set_channel(key, prepared.channel_samples(key))
# The residuals belong to the points, because the table shows them.
for point in recorder.all_points():
value = prepared.residuals.get(id(point))
point.residual_n = float("nan") if value is None else float(value)
return prepared
# ------------------------------------------------------------------- files
def save_record(self, recorder, meta: dict) -> Path:
"""Write the audit record. Returns the written path."""
points = recorder.all_points()
path = calibration_export_dir() / session_filename(meta.get("sensor_type"))
return write_calibration_session(path, points, meta=meta)
def load_record(self, path: str | Path) -> CalibrationSessionRecord:
"""Reopen a saved session and remember whose record it is."""
record = read_calibration_session(path)
identity = record.identity()
self.record_identity = {**identity, "path": str(record.path)}
self.open_record = record
return record
def export_coefficients(
self,
*,
sensor_type: str,
export_dir: str | Path | None = None,
local_bundle_dir: str | Path | None = None,
) -> tuple[Path, Path | None, list[str]]:
"""Serialise the prepared model. Raises ValueError when there is none."""
if self.prepared is None or not getattr(self.prepared, "ok", False):
warnings = list(getattr(self.prepared, "warnings", []) or [])
raise ValueError("; ".join(warnings) or "no complete channels")
return export_force_calibration_v2(
self.prepared,
export_dir=export_dir or calibration_export_dir(),
sensor_type=str(sensor_type or "box"),
local_bundle_dir=local_bundle_dir,
)
+266
View File
@@ -0,0 +1,266 @@
"""The record channel: one experiment directory, one session store, one sampler.
This is the coordination the main window used to do inline — and it is deliberately
free of Qt. Three consumers are fed from the same acquisition packet:
* the :class:`ExperimentRecorder`, which writes the directory;
* the whole-session store the charts and the browser read;
* the calibration sampler (``CalibrationRecorder``), whose lifecycle must not
depend on the calibration panel being visible.
The window keeps the buttons, the menus and the status messages; the decisions
about *what* is recorded, *how* it is named and *what actually happened* live
here, where they can be tested without a display.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, Callable, Optional
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
logger = logging.getLogger(__name__)
class RecordingCoordinator:
"""Owns the recorder and the per-packet fan-out."""
def __init__(
self,
*,
session_store,
cal_recorder,
recorder: Optional[ExperimentRecorder] = None,
) -> None:
self.session_store = session_store
self.cal_recorder = cal_recorder
self.recorder = recorder or ExperimentRecorder()
#: Confirmed calibration changes: ``(path, version, frame_id, mono_t, session)``.
self._calibration_events: list[tuple[str, str, int, float, int]] = []
#: Confirmed zeroings: ``(frame_id, mono_t, session)``.
self._reset_events: list[tuple[int, float, int]] = []
#: Raised by the writer thread; drained on the UI thread.
self._alarms: list[str] = []
#: A stop was requested and the writer has not closed its files yet.
self.report_pending = False
self.last_report: Optional[RecordReport] = None
self._active_calibration_id = ""
self._record_fps = 30.0
self.recorder.set_alarm_handler(self._on_alarm)
# ------------------------------------------------------------- acquisition
def on_packet(self, packet):
"""Runs on the **acquisition thread**. Must stay cheap and Qt-free.
The packet is converted exactly once, by the same function the CSV writer
uses, so the store and the file can never disagree about whether a value
is a measurement. Returns the converted record, which the caller may use
for its own (UI-side) bookkeeping.
"""
record = to_record_sample(
packet, calibration_version=self._active_calibration_id
)
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)
fps = float(getattr(packet, "fps", 0.0) or 0.0)
if fps > 0.0:
self._record_fps = fps
return record
def take_preroll(self, worker) -> list:
"""Frame history from the active worker, if it keeps one."""
take = getattr(worker, "take_preroll", None)
if not callable(take):
return []
try:
return list(take())
except Exception: # noqa: BLE001 - the history is optional, not the record
logger.exception("Could not take the pre-roll buffer")
return []
def set_active_calibration(self, version: str) -> None:
"""The calibration the record should name until the device says otherwise."""
self._active_calibration_id = str(version or "")
@property
def active_calibration(self) -> str:
return self._active_calibration_id
@property
def record_fps(self) -> float:
return self._record_fps
# ------------------------------------------------------------------ events
def note_calibration_applied(
self, path: str, version: str, frame_id: int, mono_t: float, session: int
) -> None:
"""Called from the acquisition thread once coefficients are loaded."""
self._calibration_events.append(
(str(path), str(version), int(frame_id), float(mono_t), int(session))
)
def note_reset(self, frame_id: int, mono_t: float, session: int) -> None:
"""Called from the acquisition thread once a zeroing took effect."""
self._reset_events.append((int(frame_id), float(mono_t), int(session)))
def drain_calibration_events(self) -> list[dict]:
"""Publish confirmed calibrations. Returns what the UI should show."""
published: list[dict] = []
while self._calibration_events:
path, version, frame_id, mono_t, session = self._calibration_events.pop(0)
# The version is the calibration's content hash: a path can be
# re-exported with different coefficients, a hash cannot.
self._active_calibration_id = version
self.session_store.set_calibration_id(version)
self.recorder.set_calibration_version(version)
snapshot = self.snapshot_calibration(path, version, mono_t, frame_id)
self.recorder.log_event(
"calibration_applied",
f"{path} ({version[:12] or 'unhashed'})"
+ (f" -> {snapshot}" if snapshot else ""),
mono_t=mono_t,
frame_id=frame_id,
)
published.append(
{
"path": path,
"version": version,
"frame_id": frame_id,
"mono_t": mono_t,
"session": session,
"snapshot": snapshot,
}
)
return published
def drain_reset_events(self) -> list[dict]:
"""Publish confirmed zeroings."""
published: list[dict] = []
while self._reset_events:
frame_id, mono_t, session = self._reset_events.pop(0)
self.recorder.log_event(
"zeroing",
"tracker reset (applied by the acquisition thread)",
mono_t=mono_t,
frame_id=frame_id,
)
published.append(
{"frame_id": frame_id, "mono_t": mono_t, "session": session}
)
return published
def snapshot_calibration(
self, path: str, version: str, mono_t: float, frame_id: int
) -> str:
"""Copy the applied coefficients into the recording. Returns its name."""
directory = self.recorder.directory
if directory is None or not path or not Path(path).is_file():
return ""
try:
name = store_calibration_snapshot(directory, Path(path), version)
except Exception: # noqa: BLE001 - the record continues without it
logger.exception("Could not snapshot the applied calibration")
return ""
self.recorder.note_calibration_snapshot(
version=version, name=name, frame_id=frame_id, mono_t=mono_t
)
return name
# ------------------------------------------------------------------ alarms
def _on_alarm(self, message: str) -> None:
"""Called from the writer thread. Queue it; never touch Qt here."""
logger.error("Recording alarm: %s", message)
self._alarms.append(str(message))
def drain_alarms(self) -> list[str]:
drained, self._alarms = self._alarms, []
return drained
# --------------------------------------------------------------- lifecycle
def start(
self,
*,
root: Path,
config: RecordConfig,
metadata: dict[str, Any],
calibration_id: str = "",
calibration_version: str = "",
preroll=(),
) -> Path:
"""Start a recording. Raises when the directory cannot be created."""
directory = self.recorder.start(
root=root,
session_id=int(self.session_store.session_id),
metadata=metadata,
calibration_id=calibration_id,
calibration_version=calibration_version,
preroll=preroll,
)
self.last_report = None
self.report_pending = False
return Path(directory)
def stop(self, *, wait: bool = True) -> Optional[RecordReport]:
"""Stop and return the report, or ``None`` when nothing was started."""
if self.recorder.directory is None and self.recorder.report is None:
self.report_pending = False
return None
if self.recorder.is_recording:
self.recorder.log_event("record_stopped", "")
report = self.recorder.stop(wait=wait)
self.publish(report)
return report
def publish(self, report: Optional[RecordReport]) -> None:
"""Record a report; a completed one becomes ``last_report``."""
if report is None:
return
if not report.completed:
# The writer is still closing; the UI timer publishes the real one.
self.report_pending = True
return
self.report_pending = False
self.last_report = report
def poll(self) -> Optional[RecordReport]:
"""The terminal report, once the writer has actually closed the files."""
if not self.report_pending:
return None
report = self.recorder.report
if report is None:
return None
self.publish(report)
return report
@property
def is_recording(self) -> bool:
return self.recorder.is_recording
@property
def directory(self):
return self.recorder.directory
def record_config_from_prefs(
prefs: dict,
*,
mode: str,
fps: float,
on_problem: Optional[Callable[[str], None]] = None,
) -> RecordConfig:
"""Build the record config the toolbar/prefs describe."""
return RecordConfig(
video=(mode == "video_data"),
lossless_frames=bool(prefs.get("record_lossless_frames", False)),
fps=float(fps),
segment_seconds=float(prefs.get("record_segment_seconds", 300.0)),
)
+88 -55
View File
@@ -30,18 +30,16 @@ from orisys_app_shared.force.calibration_io import (
CalSample,
CalSessionStore,
ChannelKey,
export_force_calibration_v2,
meta_from_sensor_json,
)
from orisys_app_shared.force.calibration_session import (
read_calibration_session,
session_filename,
write_calibration_session,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import calibration_export_dir
from orisys_app_shared.theme import color
from orisys_studio.calibration_service import CalibrationService
from orisys_studio.ui.cal_point_dialog import CalibrationPointDialog
CHANNEL_LABELS = ("fn", "fx", "fy")
@@ -71,7 +69,10 @@ class CalPanel(QFrame):
super().__init__(parent)
self.setObjectName("contentPanel")
self._theme = theme
self._store = CalSessionStore()
#: The files, the identity and the prepared model live in the service;
#: this panel shows them and emits status messages.
self._service = CalibrationService()
self._store = self._service.store
self._channel: ChannelKey = "fn"
self._running = False
#: Set by :meth:`bind_recorder`. Sampling lives there, not here, so the
@@ -244,6 +245,14 @@ class CalPanel(QFrame):
self.retranslate()
# ------------------------------------------------------------------ API
@property
def _prepared(self):
return self._service.prepared
@property
def _record_identity(self):
return self._service.record_identity
def set_theme(self, theme: str) -> None:
self._theme = theme
self._render_live_label()
@@ -266,6 +275,8 @@ class CalPanel(QFrame):
def set_meta_context(self, *, sensor_type: str, config_ref: str | None) -> None:
self._sensor_type = str(sensor_type or "box")
self._config_ref = config_ref
# A new run is the live device's context again.
self._service.clear_record_identity()
def set_live(self, fn: float, fx: float, fy: float) -> None:
"""Update the live readout. Sampling is handled by the recorder."""
@@ -519,7 +530,8 @@ class CalPanel(QFrame):
if self._recorder is None:
self.fit_label.setText("")
return
fit = self._recorder.fit(self._channel)
# Same prepared result the store and the export use — never a second one.
fit = self._recorder.fit(self._channel, prepared=self._prepared)
if not fit.usable:
self.fit_label.setText(t("cal.fit_insufficient", count=fit.n_points))
return
@@ -549,12 +561,30 @@ class CalPanel(QFrame):
state = t("cal.state_restore") if point.excluded else t("cal.state_exclude")
self.btn_toggle_excluded.setText(state)
def point_dialog(self, point):
"""The diagnostic view for a point, rebuilt from its samples if needed.
A reopened record carries the full sample series, so the three-axis view
can always be produced — waiting for a cached trace would have made
exactly the reopened points (the ones worth auditing) unopenable.
"""
if point is None or not (point.trace or point.samples):
return None
if not point.trace:
from orisys_app_shared.force.calibration_session import (
restore_diagnostics,
)
restore_diagnostics(point)
if not point.trace:
return None
return CalibrationPointDialog(point, theme=self._theme, parent=self)
def _on_row_activated(self) -> None:
"""Double click a row to inspect that point's three-axis curve."""
point = self._current_point()
if point is None or not point.trace:
return
CalibrationPointDialog(point, theme=self._theme, parent=self).exec_()
dialog = self.point_dialog(self._current_point())
if dialog is not None:
dialog.exec_()
def _on_toggle_excluded(self) -> None:
point = self._current_point()
@@ -580,32 +610,24 @@ class CalPanel(QFrame):
t("cal.marked_validation" if point.is_validation else "cal.unmarked_validation")
)
def _rebuild_store(self) -> None:
"""Rebuild the export store from the points that are still in the fit.
def _export_meta(self) -> dict:
"""The identity/pipeline metadata the export would carry."""
return self._service.export_meta(
sensor_type=self._sensor_type,
config_ref=self._config_ref,
sn=self.sn_edit.text().strip(),
duration_s=float(self.duration_spin.value()),
)
Validation points are held out: they exist to check the model, so letting
them into the build would both corrupt it and make the "independent"
error meaningless. Repeated reference loads are merged rather than
overwritten, so every sample that was actually taken reaches the model.
def _rebuild_store(self) -> None:
"""Prepare the session once: store, diagnostics and export share it.
The panel used to filter, aggregate and build its own model while
``CalibrationRecorder.fit`` prepared a second one for the table, so the
displayed residual and the exported curve could describe different
models. Both now read :class:`PreparedCalibration`, built by the service.
"""
self._store.clear()
if self._recorder is None:
return
for point in self._recorder.all_points():
if point.excluded or point.is_validation:
continue
stats = point.loaded_stats()
if not np.isfinite(stats.mean):
continue
self._store.aggregate(
point.channel,
CalSample(
weight_n=float(point.reference_load_n),
raw_mean=float(stats.mean),
n=int(stats.n),
std=float(stats.std) if np.isfinite(stats.std) else 0.0,
),
)
self._service.prepare(self._recorder, self._export_meta())
def _on_delete_row(self) -> None:
point = self._current_point()
@@ -630,20 +652,24 @@ class CalPanel(QFrame):
self._refresh_table()
def _on_export(self) -> None:
meta = meta_from_sensor_json(self._config_ref)
meta["sensor_type"] = self._sensor_type
meta["sn"] = self.sn_edit.text().strip()
meta["record_duration_s"] = float(self.duration_spin.value())
# Re-prepare first: the SN and the requested duration may have changed,
# and the file must be exactly the model the panel is showing.
self._rebuild_store()
self._refresh_fit_label()
if self._prepared is None or not self._prepared.ok:
QMessageBox.warning(
self,
t("panels.cal"),
t("cal.export_no_model", detail="; ".join(self._warnings())),
)
return
try:
# Write into the user's calibration directory only. Overwriting the
# device bundle under config/sensors/<type>/ is now an explicit,
# separate step so that exporting cannot silently change what the
# next Sensor() will load.
primary, _local_path, warnings = export_force_calibration_v2(
self._store,
export_dir=calibration_export_dir(),
primary, _local_path, warnings = self._service.export_coefficients(
sensor_type=self._sensor_type,
meta=meta,
local_bundle_dir=None,
)
except ValueError as exc:
@@ -664,6 +690,9 @@ class CalPanel(QFrame):
self.calibration_exported.emit(str(primary))
QMessageBox.information(self, t("panels.cal"), msg)
def _warnings(self) -> list[str]:
return list(getattr(self._prepared, "warnings", []) or [])
def _on_apply(self) -> None:
if not self._last_export_path:
self.status_message.emit(t("cal.apply_no_export"))
@@ -678,12 +707,10 @@ class CalPanel(QFrame):
if not points:
self.status_message.emit(t("cal.save_record_empty"))
return
meta = meta_from_sensor_json(self._config_ref)
meta["sensor_type"] = self._sensor_type
meta["sn"] = self.sn_edit.text().strip()
path = calibration_export_dir() / session_filename(self._sensor_type)
# ``_export_meta`` keeps a reopened record's own device identity.
meta = self._export_meta()
try:
written = write_calibration_session(path, points, meta=meta)
written = self._service.save_record(self._recorder, meta)
except Exception as exc: # noqa: BLE001 - reported to the operator
QMessageBox.warning(
self, t("panels.cal"), t("cal.save_record_failed", error=str(exc))
@@ -694,30 +721,36 @@ class CalPanel(QFrame):
self.status_message.emit(msg)
QMessageBox.information(self, t("panels.cal"), msg)
def _on_open_record(self) -> None:
"""Reopen a saved session so it can be inspected again."""
def _on_open_record(self, path: str | None = None) -> None:
"""Reopen a saved session: its points **and** its device identity."""
if self._recorder is None:
return
path, _filter = QFileDialog.getOpenFileName(
self,
t("cal.open_record"),
str(calibration_export_dir()),
t("cal.open_record_filter"),
)
if path is None:
path, _filter = QFileDialog.getOpenFileName(
self,
t("cal.open_record"),
str(calibration_export_dir()),
t("cal.open_record_filter"),
)
if not path:
return
try:
record = read_calibration_session(path)
record = self._service.load_record(path)
except Exception as exc: # noqa: BLE001 - reported to the operator
QMessageBox.warning(
self, t("panels.cal"), t("cal.open_record_failed", error=str(exc))
)
return
# Show the record's own SN: it is what a later save or export will carry,
# so the operator must not be looking at the connected device's number.
if record.identity().get("sn"):
self.sn_edit.setText(record.identity()["sn"])
self._recorder.load_points(record.points, replace=True)
self._rebuild_store()
self._refresh_table()
self._refresh_enabled()
msg = t("cal.open_record_ok", path=record.path.name, points=len(record.points))
msg += " " + t("cal.open_record_device", device=record.describe())
if record.warnings:
msg += "\n" + t("cal.open_record_warnings", detail="; ".join(record.warnings))
self.status_message.emit(msg)
+91 -40
View File
@@ -18,6 +18,7 @@ from PyQt5.QtWidgets import (
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ
from orisys_app_shared.force import MetricHistory
from orisys_app_shared.force.history import DEFAULT_MAX_FPS
from orisys_app_shared.force.source import StoreCurveSource
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
@@ -100,8 +101,13 @@ class ForcePlotsPanel(QFrame):
self._fixed_newton = True
self._unit = "raw"
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)
# -- browsing state -------------------------------------------------
self._store = None
#: Where the curves read from: the in-memory session store here, a
#: recording on disk in the replay. One interface either way.
self._source = StoreCurveSource()
self._mode = "follow"
self._window: tuple[float, float] = (0.0, 0.0)
self._cursor_t = 0.0
@@ -313,6 +319,7 @@ class ForcePlotsPanel(QFrame):
self.history.reset()
self._clear_curves()
self._last_values = (0.0, 0.0, 0.0)
self._available = (True, True, True)
self._unit = "raw"
self._mode = "follow"
self._window = (0.0, 0.0)
@@ -328,19 +335,33 @@ class ForcePlotsPanel(QFrame):
def push(
self,
timestamp: float,
t_rel_s: float,
fn: float,
fx: float,
fy: float,
*,
unit: str = "raw",
available: tuple[bool, bool, bool] | None = None,
) -> None:
"""Append one live sample.
``t_rel_s`` is **session-relative** time — the same base the session store
and ``forces.csv`` use. Handing the wall clock in here used to put the live
buffer on a different time axis from a rebuilt history, so switching units
(or entering browse) made the X axis jump by the size of the epoch.
``available`` marks axes that have no value in ``unit`` (a device with
only its normal-force axis calibrated). Those keep NaN and show as a gap
and a dash, never as a raw count wearing a newton label.
"""
if unit != self._unit:
# Never mix units in one buffer. Callers switch units through
# ``set_units``/``set_fixed_newton`` which rebuild from the session
# store; a bare change here would silently reinterpret old samples.
self._unit = unit
self._last_values = (float(fn), float(fx), float(fy))
if available is not None:
self._available = tuple(bool(flag) for flag in available)
self._render_value_cards()
if self._mode == "browse":
# Browsing is a display pause, not a capture pause: the record
@@ -351,7 +372,7 @@ class ForcePlotsPanel(QFrame):
# Store the true value. Clamping here (the old ``max(0.0, fn)``) erased
# negative FN from the buffer and hid zero drift; out-of-range values are
# now handled by the axis range, which only affects what is drawn.
self.history.push(timestamp, float(fn), float(fx), float(fy))
self.history.push(t_rel_s, float(fn), float(fx), float(fy))
state = self.history.state()
if state is None:
return
@@ -372,7 +393,9 @@ class ForcePlotsPanel(QFrame):
self.plots[i].setYRange(*state.y_ranges[i], padding=0.0)
finally:
self._syncing = False
self.set_cursor(float(timestamp) - (self._time_origin() or 0.0))
# The buffer already carries session-relative time, so the cursor is the
# time itself: subtracting an origin here would double-count it.
self.set_cursor(float(t_rel_s))
# The overview is a full-store read, so refresh it on a timer rather
# than on every frame.
now = time.perf_counter()
@@ -381,12 +404,6 @@ class ForcePlotsPanel(QFrame):
self._last_overview_at = now
self._sync_overview()
def _time_origin(self) -> float:
"""The store's time origin, so the cursor matches the overview axis."""
if self._store is None:
return 0.0
return self._store.origin_time()
def set_units(self, unit: str, store=None) -> None:
"""Switch display units without mixing units in the curve buffer.
@@ -398,11 +415,13 @@ class ForcePlotsPanel(QFrame):
if unit == self._unit and store is None:
return
self._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.
self.history.clear()
self._clear_curves()
self._overview_dirty = True
if store is not None:
self._store = store
self.set_store(store)
if self._mode == "browse":
self.refresh_from_store()
else:
@@ -414,6 +433,7 @@ class ForcePlotsPanel(QFrame):
def set_store(self, store) -> None:
"""Attach the whole-session store the browser reads from."""
self._store = store
self._source.set_store(store)
self._overview_dirty = True
self._refresh_mode_label()
@@ -437,19 +457,19 @@ class ForcePlotsPanel(QFrame):
def show_all(self) -> None:
"""Overview: fit the entire session, in browse mode."""
if self._store is None:
if self._source is None:
return
first, last = self._store.extent()
first, last = self._source.extent()
if last <= first:
return
self._set_mode("browse")
self.set_window(first, last)
def set_window(self, t0: float, t1: float) -> None:
"""Show ``[t0, t1]`` from the whole-session store."""
if self._store is None:
"""Show ``[t0, t1]`` from the whole-session source."""
if self._source is None:
return
first, last = self._store.extent()
first, last = self._source.extent()
if t1 <= t0:
t1 = t0 + max(0.001, self.history.window_sec)
t0 = max(first, float(t0))
@@ -463,21 +483,23 @@ class ForcePlotsPanel(QFrame):
if self._store is None:
return
t0, t1 = self._window
slice_ = self._store.visible(
# One shared rule for every view, decided **per axis**: valid and finite
# for *that* axis. An all-axes rule hid a valid FN whenever the device had
# no shear calibration, and a finiteness-only rule let a stale value
# reappear after another view excluded it.
window = self._source.window(
t0, t1, max_points=self._max_draw_points, unit=self._unit
)
self._syncing = True
try:
if len(slice_) == 0:
if len(window) == 0:
for curve in self.curves:
curve.setData([], [])
else:
# One shared rule for every view: valid *and* finite. A stale
# number left by a failed compute step must not reappear here
# after another view already excluded it.
times, values, _mask = slice_.plottable(self._unit)
for index, curve in enumerate(self.curves):
curve.setData(times, values[:, index])
times, values = window.plottable_axis(index)
curve.setData(times, values)
self._availability_from_window(window)
for plot in self.plots:
plot.setXRange(t0, t1, padding=0.0)
self._apply_axis_mode()
@@ -523,7 +545,7 @@ class ForcePlotsPanel(QFrame):
x0, x1 = float(x_range[0]), float(x_range[1])
if not (x1 > x0):
return
first, last = self._store.extent()
first, last = self._source.extent()
if last <= first:
return
# Keep the window inside the recorded session.
@@ -554,7 +576,7 @@ class ForcePlotsPanel(QFrame):
self.stats_label.setText("")
return
t0, t1 = self._window
stats = self._store.statistics(t0, t1, unit=self._unit)
stats = self._source.statistics(t0, t1, unit=self._unit)
if stats.samples == 0:
self.stats_label.setText(t("chart.selection_empty"))
return
@@ -581,19 +603,19 @@ class ForcePlotsPanel(QFrame):
def _sync_overview(self) -> None:
if self._store is None:
return
first, last = self._store.extent()
first, last = self._source.extent()
if last <= first:
self.overview_curve.setData([], [])
return
# The overview is a full-store read, so only rebuild it when it can have
# changed (window/unit/session) rather than on every pan.
slice_ = self._store.visible(
first, last, max_points=self._overview_points(), unit=self._unit
# The overview is a whole-session read, so only rebuild it when it can
# have changed (window/unit/session) rather than on every pan.
window = self._source.envelope(
buckets=self._overview_points(), unit=self._unit
)
values = slice_.value_for_unit(self._unit)
values = np.asarray(window.values, dtype=float)
column = values[:, 0]
finite = np.isfinite(column)
self.overview_curve.setData(slice_.times[finite], column[finite])
self.overview_curve.setData(window.times[finite], column[finite])
self.overview.setXRange(first, last, padding=0.0)
if self._mode == "browse":
self.overview_region.setRegion(self._window)
@@ -616,20 +638,25 @@ class ForcePlotsPanel(QFrame):
self.btn_overview.setEnabled(self._store is not None)
def _reload_from_store(self, store) -> None:
"""Re-populate the visible buffer from the whole-session store."""
"""Re-populate the visible buffer from the session source."""
window = self.history.window_sec
slice_ = store.tail(window) if window > 0 else store.data()
if len(slice_) == 0:
first, last = self._source.extent()
span = window if window > 0 else max(0.0, last - first)
data = self._source.window(
max(first, last - span), last, unit=self._unit
)
if len(data) == 0:
return
values = slice_.value_for_unit(self._unit)
for index in range(len(slice_)):
if not bool(slice_.valid[index]):
self._availability_from_window(data)
values = np.asarray(data.values, dtype=float)
for index in range(len(data)):
if not bool(data.valid[index]):
continue
row = values[index]
if not all(np.isfinite(v) for v in row):
continue
self.history.push(
float(slice_.times[index]),
float(data.times[index]),
float(row[0]),
float(row[1]),
float(row[2]),
@@ -641,6 +668,19 @@ class ForcePlotsPanel(QFrame):
curve.setData(state.times, state.values[:, i])
self.plots[i].setXRange(*state.x_range, padding=0.0)
def _availability_from_window(self, window) -> None:
"""Mark which axes have a value in the active unit, from a read window.
Used after a unit switch: the value cards must not keep showing the
previous unit's numbers, and an axis with no curve in this unit has to be
a dash until a fresh sample arrives.
"""
if window is None or len(window) == 0:
return
values = np.asarray(window.values, dtype=float)
finite = np.isfinite(values) & np.asarray(window.valid, dtype=bool)[:, None]
self._available = tuple(bool(finite[:, index].any()) for index in range(3))
def _uses_fixed_y(self) -> bool:
"""Fixed Y whenever we have limits for the active unit.
@@ -690,10 +730,21 @@ class ForcePlotsPanel(QFrame):
def _render_value_cards(self) -> None:
fmt = "{value:.3f}" if self._unit == "N" else "{value:.1f}"
for value_label, unit_label, value in zip(
self.value_labels, self.value_units, self._last_values
unavailable = t("chart.value_unavailable")
for index, (value_label, unit_label, value) in enumerate(
zip(self.value_labels, self.value_units, self._last_values)
):
if not self._available[index]:
# An axis with no curve in this unit: a dash, never a raw count
# presented as a newton value.
value_label.setText(unavailable)
unit_label.setText("")
value_label.setToolTip(
t("chart.value_unavailable_tip", channel=CHANNEL_LABELS[index])
)
continue
value_label.setText(fmt.format(value=value))
value_label.setToolTip("")
unit_label.setText(unit_display(self._unit))
self.unit_badge.setText(unit_display(self._unit))
@@ -10,6 +10,7 @@ video frame, the depth pane and the charts' cursor line together.
from __future__ import annotations
import logging
import threading
from pathlib import Path
@@ -45,6 +46,10 @@ except ImportError as exc: # pragma: no cover
CHANNEL_TOKENS = ("plot_fn", "plot_fx", "plot_fy")
SLIDER_STEPS = 2000
#: Upper bound on the points handed to the replay curves.
CHART_MAX_POINTS = 4000
logger = logging.getLogger(__name__)
class PlaybackDialog(QDialog):
@@ -75,6 +80,13 @@ class PlaybackDialog(QDialog):
self._depth_pending_seq: int | None = None
self._depth_pending_position: float | None = None
self._depth_result_seq: int | None = None
#: Which recording each answer belongs to (incremented on every open).
self._record_serial = 0
self._depth_pending_record: int | None = None
self._depth_result_record: int | None = None
self._depth_result_frame = -1
#: Processors whose owning request was still running when it was retired.
self._retired_recomputers: list = []
self._depth_poll = QTimer(self)
self._depth_poll.setInterval(120)
self._depth_poll.timeout.connect(self._poll_depth)
@@ -166,9 +178,16 @@ class PlaybackDialog(QDialog):
self.warning_label.setText(t("playback.open_failed", error=str(exc)))
def open_recording(self, directory: Path) -> None:
if self._recomputer is not None:
self._recomputer.close()
self._recomputer = None
"""Open a recording. Any depth answer still in flight is invalidated."""
# A running request belongs to the *previous* recording: its result must
# never be pasted into the new one, and its processor must be closed by
# the thread that is using it, not here.
self._retire_recomputer()
self._record_serial += 1
self._depth_request_seq += 1
self._depth_pending_seq = None
self._depth_pending_position = None
self._depth_error = None
self._reader = RecordingReader(directory)
self._recomputer = DepthRecomputer(self._reader)
info = self._reader.info()
@@ -188,19 +207,48 @@ class PlaybackDialog(QDialog):
self._depth_result_seq = None
self.set_position(0.0)
def _retire_recomputer(self) -> None:
"""Hand the current processor to its worker, or close it here if idle.
Closing a processor that a worker thread is still reading is a use-after-
free; only the thread that owns it may close it, so a busy one is marked
for the worker to close when it finishes.
"""
recomputer, self._recomputer = self._recomputer, None
if recomputer is None:
return
if self._depth_busy():
self._retired_recomputers.append(recomputer)
else:
try:
recomputer.close()
except Exception: # noqa: BLE001 - closing is best effort
logger.exception("Could not close the depth recomputer")
def _refresh_warnings(self) -> None:
if self._reader is None:
return
info = self._reader.info()
_ok, sdk_version = DepthRecomputer.sdk_available()
# The reader compares the recorded config hash against the config file as
# it is now, which is what the depth recomputation would actually use.
codes = self._reader.replay_warnings(sdk_version=sdk_version)
if not codes:
self.warning_label.setText("")
return
self.warning_label.setText(
t("playback.warnings", detail=", ".join(t(f"playback.warn_{c}") for c in codes))
)
text = ""
if codes:
text = t(
"playback.warnings",
detail=", ".join(t(f"playback.warn_{c}") for c in codes),
)
if info.decimated:
# The curves are a view, not every sample: say so rather than let the
# operator count points.
note = t(
"playback.decimated",
shown=len(info.times),
total=info.total_rows,
)
text = f"{text} {note}" if text else note
self.warning_label.setText(text)
# ------------------------------------------------------------------ drawing
def _render_curves(self) -> None:
@@ -211,11 +259,21 @@ class PlaybackDialog(QDialog):
for curve in self.curves:
curve.setData([], [])
return
# Same rule as the live charts: invalid or non-finite points are not
# drawn, whichever view is asking.
times, drawn, _mask = info.plottable(self._unit)
# Same rule as the live charts, per axis: a point is drawn only where the
# sample is valid and *that* axis is finite, so a single-axis calibration
# does not hide the axis that is calibrated. For a long recording the
# whole-session view is decimated while it is read from disk, in the unit
# being shown.
window_all = getattr(self._reader, "window_all", None)
if callable(window_all):
window = window_all(unit=self._unit, max_points=CHART_MAX_POINTS)
for index, curve in enumerate(self.curves):
times, values = window.plottable_axis(self._unit, index)
curve.setData(times, values)
return
for index, curve in enumerate(self.curves):
curve.setData(times, drawn[:, index])
times, values = info.plottable_axis(self._unit, index)
curve.setData(times, values)
self.plot_widget.ci.layout.setRowStretchFactor(index, 1)
self.plot_widget.getItem(index, 0).setXRange(
float(info.times[0]), float(info.times[-1]), padding=0.02
@@ -307,22 +365,40 @@ class PlaybackDialog(QDialog):
self._depth_error = None
self.depth_label.setText(t("playback.depth_running"))
position = self._position
# Bind the answer to this request: a result that arrives after the cursor
# moved is discarded rather than pasted onto the new position.
# Bind the answer to this request: the recording, the frame and a request
# sequence. A result that arrives after the cursor moved — or after
# another recording was opened — is discarded rather than pasted on.
recomputer = self._recomputer
record = self._record_serial
frame = self._current_frame()
self._depth_request_seq += 1
seq = self._depth_request_seq
self._depth_pending_seq = seq
self._depth_pending_position = position
self._depth_pending_record = record
def work() -> None:
# Runs off the UI thread and touches no Qt object.
# Runs off the UI thread and touches no Qt object. It captures the
# processor it was given: the dialog may already be showing another
# recording by the time this returns.
try:
result = self._recomputer.depth_at(position)
result = recomputer.depth_at(position)
except BaseException as exc: # noqa: BLE001 - reported via the poll
self._depth_error = exc
self._depth_error = (record, exc)
return
finally:
if recomputer in self._retired_recomputers:
# Retired while this request was running: the owning thread
# closes it, not the UI thread.
self._retired_recomputers.remove(recomputer)
try:
recomputer.close()
except Exception: # noqa: BLE001
logger.exception("Could not close a retired recomputer")
self._depth_result = result
self._depth_result_seq = seq
self._depth_result_record = record
self._depth_result_frame = getattr(frame, "frame_id", -1)
self._depth_thread = threading.Thread(
target=work, name="depth-replay", daemon=True
@@ -337,15 +413,22 @@ class PlaybackDialog(QDialog):
return
self._depth_poll.stop()
pending = self._depth_pending_seq
record = self._depth_pending_record
self._depth_pending_seq = None
self._depth_pending_position = None
self._depth_pending_record = None
if self._depth_error is not None:
error_record, exc = self._depth_error
self._depth_error = None
if error_record != self._record_serial:
# The failure belongs to a recording that is no longer open.
return
self._depth_ok = False
self.depth_label.setPixmap(QPixmap())
self.depth_label.setText(
t("playback.depth_failed", detail=str(self._depth_error))
)
self._depth_error = None
self.depth_label.setText(t("playback.depth_failed", detail=str(exc)))
return
if record != self._record_serial:
# Another recording was opened while this was running.
return
result = self._depth_result
if result is None or self._depth_result_seq != pending:
@@ -468,10 +551,18 @@ class PlaybackDialog(QDialog):
def closeEvent(self, event) -> None: # noqa: N802
self._depth_poll.stop()
# A depth recomputation that is blocked inside the SDK cannot be
# cancelled; it is a daemon thread, so it does not keep the process
# alive and the window always closes.
if self._recomputer is not None and not self._depth_busy():
self._recomputer.close()
# cancelled; it is a daemon thread, so it does not keep the process alive
# and the window always closes. The processor is closed by whichever
# thread owns it (see ``_retire_recomputer``), never by the UI thread
# while a worker is reading it.
for recomputer in list(self._retired_recomputers):
if not self._depth_busy():
self._retired_recomputers.remove(recomputer)
try:
recomputer.close()
except Exception: # noqa: BLE001
logger.exception("Could not close a retired recomputer")
self._retire_recomputer()
super().closeEvent(event)
def _depth_busy(self) -> bool: