73592f085f
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.
140 lines
4.7 KiB
Python
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"
|