新增统一力读数会话 ForceSession
把同帧测量、空载归零、模型推断与共用后处理收进一个 SDK 会话,Studio 显示与记录使用同一样本;旧 Sensor 读数接口保持兼容。以 force session 与受影响 SDK 套件、lint 与文档门禁验证,硬件与真实载荷精度未验证。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CpCfJF6SvonMKmPZhzKZHb
This commit is contained in:
@@ -628,3 +628,85 @@ def test_every_preset_reports_what_it_does(
|
||||
assert fp["measurement"]["flow_ema_alpha"] == pytest.approx(alpha)
|
||||
assert fp["readout"]["force_median_window"] == median
|
||||
assert fp["readout"]["force_lowpass_cutoff_hz"] == pytest.approx(cutoff)
|
||||
|
||||
|
||||
# ------------------------------------------------- session reference adapter
|
||||
def _nonlinear_scalar_calibration(schema_version: int = 2):
|
||||
return parse(
|
||||
{
|
||||
"schema_version": schema_version,
|
||||
"unit": "N",
|
||||
"channels": {
|
||||
"normal": {
|
||||
"method": "piecewise_linear",
|
||||
"points": [[0.0, 0.0], [100.0, 1.0], [200.0, 3.0]],
|
||||
"extrapolate": True,
|
||||
"clamp_output_min": None,
|
||||
},
|
||||
"shear_x": {
|
||||
"method": "piecewise_linear",
|
||||
"signed": True,
|
||||
"input": "components",
|
||||
"points": [[0.0, 0.0], [100.0, 2.0]],
|
||||
"extrapolate": True,
|
||||
"clamp_output_min": None,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("schema_version", [1, 2])
|
||||
def test_convert_referenced_subtracts_the_session_baseline_once(schema_version: int):
|
||||
calibration = _nonlinear_scalar_calibration(schema_version)
|
||||
curve = calibration.get("fn")
|
||||
assert curve is not None
|
||||
reference = {"fn": 150.0}
|
||||
converted = calibration.convert_referenced((250.0, 0.0, 0.0), reference)
|
||||
# curve(250) - curve(150) = 4.0 - 2.0: one subtraction, and the curve's own
|
||||
# offset/zero point is not removed a second time.
|
||||
assert converted[0] == pytest.approx(4.0 - 2.0)
|
||||
assert converted[0] != pytest.approx(4.0 - 2.0 * 2.0)
|
||||
# A raw-domain delta would convert 250-150 = 100 (1.0) instead of 4.0-2.0.
|
||||
assert converted[0] != pytest.approx(1.0)
|
||||
# The historical entry point is unchanged.
|
||||
assert calibration.convert(250.0, 0.0, 0.0)[0] == pytest.approx(4.0)
|
||||
|
||||
|
||||
def test_convert_referenced_keeps_signed_shear_and_missing_axes_unavailable():
|
||||
calibration = _nonlinear_scalar_calibration()
|
||||
converted = calibration.convert_referenced(
|
||||
(250.0, -50.0, 12.0), {"fn": 150.0, "fx": 10.0}
|
||||
)
|
||||
assert converted[0] == pytest.approx(2.0)
|
||||
# signed curve: convert_component(-50) - convert_component(10) = -1.0 - 0.2.
|
||||
assert converted[1] == pytest.approx(-1.2)
|
||||
# FY has no curve, no reference: unavailable, never a zero.
|
||||
assert converted[2] is None
|
||||
missing_reference = calibration.convert_referenced((250.0, 0.0, 0.0), None)
|
||||
assert missing_reference == (None, None, None)
|
||||
invalid_raw = calibration.convert_referenced(
|
||||
(float("nan"), 0.0, 0.0), {"fn": 150.0}
|
||||
)
|
||||
assert invalid_raw[0] is None
|
||||
|
||||
|
||||
def test_legacy_fingerprint_is_content_identity():
|
||||
calibration = _nonlinear_scalar_calibration()
|
||||
assert len(calibration.fingerprint) == 64
|
||||
assert calibration.fingerprint == _nonlinear_scalar_calibration().fingerprint
|
||||
changed = parse(
|
||||
{
|
||||
"schema_version": 2,
|
||||
"unit": "N",
|
||||
"channels": {
|
||||
"normal": {
|
||||
"method": "piecewise_linear",
|
||||
"points": [[0.0, 0.0], [100.0, 1.0], [200.0, 4.0]],
|
||||
"extrapolate": True,
|
||||
"clamp_output_min": None,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
assert changed.fingerprint != calibration.fingerprint
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""HHD downsample compatibility diagnostics and non-divisible grid integration.
|
||||
|
||||
The pipeline contract allows a model calibrated at one ``hhd_downsample`` to run at
|
||||
another, but the difference is an accuracy risk that must be visible instead of a
|
||||
silent pass. These tests pin the report, the unchanged refusal of every other
|
||||
identity mismatch, and the analytic block integration on grids that do not divide
|
||||
the output image evenly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys.calibration import CalibrationError, parse
|
||||
from orisys.calibration_context import build_context
|
||||
from orisys.calibration_models import ModelCalibration
|
||||
from orisys.field_calibration import FieldFrame, _block_starts, build_field_gain_map, reduce_field
|
||||
from orisys.force_readout import OutputSettings
|
||||
from orisys.force_session import ForceSession, FrameStamp
|
||||
from orisys.sensor import FrameMeasurement
|
||||
|
||||
FRAME = np.zeros((2, 2, 3), dtype=np.uint8)
|
||||
|
||||
|
||||
def _config(downsample=1, *, dis_mode=4):
|
||||
return {
|
||||
"camera": {"resolution_raw": [1280, 720], "resolution_stitch": [700, 750]},
|
||||
"tracker": {"dis_mode": dis_mode, "hhd_downsample": downsample},
|
||||
}
|
||||
|
||||
|
||||
def _document(config) -> dict:
|
||||
return {
|
||||
"schema_version": 3,
|
||||
"unit": "N",
|
||||
"identity": {"sensor_type": "box", "sn": "A1"},
|
||||
"pipeline": dict(build_context(config)),
|
||||
"model": {
|
||||
"type": "global_gain/1",
|
||||
"input_profile": "force_delta/1",
|
||||
"output_frame": "sdk_force/1",
|
||||
"outputs": ["fn"],
|
||||
"params": {
|
||||
"gains_N_per_raw": {"fn": 0.001},
|
||||
"valid_force_range_N": [0, 3],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _model(config) -> ModelCalibration:
|
||||
loaded = parse(_document(config))
|
||||
assert isinstance(loaded, ModelCalibration)
|
||||
return loaded
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ the report
|
||||
def test_downsample_only_difference_runs_and_is_reported():
|
||||
model = _model(_config(2))
|
||||
runtime = build_context(_config(3))
|
||||
|
||||
model.validate_for_context(runtime) # allowed: no exception
|
||||
|
||||
report = model.pipeline_compatibility(runtime)
|
||||
assert report["ok"] is True
|
||||
downsample = report["downsample"]
|
||||
assert downsample["declared"] == 2
|
||||
assert downsample["actual"] == 3
|
||||
assert downsample["match"] is False
|
||||
assert downsample["allowed"] is True
|
||||
assert downsample["accuracy_risk"]
|
||||
assert model.downsample_report(runtime).known is True
|
||||
|
||||
|
||||
def test_a_matching_downsample_has_no_accuracy_risk():
|
||||
model = _model(_config(3))
|
||||
report = model.pipeline_compatibility(build_context(_config(3)))
|
||||
assert report["downsample"]["match"] is True
|
||||
assert report["downsample"]["accuracy_risk"] == ""
|
||||
|
||||
|
||||
def test_an_unknown_runtime_downsample_is_unknown_not_a_match():
|
||||
model = _model(_config(2))
|
||||
report = model.downsample_report({})
|
||||
assert report.actual is None
|
||||
assert report.match is False
|
||||
assert report.allowed is True
|
||||
assert "unknown" in report.accuracy_risk
|
||||
|
||||
|
||||
def test_every_other_identity_mismatch_is_still_refused():
|
||||
model = _model(_config(2))
|
||||
runtime = build_context(_config(2, dis_mode=5))
|
||||
report = model.pipeline_compatibility(runtime)
|
||||
assert report["ok"] is False
|
||||
assert "calibration pipeline mismatch: dis_mode" in report["mismatches"].values()
|
||||
with pytest.raises(CalibrationError, match="calibration pipeline mismatch: dis_mode"):
|
||||
model.validate_for_context(runtime)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw, expected", [(None, 1), ("bad", 1), (0, 1), (-3, 1), ("3", 3), (3.7, 3)])
|
||||
def test_context_reports_its_actual_downsample(raw, expected):
|
||||
config = _config(1)
|
||||
config["tracker"]["hhd_downsample"] = raw
|
||||
assert build_context(config).actual_downsample == expected
|
||||
|
||||
|
||||
# ------------------------------------------------- non-divisible grid integration
|
||||
def _identity_coordinates(rows: int, cols: int) -> np.ndarray:
|
||||
uv = np.empty((rows, cols, 2), dtype=np.float64)
|
||||
uv[..., 0] = np.arange(cols)[None, :]
|
||||
uv[..., 1] = np.arange(rows)[:, None]
|
||||
return uv
|
||||
|
||||
|
||||
@pytest.mark.parametrize("grid_shape", [(1, 1), (2, 3), (3, 4), (4, 4)])
|
||||
def test_a_constant_field_integrates_exactly_on_a_non_divisible_grid(grid_shape):
|
||||
rows, cols = 37, 53
|
||||
uv = _identity_coordinates(rows, cols)
|
||||
mask = np.ones((rows, cols), dtype=bool)
|
||||
points = np.array(
|
||||
[
|
||||
[0.0, 0.0],
|
||||
[cols - 1.0, 0.0],
|
||||
[cols - 1.0, rows - 1.0],
|
||||
[0.0, rows - 1.0],
|
||||
]
|
||||
)
|
||||
gains = np.ones(4, dtype=np.float64)
|
||||
triangles = np.array([[0, 1, 2], [0, 2, 3]], dtype=int)
|
||||
|
||||
# The block layout is an exact partition of the output image, for every grid
|
||||
# size that does not divide it: no lost pixels and no double counting.
|
||||
for divisions, size in ((grid_shape[0], rows), (grid_shape[1], cols)):
|
||||
starts, ends = _block_starts(divisions, size)
|
||||
assert starts[0] == 0
|
||||
assert ends[-1] == size
|
||||
assert np.array_equal(starts[1:], ends[:-1])
|
||||
assert np.all(ends > starts)
|
||||
|
||||
gain_map = build_field_gain_map(points, gains, triangles, uv, mask, grid_shape, mask)
|
||||
row_starts, row_ends = _block_starts(grid_shape[0], rows)
|
||||
col_starts, col_ends = _block_starts(grid_shape[1], cols)
|
||||
block_area = np.outer(row_ends - row_starts, col_ends - col_starts)
|
||||
# A constant unit gain and a fully mapped mask: the area-weighted coverage is
|
||||
# the whole image, exactly (analytic area, not a sampled sum).
|
||||
assert float(np.sum(gain_map.coverage * block_area)) == pytest.approx(rows * cols)
|
||||
assert np.allclose(gain_map.gain, 1.0)
|
||||
assert np.allclose(gain_map.inside, 1.0)
|
||||
|
||||
field = FieldFrame(
|
||||
d=np.ones((grid_shape[0], grid_shape[1], 2), dtype=np.float32),
|
||||
valid_mask=np.ones(grid_shape, dtype=bool),
|
||||
area_scale=1.0,
|
||||
grid_shape=grid_shape,
|
||||
reference_shape=None,
|
||||
)
|
||||
reduction = reduce_field(field, gain_map)
|
||||
# q = |d_x| + |d_y| = 2 per active HHD cell; the gain map's block sums are exact
|
||||
# whatever the block boundaries are, so the integral is exactly 2 * grid cells.
|
||||
cells = grid_shape[0] * grid_shape[1]
|
||||
assert reduction.weighted_N == pytest.approx(2.0 * cells)
|
||||
assert reduction.extrapolated_fraction == 0.0
|
||||
assert reduction.coverage_fraction == pytest.approx(1.0)
|
||||
|
||||
|
||||
# ------------------------------------------------------- session output diagnostics
|
||||
class _Source:
|
||||
"""The measurement-source surface ForceSession needs; no camera involved."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.session_enabled = False
|
||||
self.reset_count = 0
|
||||
|
||||
def enable_measurement_session(self) -> None:
|
||||
self.session_enabled = True
|
||||
|
||||
def disable_measurement_session(self) -> None:
|
||||
self.session_enabled = False
|
||||
|
||||
def reset_tracker(self) -> None:
|
||||
self.reset_count += 1
|
||||
|
||||
def read_measurement_frame(
|
||||
self, source_frame, *, frame_id, timestamp_s=None, want_field=False, want_contact=False
|
||||
) -> FrameMeasurement:
|
||||
del source_frame, timestamp_s, want_field, want_contact
|
||||
return FrameMeasurement(
|
||||
raw=(10.0, 20.0, 30.0),
|
||||
raw_valid=(True, True, True),
|
||||
)
|
||||
|
||||
|
||||
def test_the_session_output_states_the_downsample_risk():
|
||||
model = _model(_config(2))
|
||||
session = ForceSession(
|
||||
source=_Source(),
|
||||
sensor_family="box",
|
||||
context=build_context(_config(3)),
|
||||
calibration=model,
|
||||
settings=OutputSettings(sample_rate_hz=30.0),
|
||||
)
|
||||
output = session.process_frame(
|
||||
FRAME,
|
||||
FrameStamp(
|
||||
session_id="s1",
|
||||
frame_id=0,
|
||||
source_frame_id=0,
|
||||
signal_time_s=0.0,
|
||||
time_basis="recorded_signal",
|
||||
),
|
||||
)
|
||||
diagnostics = dict(output.measurement.diagnostics)
|
||||
assert diagnostics["hhd_downsample_calibrated"] == 2.0
|
||||
assert diagnostics["hhd_downsample_actual"] == 3.0
|
||||
assert diagnostics["hhd_downsample_mismatch"] == 1.0
|
||||
assert diagnostics["downsample_accuracy_risk"]
|
||||
assert session.calibration_compatibility["downsample"]["actual"] == 3
|
||||
File diff suppressed because it is too large
Load Diff
@@ -455,3 +455,166 @@ def test_finger_runtime_binds_field_identity_and_forwards_the_window(monkeypatch
|
||||
runtime.declare_unloaded_reference("runtime-window")
|
||||
assert sensor.calibration.begin_calls == ["runtime-window"]
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------- session measurement seam
|
||||
def test_measurement_session_skips_old_inference_and_readout():
|
||||
"""The new entry publishes same-frame raw only: no model call, no readout."""
|
||||
sensor = make_sensor()
|
||||
sensor.cuda = sensor.verbose = False
|
||||
sensor.valid_mask = sensor.valid_mask_hhd = None
|
||||
sensor._hhd_downsample = 1
|
||||
sensor._hhd_grid_shape = sensor._hhd_ref_shape = (2, 2)
|
||||
sensor._model_frame_id = 0
|
||||
field = np.zeros((2, 2, 2), dtype=np.float32)
|
||||
sensor._Sensor__hhd = SimpleNamespace(
|
||||
get_force=lambda *args, **kwargs: (None, (10., 0., 0., 0.)),
|
||||
get_normal=lambda: field,
|
||||
get_shear=lambda: field,
|
||||
)
|
||||
sensor.enable_measurement_session()
|
||||
assert sensor.measurement_session is True
|
||||
sensor.compute_hhd(field)
|
||||
assert sensor.calibration.calls == [], "the session owns the one model call"
|
||||
assert sensor._force_readout.filtered_raw is None
|
||||
assert sensor._force_raw == (10., 0., 0.)
|
||||
assert np.isnan(sensor.fnormal)
|
||||
assert sensor.calibration_status == {}
|
||||
|
||||
# Contact statistics stay available, still without a model inference.
|
||||
sensor._Sensor__contact3d = SimpleNamespace(
|
||||
compute_foe=lambda *args, **kwargs: {
|
||||
"centroid": (0, 1), "centroid_found": True,
|
||||
"divergence_map": np.zeros((2, 2)),
|
||||
}
|
||||
)
|
||||
sensor.compute_contact()
|
||||
assert sensor.calibration.calls == []
|
||||
assert sensor.centroid_found is True
|
||||
|
||||
sensor.disable_measurement_session()
|
||||
assert sensor.measurement_session is False
|
||||
|
||||
|
||||
def test_reset_tracker_confirms_without_touching_the_calibration_reference():
|
||||
sensor = make_sensor()
|
||||
resets = []
|
||||
sensor._Sensor__tracker = SimpleNamespace(reset=lambda: resets.append("tracker"))
|
||||
sensor.set_calibration_reference({"fn": 10.}, reference_id="reference")
|
||||
sensor._force_unfiltered = (1., 2., 3.)
|
||||
sensor.compute_deformation_valid = True
|
||||
sensor.reset_tracker()
|
||||
assert resets == ["tracker"]
|
||||
assert sensor._force_unfiltered is None
|
||||
assert sensor._calibration_reference == {"fn": 10.}
|
||||
assert sensor._calibration_reference_id == "reference"
|
||||
assert sensor.compute_deformation_valid is False
|
||||
sensor.enable_measurement_session()
|
||||
sensor.reset_tracker()
|
||||
assert sensor.measurement_session is True, "the seam survives a frame-boundary reset"
|
||||
|
||||
|
||||
def test_measure_current_frame_returns_this_frames_features_once():
|
||||
sensor = make_sensor()
|
||||
sensor.cuda = sensor.verbose = False
|
||||
sensor.valid_mask = np.ones((2, 2), dtype=bool)
|
||||
sensor.valid_mask_hhd = np.ones((2, 2), dtype=bool)
|
||||
sensor._hhd_mask_f32 = None
|
||||
sensor._hhd_downsample = 1
|
||||
sensor._hhd_grid_shape = sensor._hhd_ref_shape = (2, 2)
|
||||
sensor._model_frame_id = 0
|
||||
field = np.zeros((2, 2, 2), dtype=np.float32)
|
||||
sensor._Sensor__hhd = SimpleNamespace(
|
||||
get_force=lambda *args, **kwargs: (None, (10., 20., 30., 0.)),
|
||||
get_normal=lambda: field,
|
||||
get_shear=lambda: field,
|
||||
get_field=lambda valid_mask=None: {
|
||||
"d": field, "valid_mask": np.ones((2, 2), dtype=bool),
|
||||
"area_scale": 4.0, "grid_shape": (2, 2), "reference_shape": (2, 2),
|
||||
},
|
||||
)
|
||||
sensor._Sensor__contact3d = SimpleNamespace(
|
||||
compute_foe=lambda *args, **kwargs: {
|
||||
"centroid": (0.4, 1.2), "centroid_found": True,
|
||||
"divergence_map": np.zeros((2, 2)),
|
||||
}
|
||||
)
|
||||
flow_calls = []
|
||||
sensor.compute_flow = lambda image=None: (
|
||||
flow_calls.append(image), setattr(sensor, "flow", field)
|
||||
)
|
||||
sensor.enable_measurement_session()
|
||||
sensor.img = np.zeros((2, 2), dtype=np.uint8)
|
||||
|
||||
measurement = sensor.measure_current_frame(
|
||||
frame_id=5, want_field=True, want_contact=True
|
||||
)
|
||||
assert flow_calls == [sensor.img]
|
||||
assert measurement.raw == (10., 20., 30.)
|
||||
assert measurement.raw_valid == (True, True, True)
|
||||
assert measurement.field["grid_shape"] == (2, 2)
|
||||
assert measurement.output_valid_mask.shape == (2, 2)
|
||||
assert measurement.contact_found is True
|
||||
assert measurement.centroid == (0.4, 1.2)
|
||||
assert measurement.single_contact is True
|
||||
|
||||
|
||||
def test_read_measurement_frame_prepares_the_source_frame_once():
|
||||
sensor = make_sensor()
|
||||
sensor.cuda = sensor.verbose = False
|
||||
sensor.valid_mask = sensor.valid_mask_hhd = None
|
||||
sensor._hhd_downsample = 1
|
||||
sensor._hhd_grid_shape = sensor._hhd_ref_shape = (2, 2)
|
||||
sensor._model_frame_id = 0
|
||||
field = np.zeros((2, 2, 2), dtype=np.float32)
|
||||
sensor._Sensor__hhd = SimpleNamespace(
|
||||
get_force=lambda *args, **kwargs: (None, (1., 2., 3., 0.)),
|
||||
get_normal=lambda: field,
|
||||
get_shear=lambda: field,
|
||||
)
|
||||
prepared = []
|
||||
sensor.get_img = lambda frame=None: (prepared.append(frame), setattr(sensor, "img", frame))
|
||||
sensor.compute_flow = lambda image=None: setattr(sensor, "flow", field)
|
||||
sensor.enable_measurement_session()
|
||||
frame = np.ones((2, 2, 3), dtype=np.uint8)
|
||||
measurement = sensor.read_measurement_frame(frame, frame_id=7)
|
||||
assert prepared == [frame]
|
||||
assert measurement.raw == (1., 2., 3.)
|
||||
assert measurement.field is None, "no field unless the model asked for it"
|
||||
assert measurement.contact_found is None, "no contact unless it was requested"
|
||||
|
||||
|
||||
def test_finger_runtime_session_seam_delegates_to_its_sensor():
|
||||
from orisys.finger.runtime import FingerRuntime
|
||||
|
||||
events = []
|
||||
|
||||
class FakeSensor:
|
||||
def enable_measurement_session(self):
|
||||
events.append("enable")
|
||||
|
||||
def disable_measurement_session(self):
|
||||
events.append("disable")
|
||||
|
||||
def reset_tracker(self):
|
||||
events.append("reset")
|
||||
|
||||
def measure_current_frame(self, *, frame_id, want_field=False, want_contact=False):
|
||||
events.append(("measure", frame_id, want_field, want_contact))
|
||||
return "measurement"
|
||||
|
||||
runtime = object.__new__(FingerRuntime)
|
||||
runtime.tactile = SimpleNamespace(raw_sensor=FakeSensor())
|
||||
processed = []
|
||||
runtime.process_frame = lambda frame, color=False: processed.append((frame, color))
|
||||
frame = np.zeros((2, 2, 3), dtype=np.uint8)
|
||||
result = runtime.read_measurement_frame(
|
||||
frame, frame_id=9, want_field=True, want_contact=False
|
||||
)
|
||||
assert processed == [(frame, False)]
|
||||
assert result == "measurement"
|
||||
assert events[-1] == ("measure", 9, True, False)
|
||||
runtime.enable_measurement_session()
|
||||
runtime.reset_tracker()
|
||||
runtime.disable_measurement_session()
|
||||
assert events[1:] == ["enable", "reset", "disable"]
|
||||
|
||||
@@ -14,6 +14,7 @@ from orisys.signal_processing import (
|
||||
NORMAL_FORCE_CAL_OFFSET,
|
||||
NORMAL_FORCE_CAL_SLOPE,
|
||||
LowpassFilter,
|
||||
MultiAxisCausalFilter,
|
||||
calibrate_normal_force,
|
||||
calibrate_shear_components,
|
||||
calibrate_shear_force,
|
||||
@@ -494,3 +495,76 @@ def test_the_calibration_note_names_the_bundle_that_was_found(tmp_path, monkeypa
|
||||
# A dict always keys ``found_root``, so guard its value: an unguarded copy of
|
||||
# this branch prints "bundle found at None".
|
||||
assert "None" not in note
|
||||
|
||||
|
||||
# --------------------------------------------- multi-axis causal force chain
|
||||
class _ReferenceMedian:
|
||||
"""Independent median window, so the operator test has its own expectation."""
|
||||
|
||||
def __init__(self, window):
|
||||
self.window = window
|
||||
self.history = []
|
||||
|
||||
def process(self, value):
|
||||
self.history.append(float(value))
|
||||
self.history = self.history[-self.window :]
|
||||
return float(np.median(self.history))
|
||||
|
||||
|
||||
def test_multi_axis_median_matches_a_plain_window_per_axis():
|
||||
chain = MultiAxisCausalFilter(median_window=3)
|
||||
reference = [_ReferenceMedian(3) for _ in range(3)]
|
||||
samples = [(10.0, 20.0, 30.0), (90.0, 20.0, 30.0), (10.0, 20.0, 30.0),
|
||||
(10.0, 20.0, 30.0)]
|
||||
for sample in samples:
|
||||
actual = chain.process(sample)
|
||||
expected = [reference[index].process(value) for index, value in enumerate(sample)]
|
||||
np.testing.assert_allclose(actual, expected)
|
||||
|
||||
|
||||
def test_multi_axis_nan_axis_does_not_enter_history_or_pull_the_others():
|
||||
chain = MultiAxisCausalFilter(median_window=3)
|
||||
chain.process((10.0, 20.0, 30.0))
|
||||
skipped = chain.process(
|
||||
(float("nan"), 20.0, 30.0), valid=(False, True, True)
|
||||
)
|
||||
assert np.isnan(skipped[0])
|
||||
assert skipped[1] == pytest.approx(20.0)
|
||||
# Valid axes keep advancing while the missing axis is skipped.
|
||||
resumed = chain.process((10.0, 20.0, 30.0))
|
||||
assert resumed[0] == pytest.approx(10.0)
|
||||
# A NaN arriving without an explicit validity flag is treated as unavailable.
|
||||
assert np.isnan(chain.process((float("nan"), 20.0, 30.0))[0])
|
||||
|
||||
|
||||
def test_multi_axis_all_stages_off_is_exact_passthrough_including_nan():
|
||||
chain = MultiAxisCausalFilter(median_window=1, lowpass_cutoff_hz=0.0)
|
||||
assert chain.enabled is False
|
||||
sample = (1.5, -2.0, float("nan"))
|
||||
first = chain.process(sample)
|
||||
second = chain.process(sample)
|
||||
assert first[0] == 1.5 and first[1] == -2.0 and np.isnan(first[2])
|
||||
assert second[0] == 1.5 and second[1] == -2.0 and np.isnan(second[2])
|
||||
|
||||
|
||||
def test_multi_axis_lowpass_ignores_missing_axes_and_resets():
|
||||
chain = MultiAxisCausalFilter(
|
||||
median_window=1, lowpass_cutoff_hz=5.0, sample_rate_hz=30.0, n_channels=3
|
||||
)
|
||||
values = []
|
||||
for index in range(20):
|
||||
values.append(
|
||||
chain.process(
|
||||
(float(index), float(index), float(index)),
|
||||
valid=(True, True, index % 2 == 0),
|
||||
)
|
||||
)
|
||||
for index, value in enumerate(values):
|
||||
assert np.isfinite(value[0])
|
||||
assert np.isfinite(value[1])
|
||||
# The third axis is only fed on even frames, so it is missing on odd ones.
|
||||
assert np.isnan(value[2]) == bool(index % 2)
|
||||
chain.reset()
|
||||
after = chain.process((1.0, 2.0, 3.0))
|
||||
# A reset IIR starts from its first sample's steady state.
|
||||
np.testing.assert_allclose(after, (1.0, 2.0, 3.0))
|
||||
|
||||
Reference in New Issue
Block a user