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:
@@ -144,6 +144,10 @@ def _fake_packet(
|
||||
valid: bool = True,
|
||||
depth=None,
|
||||
reason: str = "",
|
||||
calibrated_axes=None,
|
||||
timestamp: float | None = None,
|
||||
mono_t: float | None = None,
|
||||
shear_n: float | None = None,
|
||||
):
|
||||
"""A packet shaped like the real worker's, for the record-channel checks."""
|
||||
import numpy as np
|
||||
@@ -157,17 +161,19 @@ def _fake_packet(
|
||||
return SdkPacket(
|
||||
raw_frame=blank,
|
||||
processed_frame=blank,
|
||||
timestamp=1000.0 + frame_id / 30.0,
|
||||
timestamp=1000.0 + frame_id / 30.0 if timestamp is None else timestamp,
|
||||
mono_t=mono_t,
|
||||
fps=30.0,
|
||||
process_ms=1.0,
|
||||
normal_force=(2.0 + frame_id * 0.01) if valid else nan,
|
||||
shear_x=0.5 if valid else nan,
|
||||
shear_x=(0.5 if shear_n is None else shear_n) if valid else nan,
|
||||
shear_y=-0.5 if valid else nan,
|
||||
normal_force_raw=(9.0 + frame_id * 0.1) if valid else nan,
|
||||
shear_x_raw=0.4 if valid else nan,
|
||||
shear_y_raw=-0.4 if valid else nan,
|
||||
force_unit="N",
|
||||
force_calibrated=True,
|
||||
calibrated_axes=tuple(calibrated_axes or ()),
|
||||
session_id=1,
|
||||
frame_id=frame_id,
|
||||
valid=valid,
|
||||
@@ -194,6 +200,27 @@ class _StuckWorker(RecordSinkMixin):
|
||||
self.stop_blocked_for = 0.0
|
||||
#: Released by ``release()`` so daemon threads do not outlive the run.
|
||||
self._release = threading.Event()
|
||||
#: Calibration requests this worker was asked to apply.
|
||||
self.calibration_requests: list[str] = []
|
||||
#: Zeroing requests, and the confirmation callback the window installs.
|
||||
self.reset_requests = 0
|
||||
self.on_reset_applied = None
|
||||
|
||||
def set_pending_calibration(self, path) -> None:
|
||||
self.calibration_requests.append(str(path))
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Request a zeroing; a real device confirms it from its own thread.
|
||||
|
||||
This stub confirms immediately (a fast device), so the window's event path
|
||||
— request, confirmation, recorded event — is exercised end to end.
|
||||
"""
|
||||
self.reset_requests += 1
|
||||
if callable(self.on_reset_applied):
|
||||
packet = self._latest
|
||||
frame_id = int(getattr(packet, "frame_id", -1))
|
||||
mono_t = float(getattr(packet, "mono_t", 0.0) or 0.0)
|
||||
self.on_reset_applied(frame_id, mono_t, 1)
|
||||
|
||||
def start(self, _source, *, config_ref=None) -> None:
|
||||
self._release.clear()
|
||||
@@ -229,9 +256,6 @@ class _StuckWorker(RecordSinkMixin):
|
||||
reason, self._end_reason = self._end_reason, None
|
||||
return reason
|
||||
|
||||
def reset(self) -> None:
|
||||
return None
|
||||
|
||||
def stop(self, *, wait: bool = False) -> None:
|
||||
self._end_reason = self._end_reason or "stopped"
|
||||
started = time.perf_counter()
|
||||
@@ -666,8 +690,10 @@ def main() -> int:
|
||||
)
|
||||
from orisys_app_shared.force.calibration_io import (
|
||||
build_export_model,
|
||||
build_force_calibration_v2 as _build_v2,
|
||||
predict_channel,
|
||||
)
|
||||
import orisys.calibration as _sdk_calibration
|
||||
|
||||
store = window.cal_panel._store
|
||||
model = build_export_model(
|
||||
@@ -678,6 +704,44 @@ def main() -> int:
|
||||
abs(predicted - 2.0) > 0.05,
|
||||
f"excluding a point left the exported model unchanged: {predicted}",
|
||||
)
|
||||
# One preparation: the store the panel shows is the prepared model's
|
||||
# input, and the export serialises that same object.
|
||||
prepared = window.cal_panel._prepared
|
||||
check(
|
||||
prepared is not None and prepared.ok,
|
||||
"the panel has no prepared calibration to show or export",
|
||||
)
|
||||
check(
|
||||
list(store.channel("fn")) == list(prepared.samples["fn"]),
|
||||
"the panel store is not the prepared model's input",
|
||||
)
|
||||
doc, _warnings = _build_v2(prepared)
|
||||
export_dir = APP_ROOT / "outputs" / "_cal_export_check"
|
||||
if export_dir.exists():
|
||||
for stale in export_dir.iterdir():
|
||||
stale.unlink()
|
||||
export_dir.mkdir(parents=True, exist_ok=True)
|
||||
written = export_dir / "exported_calibration.json"
|
||||
import json as _cal_json
|
||||
|
||||
written.write_text(_cal_json.dumps(doc), encoding="utf-8")
|
||||
loaded = _sdk_calibration.load(written, strict=True)
|
||||
for point in window.cal_recorder.points:
|
||||
if point.excluded:
|
||||
continue
|
||||
raw = float(point.loaded_stats().mean)
|
||||
check(
|
||||
abs(
|
||||
float(loaded.get("fn").convert(raw))
|
||||
- prepared.predict("fn", raw)
|
||||
)
|
||||
<= 1e-9,
|
||||
"the exported file and the panel disagree about the model",
|
||||
)
|
||||
check(
|
||||
len(prepared.samples["fn"]) == 3,
|
||||
f"the prepared build has {len(prepared.samples['fn'])} points, not 3",
|
||||
)
|
||||
window.cal_recorder.reset_session()
|
||||
window.cal_panel._refresh_table()
|
||||
|
||||
@@ -961,7 +1025,8 @@ def main() -> int:
|
||||
app.processEvents()
|
||||
check(window.recorder.is_recording, "the recording did not start")
|
||||
check(
|
||||
window.toolbar.btn_record.isEnabled() and window.toolbar.btn_record.text() == t("record.stop_label"),
|
||||
window.toolbar.btn_record.isEnabled()
|
||||
and window.toolbar.btn_record.text() == t("record.stop_label"),
|
||||
"the record button is not a usable stop control",
|
||||
)
|
||||
check(
|
||||
@@ -974,13 +1039,26 @@ def main() -> int:
|
||||
window._on_run_tick()
|
||||
stuck.emit(_fake_packet(90, valid=False, reason="read failed"))
|
||||
window._on_run_tick()
|
||||
window._on_reset() # event: zeroing
|
||||
# The zeroing is a *request*; the event is recorded only once the acquisition
|
||||
# thread confirms it took effect (the stub confirms immediately).
|
||||
stuck.on_reset_applied = window._on_reset_applied
|
||||
window._on_reset()
|
||||
window._on_run_tick()
|
||||
app.processEvents()
|
||||
|
||||
window.toolbar.btn_record.setChecked(False)
|
||||
app.processEvents()
|
||||
report = window._record_last_report
|
||||
check(not window.recorder.is_recording, "the recording did not stop")
|
||||
check(report is not None and report.ok, f"recording reported problems: {getattr(report, 'problems', None)}")
|
||||
check(
|
||||
not window.toolbar.btn_record.isChecked(),
|
||||
"the record button still looks active after the recording stopped",
|
||||
)
|
||||
check(
|
||||
window._record_report_pending is False,
|
||||
"a finished recording is still waiting for a report",
|
||||
)
|
||||
|
||||
import csv as _csv
|
||||
import json as _json
|
||||
@@ -1019,6 +1097,388 @@ def main() -> int:
|
||||
stuck.release()
|
||||
_shutil.rmtree(scratch, ignore_errors=True)
|
||||
|
||||
section("record failure states")
|
||||
|
||||
from unittest.mock import patch as _patch
|
||||
|
||||
from orisys_app_shared.record.experiment import _SessionWriter as _Writer
|
||||
|
||||
scratch_fail = APP_ROOT / "outputs" / "_record_fail"
|
||||
_shutil.rmtree(scratch_fail, ignore_errors=True)
|
||||
scratch_fail.mkdir(parents=True, exist_ok=True)
|
||||
stuck_fail = _StuckWorker()
|
||||
fail_window = StudioMainWindow(
|
||||
prefs={
|
||||
"language": "zh_CN",
|
||||
"theme": "dark",
|
||||
"sensor_type": "box",
|
||||
"source_mode": "camera",
|
||||
}
|
||||
)
|
||||
fail_window.resize(1440, 880)
|
||||
fail_window.show()
|
||||
fail_window._ensure_workers = lambda: None
|
||||
fail_window._box_worker = stuck_fail
|
||||
fail_window._finger_worker = stuck_fail
|
||||
fail_window._start_run()
|
||||
fail_window._run_timer.stop()
|
||||
fail_window._set_phase("running")
|
||||
|
||||
# A directory that cannot be created is a terminal, retryable failure.
|
||||
blocked = scratch_fail / "blocked"
|
||||
blocked.write_text("", encoding="utf-8")
|
||||
_app_module.recordings_dir = lambda: blocked
|
||||
fail_window.toolbar.btn_record.setChecked(True)
|
||||
app.processEvents()
|
||||
check(
|
||||
not fail_window.recorder.is_recording,
|
||||
"a recording started even though its directory could not be created",
|
||||
)
|
||||
check(
|
||||
not fail_window.toolbar.btn_record.isChecked(),
|
||||
"the record button stayed active after the start failed",
|
||||
)
|
||||
check(
|
||||
fail_window.recorder.state == "failed",
|
||||
f"a failed start left the recorder in {fail_window.recorder.state!r}",
|
||||
)
|
||||
|
||||
# ...and the operator can simply try again.
|
||||
_app_module.recordings_dir = lambda: scratch_fail
|
||||
fail_window.toolbar.btn_record.setChecked(True)
|
||||
app.processEvents()
|
||||
check(
|
||||
fail_window.recorder.is_recording,
|
||||
"the recorder refused to retry after a failed directory",
|
||||
)
|
||||
|
||||
# A writer that keeps failing must fail the recording, and the window must
|
||||
# consume that report even though ``is_recording`` is already False.
|
||||
with _patch.object(_Writer, "write_sample", side_effect=OSError("disk full")):
|
||||
for index in range(8):
|
||||
stuck_fail.emit(_fake_packet(index))
|
||||
fail_window._on_run_tick()
|
||||
deadline = time.monotonic() + 3.0
|
||||
while (
|
||||
fail_window.recorder.stats().write_errors < 5
|
||||
and time.monotonic() < deadline
|
||||
):
|
||||
app.processEvents()
|
||||
time.sleep(0.01)
|
||||
check(
|
||||
fail_window.recorder.stats().failed,
|
||||
"repeated write failures never failed the recording",
|
||||
)
|
||||
check(
|
||||
not fail_window.recorder.is_recording,
|
||||
"a dead writer still claimed to be recording",
|
||||
)
|
||||
fail_window._stop_recording()
|
||||
app.processEvents()
|
||||
failed_report = fail_window._record_last_report
|
||||
check(
|
||||
failed_report is not None and failed_report.stats.failed,
|
||||
"the window did not consume the failure report",
|
||||
)
|
||||
check(
|
||||
not fail_window.toolbar.btn_record.isChecked(),
|
||||
"the record button stayed active after the recording failed",
|
||||
)
|
||||
check(
|
||||
fail_window._session_phase == "error",
|
||||
f"the session phase is {fail_window._session_phase!r} after a failed recording",
|
||||
)
|
||||
|
||||
fail_window.close()
|
||||
app.processEvents()
|
||||
stuck_fail.release()
|
||||
_app_module.recordings_dir = lambda: scratch
|
||||
_shutil.rmtree(scratch_fail, ignore_errors=True)
|
||||
|
||||
section("calibration record identity")
|
||||
|
||||
import json as _cal_json
|
||||
from unittest.mock import patch as _patch_mismatch
|
||||
|
||||
import orisys_studio.ui.cal_panel as _cal_panel_module
|
||||
import orisys_studio.calibration_service as _cal_service_module
|
||||
from PyQt5.QtWidgets import QMessageBox as _QMessageBox
|
||||
|
||||
from orisys_app_shared.force.calibration_io import (
|
||||
build_force_calibration_v2 as _build_v2b,
|
||||
write_force_calibration as _write_cal,
|
||||
)
|
||||
from orisys_app_shared.force.calibration_session import (
|
||||
build_session_document as _build_session_doc,
|
||||
)
|
||||
|
||||
record_dir = APP_ROOT / "outputs" / "_cal_record_check"
|
||||
if record_dir.exists():
|
||||
for stale in record_dir.iterdir():
|
||||
stale.unlink()
|
||||
record_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
identity_window = StudioMainWindow(
|
||||
prefs={
|
||||
"language": "zh_CN",
|
||||
"theme": "dark",
|
||||
"sensor_type": "box",
|
||||
"source_mode": "camera",
|
||||
}
|
||||
)
|
||||
identity_window.resize(1400, 880)
|
||||
identity_window.show()
|
||||
identity_window.cal_panel.set_running(True)
|
||||
taken_points = []
|
||||
for load, raw in ((1.0, 100.0), (3.0, 300.0)):
|
||||
identity_window.cal_recorder.start(
|
||||
channel="fn", reference_load_n=load, duration_s=1.0
|
||||
)
|
||||
for index in range(20):
|
||||
identity_window.cal_recorder.feed(
|
||||
_calibration_sample(index, fn_raw=raw)
|
||||
)
|
||||
taken_points.append(identity_window.cal_recorder.finish_now())
|
||||
device_a = record_dir / "device_a_session.json"
|
||||
device_a.write_text(
|
||||
_cal_json.dumps(
|
||||
_build_session_doc(
|
||||
taken_points,
|
||||
meta={
|
||||
"sensor_type": "box",
|
||||
"sn": "SN-A",
|
||||
"config_ref": "D:/lab/deviceA/sensor.json",
|
||||
},
|
||||
)
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
identity_window.cal_recorder.clear_points()
|
||||
|
||||
# The live device is B; opening A's record must not restamp it.
|
||||
panel = identity_window.cal_panel
|
||||
panel.set_meta_context(sensor_type="finger", config_ref="D:/lab/deviceB/sensor.json")
|
||||
panel.sn_edit.setText("SN-B")
|
||||
panel._on_open_record(str(device_a))
|
||||
app.processEvents()
|
||||
check(
|
||||
panel.sn_edit.text() == "SN-A",
|
||||
f"the reopened record's SN was overwritten: {panel.sn_edit.text()!r}",
|
||||
)
|
||||
meta = panel._export_meta()
|
||||
check(meta["sensor_type"] == "box", "the record's sensor type was overwritten")
|
||||
check(meta["sn"] == "SN-A", "the record's SN was not kept")
|
||||
check(
|
||||
meta["config_ref"] == "D:/lab/deviceA/sensor.json",
|
||||
f"the record's config was overwritten: {meta['config_ref']!r}",
|
||||
)
|
||||
check(
|
||||
str(meta.get("source_record", "")).endswith(device_a.name),
|
||||
"the export does not reference the source record",
|
||||
)
|
||||
saved = sorted(record_dir.glob("calibration_session_box_*.json"))
|
||||
with _patch_mismatch.object(
|
||||
_cal_panel_module, "calibration_export_dir", lambda: record_dir
|
||||
):
|
||||
with _patch_mismatch.object(
|
||||
_cal_service_module, "calibration_export_dir", lambda: record_dir
|
||||
):
|
||||
with _patch_mismatch.object(
|
||||
_cal_panel_module.QMessageBox, "information", lambda *a, **k: None
|
||||
):
|
||||
panel._on_save_record()
|
||||
saved = sorted(record_dir.glob("calibration_session_box_*.json"))
|
||||
check(bool(saved), "saving a reopened record produced no file")
|
||||
if saved:
|
||||
saved_meta = _cal_json.loads(saved[-1].read_text(encoding="utf-8"))["meta"]
|
||||
check(
|
||||
saved_meta.get("sn") == "SN-A" and saved_meta.get("sensor_type") == "box",
|
||||
f"the saved record was restamped: {saved_meta.get('sn')!r}",
|
||||
)
|
||||
doc, _warnings = _build_v2b(panel._prepared)
|
||||
check(
|
||||
doc["meta"]["sn"] == "SN-A" and doc["meta"]["sensor_type"] == "box",
|
||||
"the exported coefficients carry the wrong device identity",
|
||||
)
|
||||
|
||||
# A reopened point with only its samples can still be inspected.
|
||||
reopened = identity_window.cal_recorder.points[0]
|
||||
for entry in _build_session_doc([reopened])["points"]:
|
||||
entry["trace"] = {}
|
||||
dialog = panel.point_dialog(reopened)
|
||||
check(dialog is not None, "a reopened point could not be inspected")
|
||||
if dialog is not None:
|
||||
check(
|
||||
len(dialog.plot_widget.ci.items) == 3,
|
||||
"the rebuilt diagnostic is not one trace per axis",
|
||||
)
|
||||
dialog.close()
|
||||
|
||||
# Applying a calibration that names another device is flagged and defaults
|
||||
# to "no".
|
||||
stuck_identity = _StuckWorker()
|
||||
identity_window._ensure_workers = lambda: None
|
||||
identity_window._box_worker = stuck_identity
|
||||
identity_window._finger_worker = stuck_identity
|
||||
coefficients = record_dir / "device_a_coefficients.json"
|
||||
_write_cal(doc, coefficients)
|
||||
check(
|
||||
bool(
|
||||
identity_window.calibration_target_mismatch(
|
||||
str(coefficients),
|
||||
sensor_type="finger",
|
||||
config_ref="D:/lab/B/sensor.json",
|
||||
)
|
||||
),
|
||||
"a calibration for another device was not reported as a mismatch",
|
||||
)
|
||||
check(
|
||||
not identity_window.calibration_target_mismatch(
|
||||
str(coefficients),
|
||||
sensor_type="box",
|
||||
config_ref="D:/lab/deviceA/sensor.json",
|
||||
),
|
||||
"matching identities were reported as a mismatch",
|
||||
)
|
||||
with _patch_mismatch.object(
|
||||
_QMessageBox, "question", staticmethod(lambda *a, **k: _QMessageBox.No)
|
||||
):
|
||||
identity_window._on_calibration_apply(str(coefficients))
|
||||
check(
|
||||
not stuck_identity.calibration_requests,
|
||||
"a mismatched calibration was applied after the user declined",
|
||||
)
|
||||
check(
|
||||
identity_window.statusBar().currentMessage() == t("cal.apply_mismatch_skipped"),
|
||||
f"the refusal was not reported: {identity_window.statusBar().currentMessage()!r}",
|
||||
)
|
||||
# A matching calibration still goes through the normal confirmation.
|
||||
identity_window.sensor_picker.set_sensor_type("box")
|
||||
with _patch_mismatch.object(
|
||||
_QMessageBox, "question", staticmethod(lambda *a, **k: _QMessageBox.Yes)
|
||||
):
|
||||
identity_window._on_calibration_apply(str(coefficients))
|
||||
check(
|
||||
stuck_identity.calibration_requests == [str(coefficients)],
|
||||
f"a matching calibration was not requested: {stuck_identity.calibration_requests}",
|
||||
)
|
||||
|
||||
identity_window.close()
|
||||
app.processEvents()
|
||||
_shutil.rmtree(record_dir, ignore_errors=True)
|
||||
|
||||
section("display units and time base")
|
||||
import math as _math
|
||||
|
||||
stuck_units = _StuckWorker()
|
||||
units_window = StudioMainWindow(
|
||||
prefs={
|
||||
"language": "zh_CN",
|
||||
"theme": "dark",
|
||||
"sensor_type": "box",
|
||||
"source_mode": "camera",
|
||||
}
|
||||
)
|
||||
units_window.resize(1440, 880)
|
||||
units_window.show()
|
||||
units_window._ensure_workers = lambda: None
|
||||
units_window._box_worker = stuck_units
|
||||
units_window._finger_worker = stuck_units
|
||||
units_window._start_run()
|
||||
units_window._run_timer.stop()
|
||||
units_window._set_phase("running")
|
||||
units_plots = units_window.force_plots
|
||||
units_window.toolbar.units_combo.setCurrentIndex(0) # newton
|
||||
units_plots.reset()
|
||||
wall = 1_700_000_000.0
|
||||
for index in range(90):
|
||||
stuck_units.emit(
|
||||
_fake_packet(index, timestamp=wall + index / 30.0, mono_t=index / 30.0)
|
||||
)
|
||||
units_window._on_run_tick()
|
||||
before = units_plots.history.state()
|
||||
end_before = float(before.times[-1]) if before is not None else -1.0
|
||||
check(
|
||||
before is not None and end_before < 60.0,
|
||||
f"the live X axis is not session-relative (ends at {end_before})",
|
||||
)
|
||||
for units_index in (1, 0, 1, 0):
|
||||
units_window.toolbar.units_combo.setCurrentIndex(units_index)
|
||||
app.processEvents()
|
||||
after = units_plots.history.state()
|
||||
check(
|
||||
after is not None and abs(float(after.times[-1]) - end_before) < 1.0,
|
||||
"the X axis jumped across a raw/N switch: "
|
||||
f"{end_before} -> {None if after is None else after.times[-1]}",
|
||||
)
|
||||
check(
|
||||
units_plots.history.sample_count > 0,
|
||||
"the rebuilt history lost every sample",
|
||||
)
|
||||
units_window.close()
|
||||
units_window._box_worker = None
|
||||
stuck_units.release()
|
||||
app.processEvents()
|
||||
|
||||
# A device with only its normal-force axis calibrated: FN in newtons, and the
|
||||
# other two axes explicitly unavailable instead of showing raw counts.
|
||||
stuck_one = _StuckWorker()
|
||||
one_window = StudioMainWindow(
|
||||
prefs={
|
||||
"language": "zh_CN",
|
||||
"theme": "dark",
|
||||
"sensor_type": "box",
|
||||
"source_mode": "camera",
|
||||
}
|
||||
)
|
||||
one_window.resize(1440, 880)
|
||||
one_window.show()
|
||||
one_window._ensure_workers = lambda: None
|
||||
one_window._box_worker = stuck_one
|
||||
one_window._finger_worker = stuck_one
|
||||
one_window._start_run()
|
||||
one_window._run_timer.stop()
|
||||
one_window._set_phase("running")
|
||||
one_window.toolbar.units_combo.setCurrentIndex(0) # newton
|
||||
one_window.force_plots.reset()
|
||||
for index in range(10):
|
||||
stuck_one.emit(
|
||||
_fake_packet(index, calibrated_axes=("fn",), shear_n=float("nan"))
|
||||
)
|
||||
one_window._on_run_tick()
|
||||
app.processEvents()
|
||||
one_plots = one_window.force_plots
|
||||
check(
|
||||
one_plots._available == (True, False, False),
|
||||
f"per-axis availability is wrong: {one_plots._available}",
|
||||
)
|
||||
check(
|
||||
one_plots.value_labels[0].text() != t("chart.value_unavailable"),
|
||||
"the calibrated axis was reported as unavailable",
|
||||
)
|
||||
check(
|
||||
all(
|
||||
one_plots.value_labels[index].text() == t("chart.value_unavailable")
|
||||
for index in (1, 2)
|
||||
),
|
||||
"an axis without a curve showed a number in newton mode",
|
||||
)
|
||||
one_state = one_plots.history.state()
|
||||
check(
|
||||
one_state is not None
|
||||
and _math.isfinite(float(one_state.values[:, 0].max())),
|
||||
"the calibrated axis was not plotted",
|
||||
)
|
||||
check(
|
||||
one_state is not None
|
||||
and not any(_math.isfinite(v) for v in one_state.values[:, 1]),
|
||||
"FX carried a value without a calibration curve",
|
||||
)
|
||||
one_window.close()
|
||||
one_window._box_worker = None
|
||||
stuck_one.release()
|
||||
app.processEvents()
|
||||
|
||||
section("session browsing")
|
||||
import numpy as np
|
||||
|
||||
@@ -1190,6 +1650,31 @@ def main() -> int:
|
||||
all(int(curve.getData()[0].size) == 40 for curve in playback.curves),
|
||||
"replay did not draw every force sample",
|
||||
)
|
||||
|
||||
# Long-history browsing: an interval read is bounded by the interval, and the
|
||||
# whole-session envelope is a band rather than every row.
|
||||
from orisys_app_shared.force.source import RecordingCurveSource
|
||||
from orisys_app_shared.record.index import RecordingIndex
|
||||
from orisys_app_shared.record.playback import RecordingReader as _RecordingReader
|
||||
|
||||
reader = _RecordingReader(recording, max_rows=10)
|
||||
source = RecordingCurveSource(reader)
|
||||
check(source.kind == "disk", "the replay source is not the recording")
|
||||
index = reader.index()
|
||||
check(index.rows == 40, f"the index found {index.rows} rows, not 40")
|
||||
start, stop = index.row_range(0.5, 1.0)
|
||||
check(
|
||||
0 <= start < stop <= index.rows and (stop - start) <= 20,
|
||||
f"an interval read spans {stop - start} rows",
|
||||
)
|
||||
interval = source.window(0.5, 1.0, unit="N")
|
||||
check(0 < len(interval) <= 20, f"the interval read returned {len(interval)} rows")
|
||||
envelope = source.envelope(buckets=8, unit="N")
|
||||
check(0 < len(envelope) <= 8, f"the envelope has {len(envelope)} points")
|
||||
check(
|
||||
reader.info().total_rows == 40,
|
||||
f"the recording reports {reader.info().total_rows} rows",
|
||||
)
|
||||
check(playback.slider.isEnabled(), "the replay position slider is disabled")
|
||||
check(
|
||||
t("playback.warn_depth_recomputed").split("{")[0].strip()[:4]
|
||||
@@ -1268,6 +1753,92 @@ def main() -> int:
|
||||
|
||||
playback.close()
|
||||
app.processEvents()
|
||||
|
||||
# The plan must state where to start, which reset it is anchored to and
|
||||
# whether the state is really recoverable; and a request must belong to the
|
||||
# recording it started in.
|
||||
from orisys_app_shared.record.depth import (
|
||||
STATE_APPROXIMATE,
|
||||
STATE_PREROLL,
|
||||
DepthRecomputer,
|
||||
)
|
||||
from orisys_app_shared.record.playback import RecordingReader
|
||||
|
||||
plan_reader = RecordingReader(recording)
|
||||
planner = DepthRecomputer(plan_reader)
|
||||
frames = plan_reader.info().frames
|
||||
check(bool(frames), "the replay recording has no frame index")
|
||||
plan = planner.plan_for(frames[10])
|
||||
check(
|
||||
plan.state == STATE_APPROXIMATE and plan.anchor == "segment_start",
|
||||
f"a recording with no reset/pre-roll is not reported as approximate: {plan.state}",
|
||||
)
|
||||
check(
|
||||
plan.steps and plan.steps[0].source == plan_reader.segment_path(frames[10].segment),
|
||||
"the plan does not name the video to read",
|
||||
)
|
||||
check(
|
||||
"state_approximate" in plan.warnings,
|
||||
f"the approximate replay is not reported: {plan.warnings}",
|
||||
)
|
||||
# A pre-roll makes the start of a recording reproducible.
|
||||
session_path = recording / "session.json"
|
||||
session_doc = _json.loads(session_path.read_text(encoding="utf-8"))
|
||||
prerecord = recording / "preroll_0001.mp4"
|
||||
prerecord.write_bytes(b"")
|
||||
session_doc["preroll_frames"] = 30
|
||||
session_doc["preroll_segment"] = prerecord.name
|
||||
session_path.write_text(_json.dumps(session_doc), encoding="utf-8")
|
||||
planner.close()
|
||||
planner = DepthRecomputer(RecordingReader(recording))
|
||||
plan = planner.plan_for(planner.reader.info().frames[5])
|
||||
check(
|
||||
plan.state == STATE_PREROLL and plan.preroll == prerecord,
|
||||
f"the pre-roll was not used for the start of the recording: {plan.state}",
|
||||
)
|
||||
check(plan.frames_to_read == 30 + 6, f"unexpected replay cost: {plan.frames_to_read}")
|
||||
planner.close()
|
||||
|
||||
# A reset boundary is re-applied, and a request is bound to its recording.
|
||||
_Blocking.released.clear()
|
||||
second = PlaybackDialog(theme="dark", directory=recording)
|
||||
second.show()
|
||||
app.processEvents()
|
||||
serial_before = second._record_serial
|
||||
blocking = _Blocking()
|
||||
second._recomputer = blocking
|
||||
second._on_recompute_depth()
|
||||
check(second._depth_pending_record == serial_before, "the request has no recording id")
|
||||
check(
|
||||
second._depth_pending_seq is not None,
|
||||
"the request carries no sequence number",
|
||||
)
|
||||
# Opening another recording invalidates the running request...
|
||||
second.open_recording(recording)
|
||||
check(
|
||||
second._record_serial == serial_before + 1,
|
||||
"opening a recording did not invalidate the running request",
|
||||
)
|
||||
check(
|
||||
second._recomputer is not blocking,
|
||||
"the invalidated processor was not replaced",
|
||||
)
|
||||
_Blocking.released.set()
|
||||
for _ in range(40):
|
||||
app.processEvents()
|
||||
_time.sleep(0.05)
|
||||
if not second._depth_busy():
|
||||
break
|
||||
second._poll_depth()
|
||||
app.processEvents()
|
||||
pixmap = second.depth_label.pixmap()
|
||||
check(
|
||||
pixmap is None or pixmap.isNull(),
|
||||
"an answer for a previous recording was drawn into the new one",
|
||||
)
|
||||
second.close()
|
||||
app.processEvents()
|
||||
|
||||
_shutil.rmtree(replay_root, ignore_errors=True)
|
||||
|
||||
print(f"\nscenes: {scene_count} checks: {CHECKS} failures: {len(FAILURES)}")
|
||||
|
||||
Reference in New Issue
Block a user