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:
@@ -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,
|
||||
)
|
||||
@@ -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)),
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user