diff --git a/src/pyrecest/_backend/_shared_numpy/_common.py b/src/pyrecest/_backend/_shared_numpy/_common.py index a09ea8bde..84c4b0570 100644 --- a/src/pyrecest/_backend/_shared_numpy/_common.py +++ b/src/pyrecest/_backend/_shared_numpy/_common.py @@ -18,12 +18,13 @@ from ._dispatch import numpy as _np _DTYPES = { - _np.dtype("int32"): 0, - _np.dtype("int64"): 1, - _np.dtype("float32"): 2, - _np.dtype("float64"): 3, - _np.dtype("complex64"): 4, - _np.dtype("complex128"): 5, + _np.dtype("bool"): 0, + _np.dtype("int32"): 1, + _np.dtype("int64"): 2, + _np.dtype("float32"): 3, + _np.dtype("float64"): 4, + _np.dtype("complex64"): 5, + _np.dtype("complex128"): 6, } _COMPLEX_DTYPES = [ @@ -99,12 +100,27 @@ def to_ndarray(x, to_ndim, axis=0, dtype=None): def _get_wider_dtype(tensor_list): - dtype_list = [_DTYPES.get(x.dtype, -1) for x in tensor_list] - if len(dtype_list) == 1: - return dtype_list[0], True - - wider_dtype_index = max(dtype_list) - wider_dtype = list(_DTYPES.keys())[wider_dtype_index] + if len(tensor_list) == 0: + return None, True + + dtypes = [_np.dtype(x.dtype) for x in tensor_list] + if all(dtype == dtypes[0] for dtype in dtypes[1:]): + return dtypes[0], True + + dtype_ranks = [_DTYPES.get(dtype) for dtype in dtypes] + if any(rank is None for rank in dtype_ranks): + try: + return _np.result_type(*dtypes), False + except AttributeError as exc: + raise TypeError( + "Cannot determine a common dtype for unsupported dtype(s): " + f"{', '.join(str(dtype) for dtype in dtypes)}" + ) from exc + + wider_dtype_rank = max(dtype_ranks) + wider_dtype = next( + dtype for dtype, rank in _DTYPES.items() if rank == wider_dtype_rank + ) return wider_dtype, False diff --git a/src/pyrecest/calibration/bias.py b/src/pyrecest/calibration/bias.py index b55946852..3195f2dd5 100644 --- a/src/pyrecest/calibration/bias.py +++ b/src/pyrecest/calibration/bias.py @@ -160,6 +160,13 @@ def make_bias_training_examples( raise ValueError( "measurement_values and reference_values must have the same target dimension" ) + if feature_values is None: + features = np.empty((measurements.shape[0], 0), dtype=float) + else: + features = _as_2d(feature_values, "feature_values") + if features.shape[0] != measurements.shape[0]: + raise ValueError("feature_values rows must match measurement_values rows") + if reference_times.size == 0: return BiasTrainingExamples( measured=np.empty((0, measurements.shape[1])), @@ -168,11 +175,7 @@ def make_bias_training_examples( features=np.empty( ( 0, - ( - 0 - if feature_values is None - else _as_2d(feature_values, "feature_values").shape[1] - ), + features.shape[1], ) ), time_delta_s=np.empty(0), @@ -189,13 +192,7 @@ def make_bias_training_examples( & np.isfinite(references[nearest]).all(axis=1) & (delta_s <= float(max_time_delta_s)) ) - if feature_values is None: - features = np.empty((measurements.shape[0], 0), dtype=float) - else: - features = _as_2d(feature_values, "feature_values") - if features.shape[0] != measurements.shape[0]: - raise ValueError("feature_values rows must match measurement_values rows") - valid &= np.isfinite(features).all(axis=1) + valid &= np.isfinite(features).all(axis=1) measured = measurements[valid] reference = references[nearest[valid]] return BiasTrainingExamples( @@ -222,6 +219,8 @@ def fit_sensor_bias_correction_from_examples( raise ValueError("min_samples must be positive") y = _as_2d(examples.residual, "examples.residual") x = _as_2d(examples.features, "examples.features") + if x.shape[0] != y.shape[0]: + raise ValueError("examples.features rows must match examples.residual rows") valid = np.isfinite(y).all(axis=1) & np.isfinite(x).all(axis=1) y = y[valid] x = x[valid] diff --git a/tests/calibration/test_time_offset_bias.py b/tests/calibration/test_time_offset_bias.py index 2e4b5a404..85a15f398 100644 --- a/tests/calibration/test_time_offset_bias.py +++ b/tests/calibration/test_time_offset_bias.py @@ -3,7 +3,9 @@ import numpy as np import numpy.testing as npt from pyrecest.calibration.bias import ( + BiasTrainingExamples, fit_sensor_bias_correction, + fit_sensor_bias_correction_from_examples, make_bias_training_examples, ) from pyrecest.calibration.time_offset import ( @@ -140,6 +142,36 @@ 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_validates_feature_rows_without_references( + self, + ): + with self.assertRaisesRegex( + ValueError, + "feature_values rows must match measurement_values rows", + ): + make_bias_training_examples( + np.array([0.0, 1.0]), + np.array([[1.0], [2.0]]), + np.array([]), + np.empty((0, 1)), + feature_values=np.array([[0.0]]), + ) + + def test_fit_sensor_bias_correction_from_examples_rejects_mismatched_rows(self): + examples = BiasTrainingExamples( + measured=np.zeros((2, 1)), + reference=np.zeros((2, 1)), + residual=np.zeros((2, 1)), + features=np.zeros((1, 1)), + time_delta_s=np.zeros(2), + ) + + with self.assertRaisesRegex( + ValueError, + "examples.features rows must match examples.residual rows", + ): + fit_sensor_bias_correction_from_examples(examples, min_samples=1) + def test_fit_sensor_bias_correction_subtracts_predicted_bias(self): times = np.arange(8.0) reference = np.column_stack([times, -times]) diff --git a/tests/test_backend_contract.py b/tests/test_backend_contract.py index c57c161aa..c1e9db036 100644 --- a/tests/test_backend_contract.py +++ b/tests/test_backend_contract.py @@ -1,5 +1,6 @@ import unittest +import numpy as np import numpy.testing as npt import pyrecest.backend as backend from pyrecest._backend import BACKEND_ATTRIBUTES @@ -15,6 +16,17 @@ def test_backend_attribute_lists_do_not_contain_duplicates(self): ) self.assertEqual(duplicates, []) + def test_convert_to_wider_dtype_preserves_matching_boolean_dtype(self): + if backend.__backend_name__ not in {"autograd", "numpy"}: + self.skipTest("shared NumPy dtype promotion regression test") + + first, second = backend.convert_to_wider_dtype( + [array([True, False]), array([False, True])] + ) + + self.assertEqual(to_numpy(first).dtype, np.dtype("bool")) + self.assertEqual(to_numpy(second).dtype, np.dtype("bool")) + def test_choice_supports_numpy_like_size_replace_and_probabilities(self): values = array([0, 1, 2, 3]) weights = array([0.1, 0.2, 0.3, 0.4])