Skip to content

Commit d61d9b8

Browse files
authored
Merge pull request #137 from binary69/fix/experiment-global-time-range
fix: compute Experiment start/end time as union across all devices
2 parents 2a45423 + 2488615 commit d61d9b8

3 files changed

Lines changed: 300 additions & 3 deletions

File tree

experanto/experiment.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,11 +118,33 @@ def _load_devices(self) -> None:
118118
**interp_conf, # type: ignore[arg-type]
119119
)
120120

121-
self.devices[d.name] = dev
122-
self.start_time = dev.start_time
123-
self.end_time = dev.end_time
121+
if (
122+
dev.start_time is None
123+
or dev.end_time is None
124+
or not np.isfinite(dev.start_time)
125+
or not np.isfinite(dev.end_time)
126+
):
127+
logger.warning(
128+
"Device %s has undefined start_time or end_time and will be "
129+
"excluded from the experiment-wide time range.",
130+
d.name,
131+
)
132+
else:
133+
self.start_time = min(self.start_time, dev.start_time)
134+
self.end_time = max(self.end_time, dev.end_time)
135+
self.devices[d.name] = dev
124136
logger.info("Parsing finished")
125137

138+
if not self.devices:
139+
raise ValueError(
140+
"Experiment time range could not be determined: no devices with valid start_time and end_time were found."
141+
)
142+
elif self.start_time > self.end_time:
143+
raise ValueError(
144+
"Experiment time range could not be determined: at least one device "
145+
"must define finite start_time and end_time."
146+
)
147+
126148
@property
127149
def device_names(self):
128150
return tuple(self.devices.keys())

tests/create_experiment.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import shutil
2+
from contextlib import contextmanager
3+
4+
import numpy as np
5+
import yaml
6+
7+
8+
@contextmanager
9+
def make_sequence_device(
10+
root, name, start, end, sampling_rate=10.0, n_signals=5, override_meta=None
11+
):
12+
"""Create a single sequence device folder under root."""
13+
device_root = root / name
14+
try:
15+
(device_root / "meta").mkdir(parents=True, exist_ok=True)
16+
17+
n_samples = (
18+
int((end - start) * sampling_rate) + 1
19+
) # +1 to include both start and end as sample points
20+
timestamps = np.linspace(start, end, n_samples)
21+
data = np.random.rand(n_samples, n_signals)
22+
23+
np.save(device_root / "timestamps.npy", timestamps)
24+
np.save(device_root / "data.npy", data)
25+
26+
meta = {
27+
"start_time": start,
28+
"end_time": end,
29+
"modality": "sequence",
30+
"sampling_rate": sampling_rate,
31+
"phase_shift_per_signal": False,
32+
"is_mem_mapped": False,
33+
"n_signals": n_signals,
34+
"n_timestamps": n_samples,
35+
"dtype": "float64",
36+
}
37+
if override_meta:
38+
meta.update(override_meta)
39+
with open(device_root / "meta.yml", "w") as f:
40+
yaml.safe_dump(meta, f)
41+
42+
yield device_root
43+
44+
finally:
45+
shutil.rmtree(device_root)
46+
47+
48+
def make_modality_config(*device_names, sampling_rates=None, offsets=None):
49+
if sampling_rates is None:
50+
sampling_rates = [10.0] * len(device_names)
51+
elif isinstance(sampling_rates, (int, float)):
52+
sampling_rates = [sampling_rates] * len(device_names)
53+
54+
if offsets is None:
55+
offsets = [0.0] * len(device_names)
56+
elif isinstance(offsets, (int, float)):
57+
offsets = [offsets] * len(device_names)
58+
59+
assert len(device_names) == len(
60+
sampling_rates
61+
), f"sampling_rates length {len(sampling_rates)} does not match device_names length {len(device_names)}"
62+
assert len(device_names) == len(
63+
offsets
64+
), f"offsets length {len(offsets)} does not match device_names length {len(device_names)}"
65+
66+
return {
67+
name: {"interpolation": {"sampling_rate": sr, "offset": off}}
68+
for name, sr, off in zip(device_names, sampling_rates, offsets, strict=True)
69+
}

tests/test_experiment.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
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

Comments
 (0)