Files
orisys-app/shared/tests/test_display_units.py
T
ykcheungab 73592f085f 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.
2026-09-13 16:54:26 +08:00

140 lines
4.7 KiB
Python

"""Display unit selection and the per-axis drawing rule.
Two defects are covered here:
* the live charts chose their unit from the device-level ``force_calibrated``
flag, so a device with only its normal-force axis calibrated either showed
every axis in raw counts or showed raw counts inside newton panels;
* the drawing rule asked for **all three** axes to be finite, so a valid FN
disappeared from the chart whenever FX/FY had no calibrated value.
"""
from __future__ import annotations
import math
import numpy as np
import pytest
from orisys_app_shared.acquisition.sdk_worker import SdkPacket
from orisys_app_shared.force import select_display_forces
from orisys_app_shared.force.session import SessionSample, SessionStore
from orisys_app_shared.record.sample import to_record_sample
NAN = float("nan")
def _packet(**overrides) -> SdkPacket:
frame = np.zeros((4, 4, 3), np.uint8)
kwargs = dict(
raw_frame=frame,
processed_frame=frame,
timestamp=1000.0,
fps=30.0,
process_ms=1.0,
normal_force=1.5,
shear_x=0.25,
shear_y=-0.25,
normal_force_raw=123.0,
shear_x_raw=2.0,
shear_y_raw=-2.0,
force_calibrated=True,
calibrated_axes=("fn", "fx", "fy"),
frame_id=0,
)
kwargs.update(overrides)
return SdkPacket(**kwargs)
def test_newton_mode_marks_uncalibrated_axes_unavailable():
sample = to_record_sample(_packet(calibrated_axes=("fn",)))
display = select_display_forces(units_pref="newton", sample=sample)
assert display.unit == "N"
assert display.values[0] == pytest.approx(1.5)
assert math.isnan(display.values[1]) and math.isnan(display.values[2])
assert display.available == (True, False, False)
assert display.unavailable_axes() == ("fx", "fy")
assert not display.using_raw_fallback
def test_raw_mode_shows_the_raw_counts_for_every_axis():
sample = to_record_sample(_packet(calibrated_axes=("fn",)))
display = select_display_forces(units_pref="raw", sample=sample)
assert display.unit == "raw"
assert display.values == (123.0, 2.0, -2.0)
assert display.available == (True, True, True)
assert not display.using_raw_fallback
def test_an_uncalibrated_device_falls_back_to_raw_and_says_so():
sample = to_record_sample(
_packet(force_calibrated=False, calibrated_axes=())
)
display = select_display_forces(units_pref="newton", sample=sample)
assert display.unit == "raw"
assert display.using_raw_fallback, "the fallback must be announceable"
assert display.requested_unit == "N"
assert display.values == (123.0, 2.0, -2.0)
def test_a_failed_readout_has_no_available_axis():
sample = to_record_sample(
_packet(valid=False, invalid_reason="compute failed", stale_forces=True)
)
for unit in ("N", "raw"):
display = select_display_forces(units_pref=unit, sample=sample)
assert display.available == (False, False, False)
assert all(math.isnan(value) for value in display.values)
def _store_with_one_calibrated_axis() -> SessionStore:
store = SessionStore()
for index in range(5):
store.append(
SessionSample(
index,
index / 30.0,
fn_raw=100.0 + index,
fx_raw=2.0,
fy_raw=-2.0,
fn_n=1.0 + index * 0.1,
fx_n=NAN,
fy_n=NAN,
valid=True,
calibrated_axes=("fn",),
)
)
return store
def test_a_valid_axis_is_drawn_even_when_the_others_have_no_value():
"""The all-axes rule hid a good FN because FX/FY were uncalibrated."""
slice_ = _store_with_one_calibrated_axis().data()
times, values, mask = slice_.plottable("N")
assert mask.shape == (5, 3)
assert mask[:, 0].all(), "the calibrated axis was hidden"
assert not mask[:, 1].any()
assert not mask[:, 2].any()
fn_times, fn_values = slice_.plottable_axis("N", 0)
assert fn_times.size == 5 == fn_values.size
assert float(fn_values.max()) == pytest.approx(1.4)
fx_times, _fx_values = slice_.plottable_axis("N", 1)
assert fx_times.size == 0
def test_the_raw_triplet_is_still_complete_for_the_same_samples():
slice_ = _store_with_one_calibrated_axis().data()
for index in range(3):
times, values = slice_.plottable_axis("raw", index)
assert times.size == 5
assert np.isfinite(values).all()
def test_an_invalid_sample_is_excluded_from_every_axis():
store = _store_with_one_calibrated_axis()
store.append(SessionSample(9, 9.0, fn_n=NAN, valid=False))
slice_ = store.data()
_times, _values, mask = slice_.plottable("N")
assert not mask[-1].any(), "an invalid sample reached a drawing path"