Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/pyrecest/calibration/bias.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@ def make_bias_training_examples(
if features.shape[0] != measurements.shape[0]:
raise ValueError("feature_values rows must match measurement_values rows")

if reference_times.size == 0:
finite_reference = np.isfinite(reference_times) & np.isfinite(references).all(axis=1)
if not finite_reference.any():
return BiasTrainingExamples(
measured=np.empty((0, measurements.shape[1])),
reference=np.empty((0, measurements.shape[1])),
Expand All @@ -181,6 +182,8 @@ def make_bias_training_examples(
time_delta_s=np.empty(0),
)

reference_times = reference_times[finite_reference]
references = references[finite_reference]
order = np.argsort(reference_times)
reference_times = reference_times[order]
references = references[order]
Expand All @@ -189,7 +192,6 @@ def make_bias_training_examples(
valid = (
np.isfinite(measurement_times)
& np.isfinite(measurements).all(axis=1)
& np.isfinite(references[nearest]).all(axis=1)
& (delta_s <= float(max_time_delta_s))
)
valid &= np.isfinite(features).all(axis=1)
Expand Down
40 changes: 33 additions & 7 deletions src/pyrecest/calibration/time_offset.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,34 @@ def _validate_max_time_delta(max_time_delta_s: float | None) -> None:
raise ValueError("max_time_delta_s must be nonnegative")


def _finite_reference_rows(
reference_times_s: np.ndarray,
reference_values: np.ndarray | None = None,
) -> np.ndarray:
"""Return a mask selecting reference rows that are usable for matching."""

reference_times = np.asarray(reference_times_s, dtype=float).reshape(-1)
finite = np.isfinite(reference_times)
if reference_values is not None:
values = np.asarray(reference_values, dtype=float)
if values.ndim == 1:
values = values.reshape(-1, 1)
finite &= np.isfinite(values).all(axis=1)
return finite


def nearest_time_indices(
reference_times_s: np.ndarray, query_times_s: np.ndarray
) -> np.ndarray:
"""Return original indices of nearest reference times for each query time."""
"""Return original indices of nearest finite reference times for each query time."""

reference = np.asarray(reference_times_s, dtype=float).reshape(-1)
query = np.asarray(query_times_s, dtype=float).reshape(-1)
if reference.size == 0:
raise ValueError("reference_times_s must not be empty")
finite_reference = _finite_reference_rows(reference)
if not finite_reference.any():
raise ValueError("reference_times_s must contain at least one finite value")
original_indices = np.flatnonzero(finite_reference)
reference = reference[finite_reference]
order = np.argsort(reference)
sorted_reference = reference[order]
insertion = np.searchsorted(sorted_reference, query)
Expand All @@ -111,7 +130,7 @@ def nearest_time_indices(
use_right = np.abs(sorted_reference[right] - query) < np.abs(
sorted_reference[left] - query
)
return order[np.where(use_right, right, left)]
return original_indices[order[np.where(use_right, right, left)]]


def interpolate_reference_values(
Expand All @@ -133,8 +152,11 @@ def interpolate_reference_values(
reference_values = reference_values.reshape(-1, 1)
if reference_times.size != reference_values.shape[0]:
raise ValueError("reference_times_s length must match reference_values rows")
if reference_times.size < 2:
raise ValueError("at least two reference times are required for interpolation")
finite_reference = _finite_reference_rows(reference_times, reference_values)
if np.count_nonzero(finite_reference) < 2:
raise ValueError("at least two finite reference rows are required for interpolation")
reference_times = reference_times[finite_reference]
reference_values = reference_values[finite_reference]
order = np.argsort(reference_times)
reference_times = reference_times[order]
reference_values = reference_values[order]
Expand All @@ -145,7 +167,11 @@ def interpolate_reference_values(
for dim in range(reference_values.shape[1])
]
)
valid = (query_times >= reference_times[0]) & (query_times <= reference_times[-1])
valid = (
np.isfinite(query_times)
& (query_times >= reference_times[0])
& (query_times <= reference_times[-1])
)
if max_time_delta_s is not None:
nearest = nearest_time_indices(reference_times, query_times)
valid &= np.abs(reference_times[nearest] - query_times) <= float(
Expand Down
56 changes: 56 additions & 0 deletions tests/calibration/test_time_offset_bias.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ def test_nearest_time_indices_accepts_unsorted_reference_times(self):

npt.assert_array_equal(indices, np.array([1, 2, 0]))

def test_nearest_time_indices_ignores_nonfinite_reference_times(self):
indices = nearest_time_indices(
np.array([np.nan, 10.0, 0.0]), np.array([0.2, 9.0])
)

npt.assert_array_equal(indices, np.array([2, 1]))

def test_aggregate_time_offset_sweeps_preserves_rmse_and_max_semantics(self):
aggregated = aggregate_time_offset_sweeps(
[
Expand Down Expand Up @@ -133,6 +140,27 @@ def test_interpolation_rejects_negative_max_time_delta(self):
max_time_delta_s=-1.0,
)

def test_interpolation_skips_nonfinite_reference_rows(self):
interpolated, valid = interpolate_reference_values(
np.array([0.0, 1.0, 2.0, np.nan, 3.0]),
np.array([[0.0], [np.nan], [2.0], [99.0], [3.0]]),
np.array([0.5, 1.5, 2.5]),
)

npt.assert_allclose(interpolated, np.array([[0.5], [1.5], [2.5]]))
npt.assert_array_equal(valid, np.array([True, True, True]))

def test_interpolation_rejects_without_two_finite_reference_rows(self):
with self.assertRaisesRegex(
ValueError,
"at least two finite reference rows are required for interpolation",
):
interpolate_reference_values(
np.array([0.0, 1.0]),
np.array([[0.0], [np.nan]]),
np.array([0.5]),
)

def test_interpolation_rejects_scalar_reference_values(self):
with self.assertRaisesRegex(
ValueError,
Expand Down Expand Up @@ -193,6 +221,34 @@ def test_make_bias_training_examples_uses_nearest_reference(self):

npt.assert_allclose(examples.residual, np.array([[1.0], [1.0]]))

def test_make_bias_training_examples_skips_nonfinite_reference_rows(self):
examples = make_bias_training_examples(
np.array([0.0, 1.0, 2.0]),
np.array([[1.0], [3.0], [5.0]]),
np.array([0.0, 1.0, 2.0, np.nan]),
np.array([[0.0], [np.nan], [4.0], [99.0]]),
max_time_delta_s=0.25,
)

npt.assert_allclose(examples.measured, np.array([[1.0], [5.0]]))
npt.assert_allclose(examples.reference, np.array([[0.0], [4.0]]))
npt.assert_allclose(examples.residual, np.array([[1.0], [1.0]]))
npt.assert_allclose(examples.time_delta_s, np.array([0.0, 0.0]))

def test_make_bias_training_examples_returns_empty_without_finite_reference_rows(
self,
):
examples = make_bias_training_examples(
np.array([0.0, 1.0]),
np.array([[1.0], [2.0]]),
np.array([np.nan, 1.0]),
np.array([[0.0], [np.nan]]),
feature_values=np.array([[1.0], [2.0]]),
)

self.assertEqual(examples.measured.shape, (0, 1))
self.assertEqual(examples.features.shape, (0, 1))

def test_make_bias_training_examples_validates_feature_rows_without_references(
self,
):
Expand Down
Loading