Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,4 @@ _version.py
core_typemap.pkl

venv/
uv.lock
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
51 changes: 51 additions & 0 deletions src/pynwb/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,57 @@ 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 the TimeSeries has no data.
"""
if self.num_samples is None or self.num_samples == 0:
return None
if self.fields.get('timestamps'):
return float(self.timestamps[0])
else:
return self.starting_time

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, returns 0.

Returns
-------
float or None
The duration in seconds, or None if the TimeSeries has no data.

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.
"""
n = self.num_samples
if n is None or n == 0:
return None

if n == 1:
return 0.0

if self.fields.get('timestamps'):
timestamps = self.timestamps
return float(timestamps[-1] - timestamps[0])
else:
# Rate-based
return (n - 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:
Expand Down
37 changes: 37 additions & 0 deletions src/pynwb/epoch.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,40 @@ 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
import numpy as np
# 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
40 changes: 40 additions & 0 deletions tests/unit/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,46 @@ 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(self):
"""Test get_starting_time with empty data returns None"""
ts = mock_TimeSeries(data=[], rate=10.0)
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(self):
"""Test get_duration with empty data returns None"""
ts = mock_TimeSeries(data=[], rate=10.0)
self.assertIsNone(ts.get_duration())


class TestImage(TestCase):
def test_init(self):
Expand Down
40 changes: 40 additions & 0 deletions tests/unit/test_epoch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading