diff --git a/.gitignore b/.gitignore index 6566ba43c..00a8c7560 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,4 @@ _version.py core_typemap.pkl venv/ +uv.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index c997abef5..64b0a868a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added - Added 'target_tables' kwarg to DynamicTable subclasses to allow classes that extend DynamicTable subclasses to specify the mapping of DynamicTableRegion columns to the target tables. @rly, @stephprince [#2096](https://github.com/NeurodataWithoutBorders/pynwb/issues/2096) +- Added `get_starting_time()` and `get_duration()` methods to `TimeSeries` to get the starting time and duration of the time series. @h-mayorquin [#2146](https://github.com/NeurodataWithoutBorders/pynwb/pull/2146) +- Added `get_starting_time()` and `get_duration()` methods to `TimeIntervals` to get the earliest start time and total duration (span from earliest start to latest stop) of all intervals. @h-mayorquin [#2146](https://github.com/NeurodataWithoutBorders/pynwb/pull/2146) ### Fixed - Fixed incorrect warning for path not ending in `.nwb` when no path argument was provided. @t-b [#2130](https://github.com/NeurodataWithoutBorders/pynwb/pull/2130) diff --git a/src/pynwb/base.py b/src/pynwb/base.py index f357d0d58..a18b42b2a 100644 --- a/src/pynwb/base.py +++ b/src/pynwb/base.py @@ -398,6 +398,62 @@ def get_timestamps(self): else: return np.arange(len(self.data)) / self.rate + self.starting_time + def get_starting_time(self): + """ + Get the starting time of this TimeSeries in seconds. + + Returns + ------- + float or None + The starting time in seconds, or None if no starting time is defined + and there is no data. + """ + if self.starting_time is not None: + return self.starting_time + elif self.num_samples is not None and self.num_samples > 0: + return float(self.timestamps[0]) + else: + # No starting_time defined and no data (e.g., empty timestamps-based TimeSeries) + return None + + def get_duration(self): + """ + Get the duration of this TimeSeries in seconds. + + Returns the time span from the first sample to the last sample. + For a single sample or empty TimeSeries with a defined starting_time, returns 0. + + Returns + ------- + float or None + The duration in seconds, or None if the TimeSeries has no data and + no starting_time defined. + + Notes + ----- + For rate-based TimeSeries: duration = (n - 1) / rate + For timestamp-based TimeSeries: duration = timestamps[-1] - timestamps[0] + + The duration represents the time span between sample times, not the total + recording time. If you need to account for the last sample's duration + (e.g., for continuous recordings), add 1/rate manually. + """ + if self.num_samples is None or self.num_samples == 0: + # Empty TimeSeries with a starting_time has duration 0 + if self.starting_time is not None: + return 0.0 + return None + + if self.num_samples == 1: + return 0.0 + + if self.fields.get('timestamps'): + timestamps = self.timestamps + return float(timestamps[-1] - timestamps[0]) + else: + # Rate-based + return (self.num_samples - 1) / self.rate + def get_data_in_units(self): """ Get the data of this TimeSeries in the specified unit of measurement, applying the conversion factor and offset: diff --git a/src/pynwb/epoch.py b/src/pynwb/epoch.py index 05eefcb99..4c9209c20 100644 --- a/src/pynwb/epoch.py +++ b/src/pynwb/epoch.py @@ -1,5 +1,7 @@ from bisect import bisect_left +import numpy as np + from hdmf.data_utils import DataIO from hdmf.common import DynamicTable from hdmf.utils import docval, getargs, popargs, get_docval, AllowPositional @@ -81,3 +83,39 @@ def __calculate_idx_count(self, start_time, stop_time, ts_data): count = stop_idx - start_idx idx_start = start_idx return int(idx_start), int(count) + + def get_starting_time(self): + """ + Get the earliest start time across all intervals in this TimeIntervals table. + + Returns + ------- + float or None + The earliest start time in seconds, or None if the table is empty. + """ + if len(self) == 0: + return None + # NOTE: Could be optimized to self['start_time'].data[0] if intervals are guaranteed sorted + return float(np.min(self['start_time'].data[:])) + + def get_duration(self): + """ + Get the total duration from the earliest start time to the latest stop time. + + Returns + ------- + float or None + The duration in seconds, or None if the table is empty. + + Notes + ----- + The duration represents the time span from the earliest interval start to the + latest interval stop, not the sum of individual interval durations. + """ + if len(self) == 0: + return None + import numpy as np + starting_time = self.get_starting_time() + # NOTE: Could be optimized to self['stop_time'].data[-1] if intervals are guaranteed sorted + stopping_time = float(np.max(self['stop_time'].data[:])) + return stopping_time - starting_time diff --git a/tests/unit/test_base.py b/tests/unit/test_base.py index c18189578..4e4b9d889 100644 --- a/tests/unit/test_base.py +++ b/tests/unit/test_base.py @@ -545,6 +545,56 @@ def test_timestamps_data_length_warning_construct_mode(self): # the test later on. obj._in_construct_mode = False + def test_get_starting_time_with_rate(self): + """Test get_starting_time with rate-based TimeSeries""" + ts = mock_TimeSeries(data=[1, 2, 3], rate=10.0, starting_time=5.0) + self.assertEqual(ts.get_starting_time(), 5.0) + + def test_get_starting_time_with_timestamps(self): + """Test get_starting_time with timestamp-based TimeSeries""" + ts = mock_TimeSeries(data=[1, 2, 3], timestamps=[2.5, 3.5, 4.5]) + self.assertEqual(ts.get_starting_time(), 2.5) + + def test_get_starting_time_empty_data_with_starting_time(self): + """Test get_starting_time with empty data but defined starting_time""" + ts = mock_TimeSeries(data=[], rate=10.0, starting_time=5.0) + self.assertEqual(ts.get_starting_time(), 5.0) + + def test_get_starting_time_empty_data_no_starting_time(self): + """Test get_starting_time with empty data and no starting_time returns None""" + ts = mock_TimeSeries(data=[], timestamps=[]) + self.assertIsNone(ts.get_starting_time()) + + def test_get_duration_with_rate(self): + """Test get_duration with rate-based TimeSeries""" + ts = mock_TimeSeries(data=[1, 2, 3, 4, 5], rate=10.0) + # (5-1) samples at 10 Hz = 4/10 = 0.4 seconds + self.assertEqual(ts.get_duration(), 0.4) + + def test_get_duration_with_timestamps(self): + """Test get_duration with timestamp-based TimeSeries""" + ts = mock_TimeSeries(data=[1, 2, 3, 4], timestamps=[0.0, 1.0, 2.0, 3.0]) + # Duration from 0 to 3 seconds = 3 seconds + self.assertEqual(ts.get_duration(), 3.0) + + def test_get_duration_single_sample(self): + """Test get_duration with single sample returns 0""" + ts_rate = mock_TimeSeries(data=[1], rate=10.0) + self.assertEqual(ts_rate.get_duration(), 0.0) + + ts_timestamps = mock_TimeSeries(data=[1], timestamps=[5.0]) + self.assertEqual(ts_timestamps.get_duration(), 0.0) + + def test_get_duration_empty_data_with_starting_time(self): + """Test get_duration with empty data but defined starting_time returns 0""" + ts = mock_TimeSeries(data=[], rate=10.0, starting_time=5.0) + self.assertEqual(ts.get_duration(), 0.0) + + def test_get_duration_empty_data_no_starting_time(self): + """Test get_duration with empty data and no starting_time returns None""" + ts = mock_TimeSeries(data=[], timestamps=[]) + self.assertIsNone(ts.get_duration()) + class TestImage(TestCase): def test_init(self): diff --git a/tests/unit/test_epoch.py b/tests/unit/test_epoch.py index 6d824f41e..1cdec6ec4 100644 --- a/tests/unit/test_epoch.py +++ b/tests/unit/test_epoch.py @@ -203,3 +203,43 @@ def test_from_dataframe_missing_supplied_col(self): df = pd.DataFrame({'start_time': [1., 2., 3.], 'stop_time': [2., 3., 4.], 'label': ['a', 'b', 'c']}) with self.assertRaises(ValueError): TimeIntervals.from_dataframe(df, name='ti_name', columns=[{'name': 'not there'}]) + + def test_get_starting_time(self): + """Test get_starting_time returns the earliest start time""" + ti = TimeIntervals(name='ti_name') + ti.add_interval(start_time=5.0, stop_time=10.0) + ti.add_interval(start_time=2.0, stop_time=7.0) + ti.add_interval(start_time=8.0, stop_time=12.0) + self.assertEqual(ti.get_starting_time(), 2.0) + + def test_get_starting_time_empty_table(self): + """Test get_starting_time returns None for empty table""" + ti = TimeIntervals(name='ti_name') + self.assertIsNone(ti.get_starting_time()) + + def test_get_starting_time_single_interval(self): + """Test get_starting_time with single interval""" + ti = TimeIntervals(name='ti_name') + ti.add_interval(start_time=3.5, stop_time=7.5) + self.assertEqual(ti.get_starting_time(), 3.5) + + def test_get_duration(self): + """Test get_duration returns span from earliest start to latest stop""" + ti = TimeIntervals(name='ti_name') + ti.add_interval(start_time=2.0, stop_time=5.0) + ti.add_interval(start_time=7.0, stop_time=10.0) + ti.add_interval(start_time=12.0, stop_time=18.0) + # Duration from earliest start (2.0) to latest stop (18.0) = 16.0 + self.assertEqual(ti.get_duration(), 16.0) + + def test_get_duration_empty_table(self): + """Test get_duration returns None for empty table""" + ti = TimeIntervals(name='ti_name') + self.assertIsNone(ti.get_duration()) + + def test_get_duration_single_interval(self): + """Test get_duration with single interval""" + ti = TimeIntervals(name='ti_name') + ti.add_interval(start_time=5.0, stop_time=10.0) + # Duration: 10.0 - 5.0 = 5.0 + self.assertEqual(ti.get_duration(), 5.0)