|
| 1 | +import logging |
| 2 | +from contextlib import ExitStack |
| 3 | + |
| 4 | +import numpy as np |
| 5 | +import pytest |
| 6 | + |
| 7 | +from experanto.experiment import Experiment |
| 8 | + |
| 9 | +from .create_experiment import make_modality_config, make_sequence_device |
| 10 | + |
| 11 | +DEVICE_TIME_RANGE_CASES = [ |
| 12 | + # Single device: start and end should match that device's range |
| 13 | + ([(2.0, 9.0)], 2.0, 9.0), |
| 14 | + # Two devices with different ranges: start should be min, end should be max |
| 15 | + ([(1.0, 8.0), (0.0, 10.0)], 0.0, 10.0), |
| 16 | + # Three devices with different ranges: start should be min, end should be max |
| 17 | + ([(0.0, 10.0), (1.0, 8.0), (2.0, 9.0)], 0.0, 10.0), |
| 18 | + # Devices with non-overlapping ranges: start should be min, end should be max |
| 19 | + ([(0.0, 3.0), (7.0, 8.0)], 0.0, 8.0), |
| 20 | + # Devices with identical ranges: start and end should match that range |
| 21 | + ([(1.0, 5.0), (1.0, 5.0)], 1.0, 5.0), |
| 22 | + # Large time stamps: start should be min, end should be max |
| 23 | + ([(1e9, 1e9 + 100), (1e9 - 50, 1e9 + 50)], 1e9 - 50, 1e9 + 100), |
| 24 | +] |
| 25 | + |
| 26 | +DEVICE_TIME_RANGE_IDS = [ |
| 27 | + "single_device", |
| 28 | + "two_devices_different_ranges", |
| 29 | + "three_devices_different_ranges", |
| 30 | + "non_overlapping_ranges", |
| 31 | + "identical_ranges", |
| 32 | + "large_time_stamps", |
| 33 | +] |
| 34 | + |
| 35 | +# Inverted range is intentionally separate from INVALID_META_CASES — |
| 36 | +# None/NaN/inf are caught per-device before being added to self.devices, |
| 37 | +# whereas start > end is only caught after all devices are loaded. |
| 38 | +INVALID_META_CASES = [ |
| 39 | + {"start_time": None, "end_time": None}, # Both missing |
| 40 | + {"start_time": None, "end_time": 10.0}, # Missing start_time |
| 41 | + {"start_time": 0.0, "end_time": None}, # Missing end_time |
| 42 | + {"start_time": float("inf"), "end_time": 10.0}, # Infinite start_time |
| 43 | + {"start_time": 0.0, "end_time": float("inf")}, # Infinite end_time |
| 44 | + {"start_time": float("-inf"), "end_time": 10.0}, # Negative Infinite start_time |
| 45 | + {"start_time": 0.0, "end_time": float("-inf")}, # Negative Infinite end_time |
| 46 | + {"start_time": float("nan"), "end_time": 10.0}, # NaN start_time |
| 47 | + {"start_time": 0.0, "end_time": float("nan")}, # NaN end_time |
| 48 | +] |
| 49 | + |
| 50 | +INVALID_META_IDS = [ |
| 51 | + "both_missing", |
| 52 | + "missing_start_time", |
| 53 | + "missing_end_time", |
| 54 | + "infinite_start_time", |
| 55 | + "infinite_end_time", |
| 56 | + "negative_infinite_start_time", |
| 57 | + "negative_infinite_end_time", |
| 58 | + "nan_start_time", |
| 59 | + "nan_end_time", |
| 60 | +] |
| 61 | + |
| 62 | + |
| 63 | +# Test for union of device time ranges |
| 64 | +@pytest.mark.parametrize("n_signals", [5, 20]) |
| 65 | +@pytest.mark.parametrize( |
| 66 | + "device_ranges, expected_start, expected_end", |
| 67 | + DEVICE_TIME_RANGE_CASES, |
| 68 | + ids=DEVICE_TIME_RANGE_IDS, |
| 69 | +) |
| 70 | +def test_experiment_start_end_time_reflects_union( |
| 71 | + tmp_path, device_ranges, expected_start, expected_end, n_signals |
| 72 | +): |
| 73 | + """ |
| 74 | + Experiment.start_time and end_time should reflect the union of all |
| 75 | + device time ranges — earliest start and latest end across all devices. |
| 76 | + """ |
| 77 | + device_names = [f"device_{i}" for i in range(len(device_ranges))] |
| 78 | + |
| 79 | + with ExitStack() as stack: |
| 80 | + for name, (start, end) in zip(device_names, device_ranges, strict=True): |
| 81 | + stack.enter_context( |
| 82 | + make_sequence_device( |
| 83 | + tmp_path, |
| 84 | + name, |
| 85 | + start=start, |
| 86 | + end=end, |
| 87 | + n_signals=n_signals, |
| 88 | + sampling_rate=float(np.random.randint(5, 30)), |
| 89 | + ) |
| 90 | + ) |
| 91 | + |
| 92 | + experiment = Experiment( |
| 93 | + root_folder=tmp_path, |
| 94 | + modality_config=make_modality_config( |
| 95 | + *device_names, offsets=[float(np.random.rand()) for _ in device_names] |
| 96 | + ), |
| 97 | + ) |
| 98 | + |
| 99 | + assert experiment.start_time == ( |
| 100 | + expected_start |
| 101 | + ), f"Expected start_time={expected_start}, got {experiment.start_time}" |
| 102 | + assert experiment.end_time == ( |
| 103 | + expected_end |
| 104 | + ), f"Expected end_time={expected_end}, got {experiment.end_time}" |
| 105 | + |
| 106 | + |
| 107 | +# Safety check |
| 108 | +@pytest.mark.parametrize("override_meta", INVALID_META_CASES, ids=INVALID_META_IDS) |
| 109 | +def test_experiment_invalid_metadata(tmp_path, override_meta): |
| 110 | + """ |
| 111 | + Experiment should raise an error when initialized with invalid metadata. |
| 112 | + Covers cases where start_time or end_time is None, NaN, or infinite. |
| 113 | + """ |
| 114 | + with make_sequence_device( |
| 115 | + tmp_path, |
| 116 | + "device_0", |
| 117 | + start=0.0, |
| 118 | + end=10.0, |
| 119 | + override_meta=override_meta, |
| 120 | + ): |
| 121 | + with pytest.raises( |
| 122 | + ValueError, match="Experiment time range could not be determined" |
| 123 | + ): |
| 124 | + Experiment( |
| 125 | + root_folder=tmp_path, |
| 126 | + modality_config=make_modality_config("device_0"), |
| 127 | + ) |
| 128 | + |
| 129 | + |
| 130 | +def test_experiment_inverted_time_range_raises(tmp_path): |
| 131 | + """ |
| 132 | + Experiment should raise ValueError when start_time > end_time. |
| 133 | + This is a separate guard from invalid metadata (None/NaN/inf) because it |
| 134 | + only becomes apparent after all devices are loaded and the overall time range is computed. |
| 135 | + """ |
| 136 | + with make_sequence_device( |
| 137 | + tmp_path, |
| 138 | + "device_0", |
| 139 | + start=0.0, |
| 140 | + end=10.0, |
| 141 | + override_meta={"start_time": 5.0, "end_time": 2.0}, |
| 142 | + ): |
| 143 | + with pytest.raises( |
| 144 | + ValueError, match="Experiment time range could not be determined" |
| 145 | + ): |
| 146 | + Experiment( |
| 147 | + root_folder=tmp_path, |
| 148 | + modality_config=make_modality_config("device_0"), |
| 149 | + ) |
| 150 | + |
| 151 | + |
| 152 | +@pytest.mark.parametrize("override_meta", INVALID_META_CASES, ids=INVALID_META_IDS) |
| 153 | +def test_experiment_skips_invalid_devices(tmp_path, override_meta, caplog): |
| 154 | + """ |
| 155 | + Experiment should skip devices with invalid start_time or end_time and |
| 156 | + log a warning, but still initialize successfully if at least one valid |
| 157 | + device is present. The experiment time range should reflect only the |
| 158 | + valid device. |
| 159 | + """ |
| 160 | + start_val = np.random.lognormal(mean=0.0, sigma=1.0) # Strictly positive float |
| 161 | + duration_val = np.random.lognormal(mean=0.0, sigma=1.0) |
| 162 | + end_val = start_val + duration_val |
| 163 | + |
| 164 | + start_nonval = np.random.lognormal(mean=0.0, sigma=1.0) |
| 165 | + duration_nonval = np.random.lognormal(mean=0.0, sigma=1.0) |
| 166 | + end_nonval = start_nonval + duration_nonval |
| 167 | + |
| 168 | + with ExitStack() as stack: |
| 169 | + # Valid device with proper metadata |
| 170 | + stack.enter_context( |
| 171 | + make_sequence_device( |
| 172 | + tmp_path, |
| 173 | + "valid_device", |
| 174 | + start=start_val, |
| 175 | + end=end_val, |
| 176 | + ) |
| 177 | + ) |
| 178 | + # Invalid device with missing start_time and end_time |
| 179 | + stack.enter_context( |
| 180 | + make_sequence_device( |
| 181 | + tmp_path, |
| 182 | + "invalid_device", |
| 183 | + start=start_nonval, |
| 184 | + end=end_nonval, |
| 185 | + override_meta=override_meta, |
| 186 | + ) |
| 187 | + ) |
| 188 | + |
| 189 | + with caplog.at_level(logging.WARNING, logger="experanto.experiment"): |
| 190 | + experiment = Experiment( |
| 191 | + root_folder=tmp_path, |
| 192 | + modality_config=make_modality_config("valid_device", "invalid_device"), |
| 193 | + ) |
| 194 | + |
| 195 | + assert "valid_device" in experiment.devices |
| 196 | + assert "invalid_device" not in experiment.devices |
| 197 | + |
| 198 | + assert experiment.start_time == ( |
| 199 | + start_val |
| 200 | + ), f"Expected start_time={start_val}, got {experiment.start_time}" |
| 201 | + assert experiment.end_time == ( |
| 202 | + end_val |
| 203 | + ), f"Expected end_time={end_val}, got {experiment.end_time}" |
| 204 | + assert any( |
| 205 | + "invalid_device" in message for message in caplog.messages |
| 206 | + ), "Expected warning about invalid_device was skipped" |
0 commit comments