timetoalign 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- timetoalign-0.0.1.dist-info/METADATA +11 -0
- timetoalign-0.0.1.dist-info/RECORD +13 -0
- timetoalign-0.0.1.dist-info/WHEEL +5 -0
- timetoalign-0.0.1.dist-info/top_level.txt +1 -0
- tta/__init__.py +1 -0
- tta/common.py +58 -0
- tta/conversions.py +3777 -0
- tta/events.py +1527 -0
- tta/parsing.py +2236 -0
- tta/registry.py +546 -0
- tta/representations.py +826 -0
- tta/timelines.py +2192 -0
- tta/utils.py +963 -0
tta/timelines.py
ADDED
|
@@ -0,0 +1,2192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fractions import Fraction
|
|
4
|
+
from numbers import Number
|
|
5
|
+
from typing import (
|
|
6
|
+
Any,
|
|
7
|
+
Callable,
|
|
8
|
+
Dict,
|
|
9
|
+
Iterable,
|
|
10
|
+
Iterator,
|
|
11
|
+
Optional,
|
|
12
|
+
Self,
|
|
13
|
+
Sequence,
|
|
14
|
+
Set,
|
|
15
|
+
Type,
|
|
16
|
+
TypeAlias,
|
|
17
|
+
Union,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import pandas as pd
|
|
22
|
+
from pandas._typing import SequenceNotStr
|
|
23
|
+
|
|
24
|
+
from processing.metaframes import DF, DS, Meta, pd_concat
|
|
25
|
+
from processing.tta import utils
|
|
26
|
+
from processing.tta.conversions import (
|
|
27
|
+
C,
|
|
28
|
+
ConcatenationMap,
|
|
29
|
+
ConversionMap,
|
|
30
|
+
Coordinate,
|
|
31
|
+
FixedCoordinateTypeObject,
|
|
32
|
+
get_coordinate_type,
|
|
33
|
+
get_coordinate_value,
|
|
34
|
+
make_coordinate,
|
|
35
|
+
treat_instants_argument,
|
|
36
|
+
treat_intervals_argument,
|
|
37
|
+
)
|
|
38
|
+
from processing.tta.events import (
|
|
39
|
+
AudioSilo,
|
|
40
|
+
Event,
|
|
41
|
+
InstantEvent,
|
|
42
|
+
IntervalEvent,
|
|
43
|
+
MidiDataFrameSilo,
|
|
44
|
+
MidiInstantEvent,
|
|
45
|
+
MidiIntervalEvent,
|
|
46
|
+
PEvent,
|
|
47
|
+
PEventSilo,
|
|
48
|
+
)
|
|
49
|
+
from processing.tta.registry import (
|
|
50
|
+
ID_str,
|
|
51
|
+
ensure_registration,
|
|
52
|
+
get_object_by_id,
|
|
53
|
+
iter_objects_by_ids,
|
|
54
|
+
)
|
|
55
|
+
from processing.tta.utils import (
|
|
56
|
+
EventCategory,
|
|
57
|
+
IndexType,
|
|
58
|
+
InstantType,
|
|
59
|
+
NumberType,
|
|
60
|
+
TimeUnit,
|
|
61
|
+
TraversalOrder,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
Coord: TypeAlias = Union[Number, Coordinate]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def recursion_limit_is_reached(recursion_limit: Optional[int], limit=1) -> bool:
|
|
68
|
+
"""Checks if the recursion limit has been reached.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
recursion_limit: The current recursion limit.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
True if the limit is reached, False otherwise.
|
|
75
|
+
"""
|
|
76
|
+
return recursion_limit is not None and recursion_limit < limit
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def decrement_if_not_none(recursion_limit: Optional[int]) -> Optional[int]:
|
|
80
|
+
"""Decrements the recursion limit if it's not None.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
recursion_limit: The current recursion limit.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
The decremented limit or None.
|
|
87
|
+
"""
|
|
88
|
+
if recursion_limit is None:
|
|
89
|
+
return
|
|
90
|
+
return int(recursion_limit) - 1
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def expand_event_categories(
|
|
94
|
+
event_categories: Optional[Iterable[EventCategory] | EventCategory],
|
|
95
|
+
) -> Set[EventCategory]:
|
|
96
|
+
"""Expands 'event' category to include instant and interval event types.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
event_categories: A single category or an iterable of categories.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
A set of expanded EventCategory members.
|
|
103
|
+
"""
|
|
104
|
+
if event_categories is None:
|
|
105
|
+
return set()
|
|
106
|
+
values = [EventCategory(c) for c in utils.make_argument_iterable(event_categories)]
|
|
107
|
+
if EventCategory.event in values:
|
|
108
|
+
values.remove(EventCategory.event)
|
|
109
|
+
values.extend([EventCategory.inst_evt, EventCategory.intv_evt])
|
|
110
|
+
return set(values)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def resolve_event_type_arg(
|
|
114
|
+
event_type_arg: Optional[Type[InstantEvent | IntervalEvent]],
|
|
115
|
+
event_silo_class: Type[PEventSilo],
|
|
116
|
+
timeline_class: Type[Timeline],
|
|
117
|
+
default_arg_name: str,
|
|
118
|
+
):
|
|
119
|
+
if event_type_arg is None:
|
|
120
|
+
if silo_inst_default := getattr(event_silo_class, default_arg_name, None):
|
|
121
|
+
return silo_inst_default
|
|
122
|
+
return getattr(timeline_class, default_arg_name)
|
|
123
|
+
return event_type_arg
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class Timeline(FixedCoordinateTypeObject):
|
|
127
|
+
"""Represents a timeline, accommodating events and other timelines (segments)."""
|
|
128
|
+
|
|
129
|
+
_locked: bool = False
|
|
130
|
+
"""This is set to True when the timeline is a segment embedded in a parent timeline.
|
|
131
|
+
Operations that change a timeline's length are not allowed on a locked timeline.
|
|
132
|
+
"""
|
|
133
|
+
_default_instant_event_type = InstantEvent
|
|
134
|
+
_default_interval_event_type = IntervalEvent
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def from_events(
|
|
138
|
+
cls,
|
|
139
|
+
events: Iterable[Event] | Event,
|
|
140
|
+
unit: Optional[TimeUnit | str] = None,
|
|
141
|
+
number_type: Optional[NumberType] = None,
|
|
142
|
+
**kwargs,
|
|
143
|
+
) -> Timeline:
|
|
144
|
+
"""Creates a Timeline from a collection of events.
|
|
145
|
+
|
|
146
|
+
Note: Event IDs may differ from those generated by this timeline.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
events: Event(s) to populate the timeline.
|
|
150
|
+
unit: TimeUnit for the timeline.
|
|
151
|
+
number_type: NumberType for the timeline.
|
|
152
|
+
**kwargs: Additional arguments for Timeline initialization.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
A new Timeline instance.
|
|
156
|
+
"""
|
|
157
|
+
events = utils.make_argument_iterable(events)
|
|
158
|
+
infer_unit, infer_type = unit is None, number_type is None
|
|
159
|
+
if (infer_unit or infer_type) and events:
|
|
160
|
+
first_event = events[0]
|
|
161
|
+
if infer_unit:
|
|
162
|
+
unit = first_event.unit
|
|
163
|
+
if infer_type:
|
|
164
|
+
number_type = first_event.number_type
|
|
165
|
+
if not events:
|
|
166
|
+
return cls(unit=unit, number_type=number_type, **kwargs)
|
|
167
|
+
length = C(max(e.end.value for e in events), (unit, number_type))
|
|
168
|
+
tl = cls(length, **kwargs)
|
|
169
|
+
tl.add_events(events, allow_expansion=True)
|
|
170
|
+
return tl
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def from_event_silo(
|
|
174
|
+
cls,
|
|
175
|
+
event_silo: PEventSilo,
|
|
176
|
+
instant_event_type: Optional[Type[InstantEvent]] = None,
|
|
177
|
+
interval_event_type: Optional[Type[IntervalEvent]] = None,
|
|
178
|
+
**kwargs,
|
|
179
|
+
) -> Self:
|
|
180
|
+
timeline = cls(**kwargs)
|
|
181
|
+
id_prefix = f"{timeline.id}/ev"
|
|
182
|
+
InstEvt = resolve_event_type_arg(
|
|
183
|
+
instant_event_type, event_silo, cls, "_default_instant_event_type"
|
|
184
|
+
)
|
|
185
|
+
IntvEvt = resolve_event_type_arg(
|
|
186
|
+
interval_event_type, event_silo, cls, "_default_interval_event_type"
|
|
187
|
+
)
|
|
188
|
+
timeline._add_instant_events(
|
|
189
|
+
list(
|
|
190
|
+
event_silo.iter_instant_events(event_type=InstEvt, id_prefix=id_prefix)
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
timeline._add_interval_events(
|
|
194
|
+
list(
|
|
195
|
+
event_silo.iter_interval_events(event_type=IntvEvt, id_prefix=id_prefix)
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
return timeline
|
|
199
|
+
|
|
200
|
+
@classmethod
|
|
201
|
+
def from_concatenated_segments(
|
|
202
|
+
cls, segments: Iterable[Timeline] | Timeline, **kwargs
|
|
203
|
+
) -> Timeline:
|
|
204
|
+
"""Creates a Timeline by concatenating segments sequentially.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
segments: Timeline segment(s) to concatenate.
|
|
208
|
+
**kwargs: Additional arguments for Timeline initialization.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
A new Timeline instance.
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
ValueError: If segments have different coordinate types.
|
|
215
|
+
"""
|
|
216
|
+
coordinate_types = {(seg.unit, seg.number_type) for seg in segments}
|
|
217
|
+
if len(coordinate_types) != 1:
|
|
218
|
+
raise ValueError(
|
|
219
|
+
f"Segments need to have a single coordinate type, got: {coordinate_types}"
|
|
220
|
+
)
|
|
221
|
+
ct = coordinate_types.pop()
|
|
222
|
+
init_args = dict(unit=ct[0], number_type=ct[1], **kwargs)
|
|
223
|
+
tl = cls(**init_args)
|
|
224
|
+
tl.append_segments(segments)
|
|
225
|
+
return tl
|
|
226
|
+
|
|
227
|
+
@classmethod
|
|
228
|
+
def from_timeline(
|
|
229
|
+
cls,
|
|
230
|
+
timeline: Timeline,
|
|
231
|
+
length: Coord = 0,
|
|
232
|
+
id_prefix: str = "tl",
|
|
233
|
+
uid: Optional[str] = None,
|
|
234
|
+
meta: Optional[dict] = None,
|
|
235
|
+
):
|
|
236
|
+
"""Creates an empty Timeline from another Timeline with the same coordinate type."""
|
|
237
|
+
if not isinstance(timeline, Timeline):
|
|
238
|
+
other_class_name = cls.__class__.__name__
|
|
239
|
+
raise ValueError(
|
|
240
|
+
f"Cannot create new {other_class_name} from a {timeline.class_name}."
|
|
241
|
+
)
|
|
242
|
+
new_timeline = cls(
|
|
243
|
+
unit=timeline.unit,
|
|
244
|
+
number_type=timeline.number_type,
|
|
245
|
+
id_prefix=id_prefix,
|
|
246
|
+
uid=uid,
|
|
247
|
+
meta=meta,
|
|
248
|
+
)
|
|
249
|
+
new_timeline.length = length
|
|
250
|
+
return new_timeline
|
|
251
|
+
|
|
252
|
+
def __init__(
|
|
253
|
+
self,
|
|
254
|
+
length: Coord = 0,
|
|
255
|
+
unit: Optional[TimeUnit | str] = None,
|
|
256
|
+
number_type: Optional[NumberType] = None,
|
|
257
|
+
id_prefix: str = "tl",
|
|
258
|
+
uid: Optional[str] = None,
|
|
259
|
+
meta: Optional[dict] = None,
|
|
260
|
+
):
|
|
261
|
+
"""Initializes a Timeline.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
length: The length of the timeline.
|
|
265
|
+
unit: The time unit of the timeline.
|
|
266
|
+
number_type: The number type for coordinates.
|
|
267
|
+
id_prefix: Prefix for automatically generated ID.
|
|
268
|
+
uid: Unique identifier.
|
|
269
|
+
meta: Metadata dictionary.
|
|
270
|
+
"""
|
|
271
|
+
length = make_coordinate(
|
|
272
|
+
value=length,
|
|
273
|
+
unit=unit,
|
|
274
|
+
number_type=number_type,
|
|
275
|
+
default_unit=self._default_unit,
|
|
276
|
+
default_number_type=self._default_number_type,
|
|
277
|
+
)
|
|
278
|
+
super().__init__(
|
|
279
|
+
unit=length.unit,
|
|
280
|
+
number_type=length.number_type,
|
|
281
|
+
id_prefix=id_prefix,
|
|
282
|
+
uid=uid,
|
|
283
|
+
)
|
|
284
|
+
self._meta = Meta() if meta is None else Meta(meta)
|
|
285
|
+
self._length = None
|
|
286
|
+
self.length = length
|
|
287
|
+
self._instants: DF = make_inst_df_from_segment(self, meta=self.meta)
|
|
288
|
+
"""DataFrame keeping track of all things located on this timeline. The index comprises
|
|
289
|
+
all instants in chronological order including duplicates. Each row represents one
|
|
290
|
+
event which can correspond to beginning or end of an IntervalEvent or Segment,
|
|
291
|
+
or to an InstantEvent, according to the columns
|
|
292
|
+
["id", "class_name", "instant_type", "event_category", "length"]:
|
|
293
|
+
|
|
294
|
+
* ID of the event or segment which the instant corresponds to
|
|
295
|
+
* name of the event's class for type distinction
|
|
296
|
+
* :class:`InstantType` ("start", "end", or "inst")
|
|
297
|
+
* :class:`EventCategory` ("seg", "inst_evt", "intv_evt")
|
|
298
|
+
* the length of the event (undefined for InstantEvents).
|
|
299
|
+
|
|
300
|
+
From this core representation all sorts of derivative representations can be created
|
|
301
|
+
flexibly, including SyncMaps.
|
|
302
|
+
"""
|
|
303
|
+
self._intervals: DF = make_intv_df_from_segment(self, meta=self.meta)
|
|
304
|
+
"""DataFrame keeping track of all IntervalEvents and Segments located on this timeline.
|
|
305
|
+
This is similar to :attr:`_instants` except that this :class.`DF` has a sorted IntervalIndex
|
|
306
|
+
and does not represent InstantEvents nor the interval spanning this Timeline. The columns
|
|
307
|
+
["id", "class_name", "event_category", "length"] represent:
|
|
308
|
+
|
|
309
|
+
* ID of the event or segment which the instant corresponds to
|
|
310
|
+
* name of the event's class for type distinction
|
|
311
|
+
* :class:`EventCategory` ("seg", "intv_evt")
|
|
312
|
+
* the length of the event.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
def __repr__(self):
|
|
316
|
+
return f"{self.class_name}(id={self.id!r}, length={self.length})"
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def length(self) -> Coordinate:
|
|
320
|
+
"""The total length of the timeline."""
|
|
321
|
+
return self._length
|
|
322
|
+
|
|
323
|
+
@length.setter
|
|
324
|
+
def length(self, new_length: Coord):
|
|
325
|
+
if self._length is None:
|
|
326
|
+
self._length = self.make_coordinate(new_length)
|
|
327
|
+
self.logger.debug(
|
|
328
|
+
f"{self.id}: {self.class_name}.length set to {self._length}"
|
|
329
|
+
)
|
|
330
|
+
return
|
|
331
|
+
if new_length == self._length:
|
|
332
|
+
return
|
|
333
|
+
if self._locked:
|
|
334
|
+
raise ValueError(f"Cannot change length of locked timeline {self.id}.")
|
|
335
|
+
if new_length < self._length:
|
|
336
|
+
all_instants = self.get_instants(include_self=False)
|
|
337
|
+
later_events = all_instants.index > new_length
|
|
338
|
+
if later_events.any():
|
|
339
|
+
raise ValueError(
|
|
340
|
+
f"The length of {self.id} cannot be reduced from {self._length} to "
|
|
341
|
+
f"{new_length} because there are {later_events.sum()} instants occurring "
|
|
342
|
+
f"after that."
|
|
343
|
+
)
|
|
344
|
+
new_length_coordinate = self.make_coordinate(new_length)
|
|
345
|
+
self._length = new_length_coordinate
|
|
346
|
+
|
|
347
|
+
# now the timeline's end instant needs to be adapted both in ._instants and in ._intervals
|
|
348
|
+
new_length = new_length_coordinate.value # making sure it's just a number
|
|
349
|
+
# adapting instants index
|
|
350
|
+
inst_mask = (self._instants["id"] == self.id) & (
|
|
351
|
+
self._instants["instant_type"] == InstantType.end
|
|
352
|
+
)
|
|
353
|
+
index_values = self._instants.index.to_numpy()
|
|
354
|
+
try:
|
|
355
|
+
index_values[inst_mask] = new_length
|
|
356
|
+
except IndexError:
|
|
357
|
+
missing_id = self._instants.query("id.isna()")
|
|
358
|
+
self.logger.warning(f"The following events are missing ids: {missing_id}")
|
|
359
|
+
index_values[inst_mask.fillna(False)] = new_length
|
|
360
|
+
self._instants.index = pd.Index(index_values, name=self._instants.index.name)
|
|
361
|
+
self._instants = utils.sort_instants(self._instants)
|
|
362
|
+
# adapting intervals index
|
|
363
|
+
intv_mask = self._intervals["id"] == self.id
|
|
364
|
+
self._intervals.index = utils.new_interval_index(
|
|
365
|
+
self._intervals.index, mask=intv_mask, new_right=new_length
|
|
366
|
+
)
|
|
367
|
+
self._intervals.loc[intv_mask.values, "length"] = str(
|
|
368
|
+
new_length
|
|
369
|
+
) # for now, all columns hold strings
|
|
370
|
+
self._intervals.sort_index(inplace=True, key=utils.longer_intervals_first)
|
|
371
|
+
self.logger.debug(
|
|
372
|
+
f"{self.id}: {self.class_name}.length updated to {self._length}"
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
@property
|
|
376
|
+
def origin(self) -> Coordinate:
|
|
377
|
+
"""The origin (start point) of the timeline, typically 0."""
|
|
378
|
+
return self.make_coordinate(0)
|
|
379
|
+
|
|
380
|
+
@property
|
|
381
|
+
def instant(self) -> Coordinate:
|
|
382
|
+
"""Alias for the origin of the timeline."""
|
|
383
|
+
return self.origin
|
|
384
|
+
|
|
385
|
+
@property
|
|
386
|
+
def interval(self) -> tuple[Number, Number]:
|
|
387
|
+
"""A tuple representing the (start_value, end_value) of the timeline."""
|
|
388
|
+
return (self.start.value, self.end.value)
|
|
389
|
+
|
|
390
|
+
@property
|
|
391
|
+
def start(self) -> Coordinate:
|
|
392
|
+
"""The start coordinate of the timeline."""
|
|
393
|
+
return self.origin
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def end(self) -> Coordinate:
|
|
397
|
+
"""The end coordinate of the timeline."""
|
|
398
|
+
return self.length
|
|
399
|
+
|
|
400
|
+
@property
|
|
401
|
+
def meta(self) -> Meta:
|
|
402
|
+
"""Metadata associated with the timeline."""
|
|
403
|
+
return Meta(
|
|
404
|
+
id=self.id,
|
|
405
|
+
length=self.length,
|
|
406
|
+
unit=self.unit,
|
|
407
|
+
number_type=self.number_type,
|
|
408
|
+
**self._meta,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
@property
|
|
412
|
+
def conversion_maps(self):
|
|
413
|
+
return dict(self._cmaps)
|
|
414
|
+
|
|
415
|
+
@property
|
|
416
|
+
def zero(self) -> Number:
|
|
417
|
+
"""A zero value in the timeline's number type."""
|
|
418
|
+
return self.make_number(0)
|
|
419
|
+
|
|
420
|
+
@property
|
|
421
|
+
def n_instant_events(self) -> int:
|
|
422
|
+
"""Number of instant events directly on this timeline."""
|
|
423
|
+
return int(self._make_event_category_mask(EventCategory.instant_event).sum())
|
|
424
|
+
|
|
425
|
+
@property
|
|
426
|
+
def n_interval_events(self) -> int:
|
|
427
|
+
"""Number of interval events directly on this timeline."""
|
|
428
|
+
return int(self._make_event_category_mask(EventCategory.interval_event).sum())
|
|
429
|
+
|
|
430
|
+
@property
|
|
431
|
+
def n_segments(self) -> int:
|
|
432
|
+
"""Number of segments directly on this timeline."""
|
|
433
|
+
return int(self._make_event_category_mask(EventCategory.segment).sum())
|
|
434
|
+
|
|
435
|
+
def get_property_values(self, **kwargs) -> dict:
|
|
436
|
+
"""Retrieves a dictionary of common timeline properties.
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
**kwargs: Additional properties to include.
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
A dictionary of property names and their values.
|
|
443
|
+
"""
|
|
444
|
+
return dict(
|
|
445
|
+
id=self.id,
|
|
446
|
+
coordinate_type=self.coordinate_type,
|
|
447
|
+
unit=self.unit,
|
|
448
|
+
number_type=self.number_type,
|
|
449
|
+
start=self.start,
|
|
450
|
+
end=self.end,
|
|
451
|
+
length=self.length,
|
|
452
|
+
n_instant_events=self.n_instant_events,
|
|
453
|
+
n_interval_events=self.n_interval_events,
|
|
454
|
+
n_segments=self.n_segments,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
def add_events(
|
|
458
|
+
self,
|
|
459
|
+
events: Event | PEvent | Iterable[Event | PEvent],
|
|
460
|
+
allow_expansion: bool = False,
|
|
461
|
+
):
|
|
462
|
+
"""Adds event(s) to the timeline.
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
events: The event or iterable of events to add.
|
|
466
|
+
allow_expansion: If True, allows the timeline to expand if an event exceeds its length.
|
|
467
|
+
|
|
468
|
+
Raises:
|
|
469
|
+
ValueError: If an event type is invalid or expansion is prohibited.
|
|
470
|
+
"""
|
|
471
|
+
events = utils.make_argument_iterable(events)
|
|
472
|
+
inst, intv, invalid = [], [], []
|
|
473
|
+
for event in events:
|
|
474
|
+
try:
|
|
475
|
+
if event.end > self.length and (self._locked or not allow_expansion):
|
|
476
|
+
error_msg = self._make_error_message_for_prohibited_expansion(
|
|
477
|
+
event, event.start
|
|
478
|
+
)
|
|
479
|
+
raise ValueError(error_msg)
|
|
480
|
+
except TypeError:
|
|
481
|
+
event_info = f"{event.__class__.__name__}"
|
|
482
|
+
if event_id := getattr(event, "id", None):
|
|
483
|
+
event_info += f" ({event_id})"
|
|
484
|
+
if event_unit := getattr(event, "unit", None):
|
|
485
|
+
event_info += f" with unit {event_unit}"
|
|
486
|
+
else:
|
|
487
|
+
event_info += " which has no unit."
|
|
488
|
+
raise TypeError(
|
|
489
|
+
f"{self.class_name} {self.id} is measured in {self.unit} and cannot "
|
|
490
|
+
f"accomodate {event_info}."
|
|
491
|
+
)
|
|
492
|
+
if hasattr(event, "instant"): # implements the PInstantEvent Protocol
|
|
493
|
+
inst.append(event)
|
|
494
|
+
elif hasattr(event, "start"):
|
|
495
|
+
intv.append(event)
|
|
496
|
+
else:
|
|
497
|
+
invalid.append(event)
|
|
498
|
+
if inst:
|
|
499
|
+
self._add_instant_events(inst)
|
|
500
|
+
if intv:
|
|
501
|
+
self._add_interval_events(intv)
|
|
502
|
+
if invalid:
|
|
503
|
+
invalid_types = [type(event).__name__ for event in invalid]
|
|
504
|
+
raise ValueError(
|
|
505
|
+
f"The following events have neither 'instant' nor 'start': {invalid_types}"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
def _add_instant_events(self, events: InstantEvent | Iterable[InstantEvent]):
|
|
509
|
+
"""Internal helper to add instant events.
|
|
510
|
+
|
|
511
|
+
Args:
|
|
512
|
+
events: The instant event(s) to add.
|
|
513
|
+
"""
|
|
514
|
+
events = utils.make_argument_iterable(events)
|
|
515
|
+
new_inst_df = make_inst_df_from_inst_events(events=events, meta=self.meta)
|
|
516
|
+
self._update_instants(new_inst_df)
|
|
517
|
+
|
|
518
|
+
def _add_interval_events(
|
|
519
|
+
self, events: IntervalEvent | Iterable[IntervalEvent] # Corrected type hint
|
|
520
|
+
):
|
|
521
|
+
"""Internal helper to add interval events.
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
events: The interval event(s) to add.
|
|
525
|
+
"""
|
|
526
|
+
events = utils.make_argument_iterable(events)
|
|
527
|
+
new_inst_df = make_inst_df_from_intv_events(events=events, meta=self.meta)
|
|
528
|
+
self._update_instants(new_inst_df)
|
|
529
|
+
new_intv_df = make_intv_df_from_intv_events(events=events, meta=self.meta)
|
|
530
|
+
self._update_intervals(new_intv_df)
|
|
531
|
+
|
|
532
|
+
def add_instants(
|
|
533
|
+
self,
|
|
534
|
+
instants: Iterable[Coord] | Coord,
|
|
535
|
+
objects: Iterable[object] | object,
|
|
536
|
+
allow_expansion: bool = False,
|
|
537
|
+
):
|
|
538
|
+
"""Adds instants and their associated objects to the timeline. Objects can be external
|
|
539
|
+
in which case they will be registered with an ID based on their type. This mechanism
|
|
540
|
+
creates implicit InstantEvents in the sense that the added objects will not themselves be
|
|
541
|
+
associated with any time-related information and any such information (e.g. of existing
|
|
542
|
+
Event objects) will be ignored.
|
|
543
|
+
"""
|
|
544
|
+
instants = pd.Index(treat_instants_argument(instants, ensure_unit=self.unit))
|
|
545
|
+
objects = list(utils.make_argument_iterable(objects))
|
|
546
|
+
if not len(objects):
|
|
547
|
+
self.logger.warning("Received no objects to to add")
|
|
548
|
+
return
|
|
549
|
+
if instants.max() > self.length and (self._locked or not allow_expansion):
|
|
550
|
+
obj = objects[instants.argmax()]
|
|
551
|
+
error_msg = self._make_error_message_for_prohibited_expansion(
|
|
552
|
+
obj, instants.max()
|
|
553
|
+
)
|
|
554
|
+
raise ValueError(error_msg)
|
|
555
|
+
new_inst_df = make_inst_df_from_objs_and_index(
|
|
556
|
+
objs=objects, index=instants, meta=self.meta
|
|
557
|
+
)
|
|
558
|
+
self._update_instants(new_inst_df)
|
|
559
|
+
|
|
560
|
+
def add_intervals(
|
|
561
|
+
self,
|
|
562
|
+
intervals: (
|
|
563
|
+
pd.IntervalIndex
|
|
564
|
+
| Iterable[tuple[Coord, Coord]]
|
|
565
|
+
| tuple[Coord, Coord]
|
|
566
|
+
| pd.Interval
|
|
567
|
+
),
|
|
568
|
+
objects: Iterable[object] | object,
|
|
569
|
+
allow_expansion: bool = False,
|
|
570
|
+
):
|
|
571
|
+
"""Adds intervals and their associated objects to the timeline. Objects can be external
|
|
572
|
+
in which case they will be registered with an ID based on their type. This mechanism
|
|
573
|
+
creates implicit IntervalEvents in the sense that the added objects will not themselves be
|
|
574
|
+
associated with any time-related information and any such information (e.g. of existing
|
|
575
|
+
Event objects) will be ignored.
|
|
576
|
+
"""
|
|
577
|
+
objects = list(utils.make_argument_iterable(objects))
|
|
578
|
+
if not len(objects):
|
|
579
|
+
self.logger.warning("Received no objects to to add")
|
|
580
|
+
return
|
|
581
|
+
iix = treat_intervals_argument(
|
|
582
|
+
intervals, ensure_unit=self.unit, make_iterable_singular=len(objects) == 1
|
|
583
|
+
)
|
|
584
|
+
if iix.right.max() > self.length and (self._locked or not allow_expansion):
|
|
585
|
+
obj = objects[iix.right.argmax()]
|
|
586
|
+
error_msg = self._make_error_message_for_prohibited_expansion(
|
|
587
|
+
obj, iix.right.max()
|
|
588
|
+
)
|
|
589
|
+
raise ValueError(error_msg)
|
|
590
|
+
new_inst_df = make_inst_df_from_objs_and_index(
|
|
591
|
+
objs=objects, index=iix.left, meta=self.meta
|
|
592
|
+
)
|
|
593
|
+
self._update_instants(new_inst_df)
|
|
594
|
+
new_intv_df = make_intv_df_from_objs_and_index(
|
|
595
|
+
objs=objects, intervals=iix, meta=self.meta
|
|
596
|
+
)
|
|
597
|
+
self._update_intervals(new_intv_df)
|
|
598
|
+
|
|
599
|
+
def count_events(
|
|
600
|
+
self,
|
|
601
|
+
instant_events: bool = True,
|
|
602
|
+
interval_events: bool = True,
|
|
603
|
+
segments: bool = False,
|
|
604
|
+
) -> int:
|
|
605
|
+
"""Counts events on the timeline based on specified types.
|
|
606
|
+
|
|
607
|
+
Args:
|
|
608
|
+
instant_events: If True, count instant events.
|
|
609
|
+
interval_events: If True, count interval events.
|
|
610
|
+
segments: If True, count segments.
|
|
611
|
+
|
|
612
|
+
Returns:
|
|
613
|
+
The total count of specified event types.
|
|
614
|
+
"""
|
|
615
|
+
event_count = 0
|
|
616
|
+
if instant_events:
|
|
617
|
+
event_count += self.n_instant_events
|
|
618
|
+
if interval_events:
|
|
619
|
+
event_count += self.n_interval_events
|
|
620
|
+
if segments:
|
|
621
|
+
event_count += self.n_segments
|
|
622
|
+
return event_count
|
|
623
|
+
|
|
624
|
+
def validate_segment(
|
|
625
|
+
self, segment: Timeline, start: Coordinate, allow_expansion: bool = False
|
|
626
|
+
):
|
|
627
|
+
"""Checks if a segment can be added to this timeline.
|
|
628
|
+
|
|
629
|
+
Args:
|
|
630
|
+
segment: The segment to validate.
|
|
631
|
+
start: The proposed start coordinate for the segment on this timeline.
|
|
632
|
+
allow_expansion: If True, allows timeline expansion.
|
|
633
|
+
|
|
634
|
+
Raises:
|
|
635
|
+
AssertionError: If segment is not of the same class.
|
|
636
|
+
NotImplementedError: If coordinate types require conversion.
|
|
637
|
+
ValueError: If adding the segment would cause prohibited expansion.
|
|
638
|
+
"""
|
|
639
|
+
assert isinstance(segment, self.__class__), (
|
|
640
|
+
f"{segment} (a {segment.name}) is not a {self.class_name} and cannot be "
|
|
641
|
+
f"added."
|
|
642
|
+
)
|
|
643
|
+
if segment.coordinate_type != self.coordinate_type:
|
|
644
|
+
NotImplementedError(
|
|
645
|
+
f"{segment.coordinate_type}s need conversion to {self.coordinate_type}"
|
|
646
|
+
)
|
|
647
|
+
new_length = start + segment.length
|
|
648
|
+
if new_length > self.length and (self._locked or not allow_expansion):
|
|
649
|
+
error_msg = self._make_error_message_for_prohibited_expansion(
|
|
650
|
+
segment, start
|
|
651
|
+
)
|
|
652
|
+
raise ValueError(error_msg)
|
|
653
|
+
|
|
654
|
+
def add_segments(
|
|
655
|
+
self,
|
|
656
|
+
segments: Iterable[Timeline] | Timeline,
|
|
657
|
+
starts: Iterable[Coordinate] | Coordinate,
|
|
658
|
+
allow_expansion: bool = False,
|
|
659
|
+
):
|
|
660
|
+
"""Adds segment(s) to the timeline at specified start times.
|
|
661
|
+
|
|
662
|
+
Args:
|
|
663
|
+
segments: The segment or iterable of segments to add.
|
|
664
|
+
starts: The start coordinate or iterable of start coordinates.
|
|
665
|
+
allow_expansion: If True, allows the timeline to expand.
|
|
666
|
+
|
|
667
|
+
Raises:
|
|
668
|
+
AssertionError: If the number of segments and starts do not match.
|
|
669
|
+
"""
|
|
670
|
+
segments = list(utils.make_argument_iterable(segments))
|
|
671
|
+
starts = list(utils.make_argument_iterable(starts))
|
|
672
|
+
assert len(segments) == len(
|
|
673
|
+
starts
|
|
674
|
+
), f"Got {len(starts)} starts for {len(segments)} segments."
|
|
675
|
+
start_coordinates = []
|
|
676
|
+
for start_val in starts:
|
|
677
|
+
if isinstance(start_val, Coordinate):
|
|
678
|
+
if start_val.unit != self.unit:
|
|
679
|
+
raise NotImplementedError(
|
|
680
|
+
f"The start coordinates's {start_val.unit} needs conversion to"
|
|
681
|
+
f" {self.unit}"
|
|
682
|
+
)
|
|
683
|
+
start_coordinates.append(start_val)
|
|
684
|
+
else:
|
|
685
|
+
start_coordinates.append(self.make_coordinate(start_val))
|
|
686
|
+
for i, segment in enumerate(segments):
|
|
687
|
+
self.validate_segment(
|
|
688
|
+
segment, start=start_coordinates[i], allow_expansion=allow_expansion
|
|
689
|
+
)
|
|
690
|
+
self._add_segments(segments=segments, starts=start_coordinates)
|
|
691
|
+
|
|
692
|
+
def _add_segments(
|
|
693
|
+
self,
|
|
694
|
+
segments: Iterable[Timeline] | Timeline,
|
|
695
|
+
starts: Iterable[Coordinate] | Coordinate,
|
|
696
|
+
):
|
|
697
|
+
"""Internal helper to add segments without validation.
|
|
698
|
+
|
|
699
|
+
Args:
|
|
700
|
+
segments: The segment(s) to add.
|
|
701
|
+
starts: The start coordinate(s) for the segments.
|
|
702
|
+
"""
|
|
703
|
+
segments = utils.make_argument_iterable(segments)
|
|
704
|
+
starts = utils.make_argument_iterable(starts)
|
|
705
|
+
intv_dfs, inst_dfs = [], []
|
|
706
|
+
for segment, start in zip(segments, starts):
|
|
707
|
+
intv_dfs.append(
|
|
708
|
+
make_intv_df_from_segment(segment=segment, meta=self.meta, start=start)
|
|
709
|
+
)
|
|
710
|
+
inst_dfs.append(
|
|
711
|
+
make_inst_df_from_segment(segment=segment, meta=self.meta, start=start)
|
|
712
|
+
)
|
|
713
|
+
segment._locked = True
|
|
714
|
+
self.logger.debug(
|
|
715
|
+
f"Added segment {segment.id} to {self.id} @ {self.make_coordinate(start)}."
|
|
716
|
+
)
|
|
717
|
+
self._update_intervals(*intv_dfs)
|
|
718
|
+
self._update_instants(*inst_dfs)
|
|
719
|
+
|
|
720
|
+
def append_segments(self, segments: Iterable[Timeline] | Timeline):
|
|
721
|
+
"""Appends segment(s) to the end of the timeline, allowing expansion.
|
|
722
|
+
|
|
723
|
+
Args:
|
|
724
|
+
segments: The segment or iterable of segments to append.
|
|
725
|
+
"""
|
|
726
|
+
segments = utils.make_argument_iterable(segments)
|
|
727
|
+
for segment in segments:
|
|
728
|
+
self.add_segments(
|
|
729
|
+
segments=segment, starts=self.length, allow_expansion=True
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
def create_segment(
|
|
733
|
+
self,
|
|
734
|
+
start: Coordinate | Number,
|
|
735
|
+
end: Optional[Coordinate | Number] = None,
|
|
736
|
+
length: Optional[Coordinate | Number] = None,
|
|
737
|
+
id_prefix: str = "tl",
|
|
738
|
+
uid: Optional[str] = None,
|
|
739
|
+
meta: Optional[dict] = None,
|
|
740
|
+
allow_expansion: bool = False,
|
|
741
|
+
) -> Self:
|
|
742
|
+
utils.validate_interval_args(start=start, end=end, length=length)
|
|
743
|
+
if length is None:
|
|
744
|
+
length = end - start
|
|
745
|
+
segment = self.from_timeline(
|
|
746
|
+
timeline=self, length=length, id_prefix=id_prefix, uid=uid, meta=meta
|
|
747
|
+
)
|
|
748
|
+
self.add_segments(
|
|
749
|
+
segments=segment, starts=start, allow_expansion=allow_expansion
|
|
750
|
+
)
|
|
751
|
+
return segment
|
|
752
|
+
|
|
753
|
+
def convert_to(
|
|
754
|
+
self,
|
|
755
|
+
values: pd.Series | np.ndarray | Sequence[Coord] | Coord,
|
|
756
|
+
target_unit: TimeUnit,
|
|
757
|
+
target_type: Optional[NumberType] = None,
|
|
758
|
+
custom_converter_func: Optional[
|
|
759
|
+
Callable[[Union[Number, np.ndarray]], Union[Number, np.ndarray]]
|
|
760
|
+
] = None,
|
|
761
|
+
) -> Any:
|
|
762
|
+
try:
|
|
763
|
+
# first, try to use one of the added or default maps
|
|
764
|
+
return super().convert_to(
|
|
765
|
+
values=values,
|
|
766
|
+
target_unit=target_unit,
|
|
767
|
+
target_type=target_type,
|
|
768
|
+
custom_converter_func=custom_converter_func,
|
|
769
|
+
)
|
|
770
|
+
except ValueError:
|
|
771
|
+
# if it fails (because no such map is available) try to construct one
|
|
772
|
+
cmap_from_segments = self.make_conversion_map(
|
|
773
|
+
target_unit=target_unit,
|
|
774
|
+
target_type=target_type,
|
|
775
|
+
custom_converter_func=custom_converter_func,
|
|
776
|
+
)
|
|
777
|
+
return cmap_from_segments(values)
|
|
778
|
+
|
|
779
|
+
def get_instants(
|
|
780
|
+
self,
|
|
781
|
+
instant_types: Optional[Iterable[InstantType] | InstantType] = None,
|
|
782
|
+
event_categories: Optional[Iterable[EventCategory] | EventCategory] = None,
|
|
783
|
+
class_names: Optional[Iterable[str] | str] = None,
|
|
784
|
+
recursion_limit: Optional[int] = None,
|
|
785
|
+
include_self: bool = True,
|
|
786
|
+
) -> DF:
|
|
787
|
+
"""Retrieves instants from this timeline and optionally its segments.
|
|
788
|
+
|
|
789
|
+
Args:
|
|
790
|
+
instant_types: Filter by InstantType(s).
|
|
791
|
+
event_categories: Filter by EventCategory(s).
|
|
792
|
+
class_names: Filter by class name(s).
|
|
793
|
+
recursion_limit: Depth for recursive segment traversal.
|
|
794
|
+
include_self: If True, include start and end of this timeline itself.
|
|
795
|
+
|
|
796
|
+
Returns:
|
|
797
|
+
A DataFrame of filtered instants.
|
|
798
|
+
"""
|
|
799
|
+
mask = self._make_instants_mask(
|
|
800
|
+
instant_types=instant_types,
|
|
801
|
+
event_categories=event_categories,
|
|
802
|
+
class_names=class_names,
|
|
803
|
+
include_self=include_self,
|
|
804
|
+
)
|
|
805
|
+
instants = self._instants[mask]
|
|
806
|
+
if self.n_segments == 0 or recursion_limit_is_reached(recursion_limit):
|
|
807
|
+
return instants.copy()
|
|
808
|
+
recursion_limit = decrement_if_not_none(recursion_limit)
|
|
809
|
+
instants_dfs = [instants]
|
|
810
|
+
segment_mask = self._make_instants_mask(
|
|
811
|
+
instant_types=InstantType.start,
|
|
812
|
+
event_categories=EventCategory.seg,
|
|
813
|
+
include_self=False,
|
|
814
|
+
)
|
|
815
|
+
for start, segment_id in self._instants.loc[segment_mask, "id"].items():
|
|
816
|
+
segment = get_object_by_id(segment_id)
|
|
817
|
+
segment_instants = segment.get_instants(
|
|
818
|
+
instant_types=instant_types,
|
|
819
|
+
event_categories=event_categories,
|
|
820
|
+
class_names=class_names,
|
|
821
|
+
recursion_limit=recursion_limit,
|
|
822
|
+
include_self=False,
|
|
823
|
+
)
|
|
824
|
+
if segment_instants.empty:
|
|
825
|
+
continue
|
|
826
|
+
instants_dfs.append(
|
|
827
|
+
segment_instants.set_axis(segment_instants.index + start)
|
|
828
|
+
)
|
|
829
|
+
return pd_concat(instants_dfs, meta=self.meta).sort_index()
|
|
830
|
+
|
|
831
|
+
def get_timestamps(
|
|
832
|
+
self,
|
|
833
|
+
conversion_maps: Optional[
|
|
834
|
+
Iterable[ConversionMap | TimeUnit] | ConversionMap | TimeUnit
|
|
835
|
+
] = None,
|
|
836
|
+
recursion_limit: Optional[int] = None,
|
|
837
|
+
complete_self: bool = True,
|
|
838
|
+
) -> Optional[DF]:
|
|
839
|
+
"""Generates a DataFrame of timestamps, recursively including segments concatenated on the right.
|
|
840
|
+
|
|
841
|
+
Args:
|
|
842
|
+
conversion_maps:
|
|
843
|
+
CoordinatesMap(s) or TimeUnit(s) used to create columns in addition to the ones created by
|
|
844
|
+
the
|
|
845
|
+
recursion_limit: Depth for recursive segment traversal.
|
|
846
|
+
complete_self:
|
|
847
|
+
If True, After recursively constructing timestamps, the coordinates of the main timeline are still
|
|
848
|
+
present only where it encompasses events. By default, the gaps coming from positions on child timelines
|
|
849
|
+
are filled based on the axis. Set False to prevent that.
|
|
850
|
+
|
|
851
|
+
Returns:
|
|
852
|
+
A DataFrame with original and converted timestamps.
|
|
853
|
+
"""
|
|
854
|
+
timestamps_df = self.make_timestamps(conversion_maps=conversion_maps)
|
|
855
|
+
if recursion_limit_is_reached(recursion_limit):
|
|
856
|
+
return timestamps_df
|
|
857
|
+
|
|
858
|
+
recursion_limit = decrement_if_not_none(recursion_limit)
|
|
859
|
+
timestamps_dfs = [] if timestamps_df is None else [timestamps_df]
|
|
860
|
+
for start, segment in self.iter_segments(
|
|
861
|
+
recursion_limit=0, traversal_order=TraversalOrder.sorted, include_self=False
|
|
862
|
+
):
|
|
863
|
+
seg_timestamps = segment.get_timestamps(
|
|
864
|
+
conversion_maps=conversion_maps,
|
|
865
|
+
recursion_limit=recursion_limit,
|
|
866
|
+
complete_self=complete_self,
|
|
867
|
+
)
|
|
868
|
+
seg_timestamps.index += start.value
|
|
869
|
+
timestamps_dfs.append(seg_timestamps)
|
|
870
|
+
if not timestamps_dfs:
|
|
871
|
+
return
|
|
872
|
+
if len(timestamps_dfs) == 1:
|
|
873
|
+
return timestamps_dfs[0]
|
|
874
|
+
timestamps = pd_concat(timestamps_dfs, axis=1).sort_index()
|
|
875
|
+
if not complete_self:
|
|
876
|
+
return timestamps
|
|
877
|
+
coordinates = timestamps.index
|
|
878
|
+
completed_self_timestamps = self.make_timestamps(
|
|
879
|
+
conversion_maps=conversion_maps, values=coordinates
|
|
880
|
+
)
|
|
881
|
+
if timestamps_df is None:
|
|
882
|
+
return pd_concat([completed_self_timestamps, timestamps], axis=1)
|
|
883
|
+
return pd_concat(
|
|
884
|
+
[completed_self_timestamps, pd_concat(timestamps_dfs[1:], axis=1)], axis=1
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
def get_intervals(
|
|
888
|
+
self,
|
|
889
|
+
event_categories: Optional[Iterable[EventCategory] | EventCategory] = None,
|
|
890
|
+
class_names: Optional[Iterable[str] | str] = None,
|
|
891
|
+
recursion_limit: Optional[int] = None,
|
|
892
|
+
include_self: bool = True,
|
|
893
|
+
) -> DF:
|
|
894
|
+
"""Retrieves intervals for events and segments.
|
|
895
|
+
|
|
896
|
+
Args:
|
|
897
|
+
event_categories: Filter by EventCategory(s).
|
|
898
|
+
class_names: Filter by class name(s).
|
|
899
|
+
recursion_limit: Depth for recursive segment traversal.
|
|
900
|
+
include_self: If True, include intervals from this timeline itself.
|
|
901
|
+
|
|
902
|
+
Returns:
|
|
903
|
+
A DataFrame of filtered intervals.
|
|
904
|
+
"""
|
|
905
|
+
mask = self._make_intervals_mask(
|
|
906
|
+
event_categories=event_categories,
|
|
907
|
+
class_names=class_names,
|
|
908
|
+
include_self=include_self,
|
|
909
|
+
)
|
|
910
|
+
intervals = self._intervals[mask]
|
|
911
|
+
if self.n_segments == 0 or recursion_limit_is_reached(recursion_limit):
|
|
912
|
+
return intervals.copy()
|
|
913
|
+
recursion_limit = decrement_if_not_none(recursion_limit)
|
|
914
|
+
interval_dfs = [intervals]
|
|
915
|
+
segment_mask = self._make_instants_mask(
|
|
916
|
+
instant_types=InstantType.start,
|
|
917
|
+
event_categories=EventCategory.seg,
|
|
918
|
+
include_self=False,
|
|
919
|
+
)
|
|
920
|
+
for start, segment_id in self._instants.loc[segment_mask, "id"].items():
|
|
921
|
+
segment = get_object_by_id(segment_id)
|
|
922
|
+
segment_intervals = segment.get_intervals(
|
|
923
|
+
event_categories=event_categories,
|
|
924
|
+
class_names=class_names,
|
|
925
|
+
recursion_limit=recursion_limit,
|
|
926
|
+
include_self=False,
|
|
927
|
+
)
|
|
928
|
+
if segment_intervals.empty:
|
|
929
|
+
continue
|
|
930
|
+
interval_dfs.append(
|
|
931
|
+
segment_intervals.set_axis(
|
|
932
|
+
utils.shift_interval_index(segment_intervals.index, start)
|
|
933
|
+
)
|
|
934
|
+
)
|
|
935
|
+
return pd_concat(interval_dfs, meta=self.meta).sort_index(
|
|
936
|
+
key=utils.longer_intervals_first
|
|
937
|
+
)
|
|
938
|
+
|
|
939
|
+
def get_events(
|
|
940
|
+
self,
|
|
941
|
+
index_type: IndexType = IndexType.instant,
|
|
942
|
+
event_categories: Optional[
|
|
943
|
+
Iterable[EventCategory], EventCategory
|
|
944
|
+
] = EventCategory.events,
|
|
945
|
+
class_names: Optional[Iterable[str] | str] = None,
|
|
946
|
+
recursion_limit: Optional[int] = None,
|
|
947
|
+
**column2field,
|
|
948
|
+
) -> DF:
|
|
949
|
+
"""Retrieves events with their properties, indexed by specified type.
|
|
950
|
+
|
|
951
|
+
Args:
|
|
952
|
+
index_type: Type of index for the resulting DataFrame.
|
|
953
|
+
event_categories: Filter by EventCategory.
|
|
954
|
+
class_names: Filter by class name(s).
|
|
955
|
+
recursion_limit: Depth for recursive segment traversal.
|
|
956
|
+
**column2field: Mapping of DataFrame column names to metadata.
|
|
957
|
+
|
|
958
|
+
Returns:
|
|
959
|
+
A DataFrame of events and their properties.
|
|
960
|
+
"""
|
|
961
|
+
event_categories = expand_event_categories(event_categories)
|
|
962
|
+
index_type = IndexType(index_type)
|
|
963
|
+
if index_type == IndexType.intervals:
|
|
964
|
+
df = self.get_intervals(
|
|
965
|
+
event_categories=event_categories,
|
|
966
|
+
class_names=class_names,
|
|
967
|
+
recursion_limit=recursion_limit,
|
|
968
|
+
)
|
|
969
|
+
else:
|
|
970
|
+
instant_type = InstantType(index_type.value)
|
|
971
|
+
if instant_type == InstantType.instant:
|
|
972
|
+
instant_type = None
|
|
973
|
+
df = self.get_instants(
|
|
974
|
+
instant_types=instant_type,
|
|
975
|
+
event_categories=event_categories,
|
|
976
|
+
class_names=class_names,
|
|
977
|
+
recursion_limit=recursion_limit,
|
|
978
|
+
)
|
|
979
|
+
if len(df) == 0:
|
|
980
|
+
return df
|
|
981
|
+
id_is_duplicate = df.duplicated(subset="id")
|
|
982
|
+
if id_is_duplicate.any():
|
|
983
|
+
event_ids = df["id"].unique()
|
|
984
|
+
else:
|
|
985
|
+
event_ids = df["id"]
|
|
986
|
+
event_records = [
|
|
987
|
+
evt.get_property_values(**column2field) # ToDo: use get_event_properties()
|
|
988
|
+
for evt in iter_objects_by_ids(*event_ids)
|
|
989
|
+
]
|
|
990
|
+
if id_is_duplicate.any():
|
|
991
|
+
events = pd.DataFrame.from_records(event_records)
|
|
992
|
+
return pd.merge(df, events, how="left", on="id").set_axis(df.index)
|
|
993
|
+
events = pd.DataFrame.from_records(event_records, index=df.index)
|
|
994
|
+
return pd_concat([df, events.drop(columns="id")], axis=1, meta=df._meta)
|
|
995
|
+
|
|
996
|
+
def get_segment_by_id(
|
|
997
|
+
self,
|
|
998
|
+
segment_id: str,
|
|
999
|
+
) -> Timeline:
|
|
1000
|
+
"""Retrieves a segment by its ID.
|
|
1001
|
+
|
|
1002
|
+
Args:
|
|
1003
|
+
segment_id: The ID of the segment to retrieve.
|
|
1004
|
+
|
|
1005
|
+
Returns:
|
|
1006
|
+
The Timeline object for the segment.
|
|
1007
|
+
|
|
1008
|
+
Raises:
|
|
1009
|
+
TypeError: If the object found is not a Timeline.
|
|
1010
|
+
"""
|
|
1011
|
+
segment = get_object_by_id(segment_id)
|
|
1012
|
+
if not isinstance(segment, Timeline):
|
|
1013
|
+
raise TypeError(
|
|
1014
|
+
f"The object associated with ID {segment_id} is not a Timeline but a "
|
|
1015
|
+
f"{type(segment).__name__}."
|
|
1016
|
+
)
|
|
1017
|
+
return segment
|
|
1018
|
+
|
|
1019
|
+
def get_segments_by_ids(
|
|
1020
|
+
self, segment_ids: Iterable[str] | str
|
|
1021
|
+
) -> Dict[str, Timeline]:
|
|
1022
|
+
"""Retrieves multiple segments by their IDs.
|
|
1023
|
+
|
|
1024
|
+
Args:
|
|
1025
|
+
segment_ids: An iterable of segment IDs or a single segment ID.
|
|
1026
|
+
|
|
1027
|
+
Returns:
|
|
1028
|
+
A dictionary mapping segment IDs to Timeline objects.
|
|
1029
|
+
"""
|
|
1030
|
+
segment_ids = utils.make_argument_iterable(segment_ids)
|
|
1031
|
+
return {seg_id: self.get_segment_by_id(seg_id) for seg_id in segment_ids}
|
|
1032
|
+
|
|
1033
|
+
def iter_conversion_maps(
|
|
1034
|
+
self,
|
|
1035
|
+
target_units: Iterable[TimeUnit] | TimeUnit,
|
|
1036
|
+
recursion_limit: Optional[int] = None,
|
|
1037
|
+
traversal_order: TraversalOrder = TraversalOrder.sorted,
|
|
1038
|
+
include_self: bool = True,
|
|
1039
|
+
) -> Iterator[tuple[Coordinate, list[ConversionMap]]]:
|
|
1040
|
+
"""Iterates over conversion maps for this timeline and its segments.
|
|
1041
|
+
|
|
1042
|
+
Args:
|
|
1043
|
+
target_units: The target TimeUnit for conversion.
|
|
1044
|
+
recursion_limit: Depth for recursive segment traversal.
|
|
1045
|
+
traversal_order: Order of traversal (breadth_first, depth_first, sorted).
|
|
1046
|
+
include_self: If True, yield cmaps of this timeline first.
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
Yields:
|
|
1050
|
+
Tuples of (start_coordinate, CoordinatesMap).
|
|
1051
|
+
"""
|
|
1052
|
+
for start, segment in self.iter_segments(
|
|
1053
|
+
recursion_limit=recursion_limit,
|
|
1054
|
+
traversal_order=traversal_order,
|
|
1055
|
+
include_self=include_self,
|
|
1056
|
+
):
|
|
1057
|
+
yield start, segment.get_conversion_maps(target_units=target_units)
|
|
1058
|
+
|
|
1059
|
+
def _iter_segments(
|
|
1060
|
+
self,
|
|
1061
|
+
shift_start: Number = 0,
|
|
1062
|
+
recursion_limit: Optional[int] = None,
|
|
1063
|
+
breadth_first=False,
|
|
1064
|
+
include_self: bool = True,
|
|
1065
|
+
) -> Iterator[tuple[Coordinate, Timeline]]:
|
|
1066
|
+
"""Internal helper to iterate over segments recursively.
|
|
1067
|
+
|
|
1068
|
+
Args:
|
|
1069
|
+
shift_start: Offset to apply to segment start times.
|
|
1070
|
+
recursion_limit: Depth for recursion.
|
|
1071
|
+
breadth_first: If True, traverse breadth-first.
|
|
1072
|
+
include_self: If True, yield this timeline first.
|
|
1073
|
+
|
|
1074
|
+
Yields:
|
|
1075
|
+
Tuples of (shifted_start_coordinate, SegmentTimeline).
|
|
1076
|
+
"""
|
|
1077
|
+
if include_self:
|
|
1078
|
+
yield self.make_coordinate(shift_start), self
|
|
1079
|
+
if self.n_segments == 0 or recursion_limit_is_reached(recursion_limit, limit=0):
|
|
1080
|
+
reason = (
|
|
1081
|
+
"No segments" if self.n_segments == 0 else "Recursion limit reached"
|
|
1082
|
+
)
|
|
1083
|
+
self.logger.debug(
|
|
1084
|
+
f"{self.id}._iter_segments({shift_start=}, {recursion_limit=}, {include_self=}):"
|
|
1085
|
+
f" {reason}"
|
|
1086
|
+
)
|
|
1087
|
+
return
|
|
1088
|
+
recursion_limit = decrement_if_not_none(recursion_limit)
|
|
1089
|
+
segment_mask = self._make_instants_mask(
|
|
1090
|
+
instant_types=InstantType.start,
|
|
1091
|
+
event_categories=EventCategory.seg,
|
|
1092
|
+
include_self=False,
|
|
1093
|
+
)
|
|
1094
|
+
self.logger.debug(
|
|
1095
|
+
f"{self.id}._iter_segments({shift_start=}, {recursion_limit=}, {include_self=}): Yielding "
|
|
1096
|
+
f"from {segment_mask.sum()} segments {'breadth' if breadth_first else 'depth'}-first."
|
|
1097
|
+
)
|
|
1098
|
+
if breadth_first:
|
|
1099
|
+
this_level = []
|
|
1100
|
+
for start, segment_id in self._instants.loc[segment_mask, "id"].items():
|
|
1101
|
+
segment = get_object_by_id(segment_id)
|
|
1102
|
+
shifted_segment_start = self.make_coordinate(start + shift_start)
|
|
1103
|
+
this_level.append((shifted_segment_start, segment))
|
|
1104
|
+
yield shifted_segment_start, segment
|
|
1105
|
+
for shifted_segment_start, segment in this_level:
|
|
1106
|
+
yield from segment._iter_segments(
|
|
1107
|
+
shift_start=shifted_segment_start,
|
|
1108
|
+
recursion_limit=recursion_limit,
|
|
1109
|
+
breadth_first=breadth_first,
|
|
1110
|
+
include_self=False,
|
|
1111
|
+
)
|
|
1112
|
+
else:
|
|
1113
|
+
# depth first
|
|
1114
|
+
for start, segment_id in self._instants.loc[segment_mask, "id"].items():
|
|
1115
|
+
segment = get_object_by_id(segment_id)
|
|
1116
|
+
shifted_segment_start = self.make_coordinate(start + shift_start)
|
|
1117
|
+
yield from segment._iter_segments(
|
|
1118
|
+
shift_start=shifted_segment_start,
|
|
1119
|
+
recursion_limit=recursion_limit,
|
|
1120
|
+
breadth_first=breadth_first,
|
|
1121
|
+
include_self=True,
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
def iter_segments(
|
|
1125
|
+
self,
|
|
1126
|
+
shift_start: Number = 0,
|
|
1127
|
+
recursion_limit: Optional[int] = None,
|
|
1128
|
+
traversal_order: TraversalOrder = TraversalOrder.sorted,
|
|
1129
|
+
include_self: bool = True,
|
|
1130
|
+
) -> Iterator[tuple[Coordinate, Timeline]]:
|
|
1131
|
+
"""Iterates over this timeline and its segments.
|
|
1132
|
+
|
|
1133
|
+
Args:
|
|
1134
|
+
shift_start: Offset to apply to segment start times.
|
|
1135
|
+
recursion_limit: Depth for recursive segment traversal.
|
|
1136
|
+
traversal_order: Order of traversal (breadth_first, depth_first, sorted).
|
|
1137
|
+
include_self: If True, yield this timeline first (or as per sorted order).
|
|
1138
|
+
|
|
1139
|
+
Yields:
|
|
1140
|
+
Tuples of (shifted_start_coordinate, SegmentTimeline).
|
|
1141
|
+
"""
|
|
1142
|
+
traversal_order = TraversalOrder(traversal_order)
|
|
1143
|
+
if traversal_order == TraversalOrder.breadth_first:
|
|
1144
|
+
breadth_first = True
|
|
1145
|
+
else:
|
|
1146
|
+
# for sorted order, we go depth-first
|
|
1147
|
+
breadth_first = False
|
|
1148
|
+
if traversal_order == TraversalOrder.sorted:
|
|
1149
|
+
yield from sorted(
|
|
1150
|
+
self._iter_segments(
|
|
1151
|
+
shift_start=shift_start,
|
|
1152
|
+
recursion_limit=recursion_limit,
|
|
1153
|
+
breadth_first=breadth_first,
|
|
1154
|
+
include_self=include_self,
|
|
1155
|
+
),
|
|
1156
|
+
key=lambda tup: (tup[0], -tup[1].length.value),
|
|
1157
|
+
)
|
|
1158
|
+
else:
|
|
1159
|
+
yield from self._iter_segments(
|
|
1160
|
+
shift_start=shift_start,
|
|
1161
|
+
recursion_limit=recursion_limit,
|
|
1162
|
+
breadth_first=breadth_first,
|
|
1163
|
+
include_self=include_self,
|
|
1164
|
+
)
|
|
1165
|
+
|
|
1166
|
+
def make_conversion_map(
|
|
1167
|
+
self,
|
|
1168
|
+
target_unit: TimeUnit,
|
|
1169
|
+
target_type: Optional[NumberType] = None,
|
|
1170
|
+
custom_converter_func: Optional[
|
|
1171
|
+
Callable[[Union[Number, np.ndarray]], Union[Number, np.ndarray]]
|
|
1172
|
+
] = None,
|
|
1173
|
+
recursion_limit: Optional[int] = None,
|
|
1174
|
+
) -> Optional[ConversionMap]:
|
|
1175
|
+
"""Creates a CoordinatesMap or list of maps for this timeline.
|
|
1176
|
+
|
|
1177
|
+
Note: If recursive, currently returns a list of (start, map) tuples.
|
|
1178
|
+
A single concatenated map for recursive cases is a ToDo.
|
|
1179
|
+
|
|
1180
|
+
Args:
|
|
1181
|
+
target_unit: The target TimeUnit.
|
|
1182
|
+
target_type: The target NumberType.
|
|
1183
|
+
custom_converter_func: Custom conversion function.
|
|
1184
|
+
recursion_limit: Depth for recursive segment traversal.
|
|
1185
|
+
|
|
1186
|
+
Returns:
|
|
1187
|
+
A single CoordinatesMap or a list of (start, CoordinatesMap) tuples.
|
|
1188
|
+
"""
|
|
1189
|
+
cmap_line = self.make_cmap_line(
|
|
1190
|
+
target_units=target_unit, recursion_limit=recursion_limit
|
|
1191
|
+
)
|
|
1192
|
+
if cmap_line.n_interval_events == 0:
|
|
1193
|
+
raise ValueError(f"No cmaps available for unit {target_unit}.")
|
|
1194
|
+
cmap_regions = cmap_line.get_intervals()
|
|
1195
|
+
if not cmap_regions.index.is_non_overlapping_monotonic:
|
|
1196
|
+
raise NotImplementedError(
|
|
1197
|
+
f"The cmaps for unit {target_unit} cannot be concatenated into "
|
|
1198
|
+
f"a correct map: {cmap_regions.id}"
|
|
1199
|
+
)
|
|
1200
|
+
return ConcatenationMap(cmap_regions.id)
|
|
1201
|
+
|
|
1202
|
+
def get_coordinates(
|
|
1203
|
+
self,
|
|
1204
|
+
values: Iterable[Number] = None,
|
|
1205
|
+
name: Optional[str] = None,
|
|
1206
|
+
unique: bool = True,
|
|
1207
|
+
as_objects: bool = False,
|
|
1208
|
+
) -> DS:
|
|
1209
|
+
"""Creates a pandas Series of coordinates from given values or internal instants.
|
|
1210
|
+
|
|
1211
|
+
Args:
|
|
1212
|
+
values: Values to use for the coordinate column. Defaults to internal instants.
|
|
1213
|
+
name: Name for the Series. Defaults to timeline ID.
|
|
1214
|
+
unique: If True, drop duplicate index values.
|
|
1215
|
+
as_objects: If True, values will be Coordinate objects; otherwise, numerical.
|
|
1216
|
+
|
|
1217
|
+
Returns:
|
|
1218
|
+
A pandas Series representing the coordinate column.
|
|
1219
|
+
"""
|
|
1220
|
+
if values is None:
|
|
1221
|
+
values = self._instants.index
|
|
1222
|
+
if isinstance(values, pd.Index):
|
|
1223
|
+
index = values
|
|
1224
|
+
else:
|
|
1225
|
+
index = pd.Index(values, name="axis")
|
|
1226
|
+
if unique:
|
|
1227
|
+
index = index.drop_duplicates()
|
|
1228
|
+
if as_objects:
|
|
1229
|
+
coordinate_type = get_coordinate_type(self.coordinate_type)
|
|
1230
|
+
values = index.map(lambda x: Coordinate(x, coordinate_type))
|
|
1231
|
+
dtype = None
|
|
1232
|
+
else:
|
|
1233
|
+
values = index
|
|
1234
|
+
dtype = self.number_type.value
|
|
1235
|
+
if dtype == int:
|
|
1236
|
+
dtype = "Int64" # Use pandas nullable integer type
|
|
1237
|
+
if dtype == Fraction:
|
|
1238
|
+
dtype = object
|
|
1239
|
+
name = self.id if name is None else name
|
|
1240
|
+
return DS(values, index=index, dtype=dtype, name=name, meta=self.meta)
|
|
1241
|
+
|
|
1242
|
+
def make_cmap_line(
|
|
1243
|
+
self,
|
|
1244
|
+
target_units: Optional[Iterable[TimeUnit] | TimeUnit] = None,
|
|
1245
|
+
recursion_limit: Optional[int] = None,
|
|
1246
|
+
include_self: bool = True,
|
|
1247
|
+
) -> CmapLine:
|
|
1248
|
+
cmaps, cmap_intervals = [], []
|
|
1249
|
+
for start, segment in self.iter_segments(
|
|
1250
|
+
recursion_limit=recursion_limit,
|
|
1251
|
+
traversal_order=TraversalOrder.depth_first,
|
|
1252
|
+
include_self=include_self,
|
|
1253
|
+
):
|
|
1254
|
+
segment_cmaps = segment.get_conversion_maps(target_units=target_units)
|
|
1255
|
+
if not segment_cmaps:
|
|
1256
|
+
continue
|
|
1257
|
+
cmaps.extend(segment_cmaps)
|
|
1258
|
+
shifted_interval = segment.pd_interval + start.value
|
|
1259
|
+
cmap_intervals.extend([shifted_interval] * len(segment_cmaps))
|
|
1260
|
+
cl = CmapLine(self.length, id_prefix=f"{self.id}/cl")
|
|
1261
|
+
cl.add_intervals(intervals=cmap_intervals, objects=cmaps)
|
|
1262
|
+
return cl
|
|
1263
|
+
|
|
1264
|
+
def make_empty_copy(
|
|
1265
|
+
self,
|
|
1266
|
+
length: Coord = 0,
|
|
1267
|
+
id_prefix: str = "tl",
|
|
1268
|
+
uid: Optional[str] = None,
|
|
1269
|
+
meta: Optional[dict] = None,
|
|
1270
|
+
) -> Self:
|
|
1271
|
+
"""Returns a new timeline with the same timeline and coordinate type and length 0
|
|
1272
|
+
(unless another length is specified).
|
|
1273
|
+
"""
|
|
1274
|
+
return self.from_timeline(
|
|
1275
|
+
timeline=self, length=length, id_prefix=id_prefix, uid=uid, meta=meta
|
|
1276
|
+
)
|
|
1277
|
+
|
|
1278
|
+
def make_sync_map(
|
|
1279
|
+
self,
|
|
1280
|
+
fill_value: Optional[Any] = None,
|
|
1281
|
+
index_type: IndexType = IndexType.instant,
|
|
1282
|
+
event_categories: Optional[Iterable[EventCategory] | EventCategory] = None,
|
|
1283
|
+
class_names: Optional[Iterable[str] | str] = None,
|
|
1284
|
+
recursion_limit: Optional[int] = None,
|
|
1285
|
+
group_by: Optional[tuple[str, ...] | list[str]] = ("instant_type",),
|
|
1286
|
+
unstack: Optional[tuple[str, ...] | list[str]] = ("instant_type",),
|
|
1287
|
+
timestamps: bool = True,
|
|
1288
|
+
**column2field,
|
|
1289
|
+
) -> pd.DataFrame:
|
|
1290
|
+
"""Creates a synchronization map (SyncMap) from events.
|
|
1291
|
+
|
|
1292
|
+
This method will evolve in at least two ways:
|
|
1293
|
+
|
|
1294
|
+
1. Other units may be used to create different SyncMaps for the same timeline.
|
|
1295
|
+
2. A closeness epsilon, binning/quantization function, and similar things can be used
|
|
1296
|
+
to group temporally close coordinates.
|
|
1297
|
+
|
|
1298
|
+
Args:
|
|
1299
|
+
fill_value:
|
|
1300
|
+
Only relevant when `unstack` is specified. In this case, the SyncMap is cast to
|
|
1301
|
+
wide format with as many column levels as `unstack` arguments and empty cells are
|
|
1302
|
+
filled with this value. With the default `fill_value` = None, empty cells are left
|
|
1303
|
+
as `pd.NA` (null). The cleanest fill_value is `[]` because all existing values
|
|
1304
|
+
are lists of IDs.
|
|
1305
|
+
instant_type: Type of instant to use for indexing the SyncMap.
|
|
1306
|
+
event_categories: Filter events by category.
|
|
1307
|
+
class_names: Filter events by class name.
|
|
1308
|
+
recursion_limit: Depth for recursive segment traversal.
|
|
1309
|
+
group_by:
|
|
1310
|
+
A list of columns. Used to control for which value combinations event IDs are to be
|
|
1311
|
+
aggregated into lists. If `unstack` is not specified, n group_by columns will result
|
|
1312
|
+
in n+1 index levels (the first level is always unique coordinates). If m of the
|
|
1313
|
+
n group_by columns are also specified for `unstack`, the column index has m levels
|
|
1314
|
+
and the row index n-m + 1 levels.
|
|
1315
|
+
unstack: Levels to unstack for wide format. Needs to be a subset of `group_by`.
|
|
1316
|
+
timestamps:
|
|
1317
|
+
By default, all conversion maps are applied and timeline coordinates enriched
|
|
1318
|
+
accordingly. Set False to prevent and only get timeline coordinates.
|
|
1319
|
+
**column2field: Mapping of DataFrame column names to event property names.
|
|
1320
|
+
|
|
1321
|
+
Returns:
|
|
1322
|
+
A DataFrame representing the SyncMap.
|
|
1323
|
+
"""
|
|
1324
|
+
events = self.get_events(
|
|
1325
|
+
index_type=index_type,
|
|
1326
|
+
event_categories=event_categories,
|
|
1327
|
+
class_names=class_names,
|
|
1328
|
+
recursion_limit=recursion_limit,
|
|
1329
|
+
**column2field,
|
|
1330
|
+
).reset_index(names="instants")
|
|
1331
|
+
gpb_columns = ["instants"]
|
|
1332
|
+
if group_by:
|
|
1333
|
+
group_by = utils.make_argument_iterable(group_by)
|
|
1334
|
+
gpb_columns.extend(group_by)
|
|
1335
|
+
assert "id" not in gpb_columns, "Cannot group by the 'id' column."
|
|
1336
|
+
sync_map_long = events.groupby(gpb_columns).id.aggregate(list)
|
|
1337
|
+
if unstack:
|
|
1338
|
+
unstack = utils.make_argument_iterable(unstack)
|
|
1339
|
+
sync_map = (
|
|
1340
|
+
sync_map_long.unstack(unstack, fill_value=fill_value).sort_index(
|
|
1341
|
+
axis=1, key=lambda idx: idx.to_frame().replace(dict(ends="stop"))
|
|
1342
|
+
)
|
|
1343
|
+
# the key is making sure that end columns comes after start columns ("stop" is arbitrary)
|
|
1344
|
+
)
|
|
1345
|
+
else:
|
|
1346
|
+
sync_map = sync_map_long
|
|
1347
|
+
if timestamps:
|
|
1348
|
+
timestamps = self.get_timestamps()
|
|
1349
|
+
# reindexed_ts = timestamps.reindex(sync_map.index.get_level_values(0))
|
|
1350
|
+
return pd_concat([timestamps, sync_map], axis=1)
|
|
1351
|
+
return sync_map
|
|
1352
|
+
|
|
1353
|
+
def make_timestamps(
|
|
1354
|
+
self,
|
|
1355
|
+
conversion_maps: Optional[
|
|
1356
|
+
Iterable[ConversionMap | TimeUnit] | ConversionMap | TimeUnit
|
|
1357
|
+
] = None,
|
|
1358
|
+
values: Iterable[Number] = None,
|
|
1359
|
+
) -> Optional[DF]:
|
|
1360
|
+
"""Generates a DataFrame of timestamps, optionally converted to other units.
|
|
1361
|
+
|
|
1362
|
+
Args:
|
|
1363
|
+
conversion_maps: CoordinatesMap(s) or TimeUnit(s) for additional columns.
|
|
1364
|
+
|
|
1365
|
+
Returns:
|
|
1366
|
+
A DataFrame with original and converted timestamps, or None if empty.
|
|
1367
|
+
"""
|
|
1368
|
+
coordinates = self.get_coordinates(values=values)
|
|
1369
|
+
if coordinates.empty:
|
|
1370
|
+
return None
|
|
1371
|
+
columns = [coordinates]
|
|
1372
|
+
conversion_maps = self._resolve_cmaps_arg(conversion_maps)
|
|
1373
|
+
for cmap in conversion_maps.values():
|
|
1374
|
+
if cmap is None:
|
|
1375
|
+
continue
|
|
1376
|
+
converted_coordinates = cmap.convert_series(coordinates)
|
|
1377
|
+
columns.append(converted_coordinates)
|
|
1378
|
+
if len(columns) == 1:
|
|
1379
|
+
return coordinates.to_frame()
|
|
1380
|
+
return pd_concat(columns, axis=1, meta=self.meta)
|
|
1381
|
+
|
|
1382
|
+
def _resolve_cmaps_arg(
|
|
1383
|
+
self,
|
|
1384
|
+
conversion_maps: Optional[
|
|
1385
|
+
Iterable[ConversionMap | TimeUnit | ID_str]
|
|
1386
|
+
| ConversionMap
|
|
1387
|
+
| TimeUnit
|
|
1388
|
+
| ID_str
|
|
1389
|
+
] = None,
|
|
1390
|
+
include_added: bool = True,
|
|
1391
|
+
) -> dict[TimeUnit | ID_str, Optional[ConversionMap]]:
|
|
1392
|
+
"""Converts various conversion map arguments to a dictionary.
|
|
1393
|
+
|
|
1394
|
+
Args:
|
|
1395
|
+
conversion_maps: CoordinatesMap(s) or TimeUnit(s).
|
|
1396
|
+
include_added:
|
|
1397
|
+
By default, conversion maps added to this object are always included, e.g. when
|
|
1398
|
+
creating TimeStamps. Set False if you want to retrieve only cmaps for the arguments.
|
|
1399
|
+
|
|
1400
|
+
Returns:
|
|
1401
|
+
A dictionary mapping target TimeUnit to CoordinatesMap.
|
|
1402
|
+
"""
|
|
1403
|
+
conversion_maps = utils.make_argument_iterable(conversion_maps)
|
|
1404
|
+
result = self.conversion_maps if include_added else {}
|
|
1405
|
+
if not conversion_maps:
|
|
1406
|
+
return result
|
|
1407
|
+
for cmap_arg in conversion_maps:
|
|
1408
|
+
if isinstance(cmap_arg, str):
|
|
1409
|
+
cmap = self._resolve_cmap_argument(cmap_arg)
|
|
1410
|
+
result[cmap_arg] = cmap
|
|
1411
|
+
elif isinstance(cmap_arg, ConversionMap):
|
|
1412
|
+
result[cmap_arg.id] = cmap_arg
|
|
1413
|
+
else:
|
|
1414
|
+
raise ValueError(f"Cannot interpret {cmap_arg} as CoordinatesMap.")
|
|
1415
|
+
return result
|
|
1416
|
+
|
|
1417
|
+
def _resolve_cmap_argument(
|
|
1418
|
+
self, conversion_map: Optional[ConversionMap | TimeUnit | ID_str]
|
|
1419
|
+
) -> Optional[ConversionMap]:
|
|
1420
|
+
"""Resolves a conversion map argument to a CoordinatesMap instance.
|
|
1421
|
+
|
|
1422
|
+
Args:
|
|
1423
|
+
conversion_map_arg: A CoordinatesMap, TimeUnit, or string representing a TimeUnit.
|
|
1424
|
+
|
|
1425
|
+
Returns:
|
|
1426
|
+
A CoordinatesMap instance or None.
|
|
1427
|
+
|
|
1428
|
+
Raises:
|
|
1429
|
+
ValueError: If the argument cannot be interpreted as a CoordinatesMap.
|
|
1430
|
+
"""
|
|
1431
|
+
if conversion_map is None:
|
|
1432
|
+
return
|
|
1433
|
+
if isinstance(conversion_map, ConversionMap):
|
|
1434
|
+
return conversion_map
|
|
1435
|
+
if isinstance(conversion_map, str):
|
|
1436
|
+
try:
|
|
1437
|
+
unit = TimeUnit(conversion_map)
|
|
1438
|
+
except ValueError:
|
|
1439
|
+
self.logger.debug(
|
|
1440
|
+
f"{conversion_map} is not a TimeUnit and interpreted as id."
|
|
1441
|
+
)
|
|
1442
|
+
return get_object_by_id(conversion_map)
|
|
1443
|
+
return self.get_conversion_maps(unit)
|
|
1444
|
+
raise ValueError(f"Cannot interpret {conversion_map} as CoordinatesMap.")
|
|
1445
|
+
|
|
1446
|
+
def _make_event_category_mask(
|
|
1447
|
+
self, event_category: EventCategory, exclude_self: bool = True
|
|
1448
|
+
) -> pd.Series:
|
|
1449
|
+
"""Creates a boolean mask for a given event category.
|
|
1450
|
+
|
|
1451
|
+
Args:
|
|
1452
|
+
event_category: The EventCategory to filter by.
|
|
1453
|
+
exclude_self: If True and category is 'segment', exclude this timeline itself.
|
|
1454
|
+
|
|
1455
|
+
Returns:
|
|
1456
|
+
A pandas Series boolean mask.
|
|
1457
|
+
"""
|
|
1458
|
+
ev_cat = EventCategory(event_category)
|
|
1459
|
+
if ev_cat == EventCategory.inst_evt:
|
|
1460
|
+
if self._instants.empty:
|
|
1461
|
+
return pd.Series(dtype=bool)
|
|
1462
|
+
return self._instants.event_category == ev_cat
|
|
1463
|
+
if self._intervals.empty:
|
|
1464
|
+
return pd.Series(dtype=bool)
|
|
1465
|
+
result = self._intervals.event_category == ev_cat
|
|
1466
|
+
if ev_cat == EventCategory.seg and exclude_self:
|
|
1467
|
+
result &= self._intervals["id"] != self.id
|
|
1468
|
+
return result
|
|
1469
|
+
|
|
1470
|
+
def _make_error_message_for_prohibited_expansion(
|
|
1471
|
+
self, event: Event | Timeline, start: Coordinate
|
|
1472
|
+
) -> str:
|
|
1473
|
+
"""Generates an error message for prohibited timeline expansion.
|
|
1474
|
+
|
|
1475
|
+
Args:
|
|
1476
|
+
event: The event or timeline causing the potential expansion.
|
|
1477
|
+
start: The start coordinate of the event/timeline.
|
|
1478
|
+
|
|
1479
|
+
Returns:
|
|
1480
|
+
A formatted error message string.
|
|
1481
|
+
"""
|
|
1482
|
+
new_length = start + event.length
|
|
1483
|
+
reason = (
|
|
1484
|
+
"the timeline is locked" if self._locked else "'allow_expansion' is False"
|
|
1485
|
+
)
|
|
1486
|
+
error_msg = (
|
|
1487
|
+
f"Adding {event.__class__.__name__} of length {event.length} to {self.id} @ {start} "
|
|
1488
|
+
f"would result in an expansion to {new_length} but {reason}."
|
|
1489
|
+
)
|
|
1490
|
+
return error_msg
|
|
1491
|
+
|
|
1492
|
+
def _make_instants_mask(
|
|
1493
|
+
self,
|
|
1494
|
+
instant_types: Optional[Iterable[InstantType] | InstantType] = None,
|
|
1495
|
+
event_categories: Optional[Iterable[EventCategory] | EventCategory] = None,
|
|
1496
|
+
class_names: Optional[Iterable[str] | str] = None,
|
|
1497
|
+
include_self: bool = True,
|
|
1498
|
+
) -> pd.Series:
|
|
1499
|
+
"""Creates a boolean mask for filtering instants.
|
|
1500
|
+
|
|
1501
|
+
Args:
|
|
1502
|
+
instant_types: Filter by InstantType(s).
|
|
1503
|
+
event_categories: Filter by EventCategory(s).
|
|
1504
|
+
class_names: Filter by class name(s).
|
|
1505
|
+
include_self: If True, do not exclude this timeline's own segment representation.
|
|
1506
|
+
|
|
1507
|
+
Returns:
|
|
1508
|
+
A pandas Series boolean mask against `_instants`.
|
|
1509
|
+
"""
|
|
1510
|
+
if self._instants.empty:
|
|
1511
|
+
return pd.Series(dtype=bool)
|
|
1512
|
+
instant_types = [
|
|
1513
|
+
InstantType(t) for t in utils.make_argument_iterable(instant_types)
|
|
1514
|
+
]
|
|
1515
|
+
event_categories = expand_event_categories(event_categories)
|
|
1516
|
+
class_names = utils.make_argument_iterable(class_names)
|
|
1517
|
+
mask = pd.Series(True, index=self._instants.index)
|
|
1518
|
+
if instant_types:
|
|
1519
|
+
mask &= self._instants.instant_type.isin(instant_types)
|
|
1520
|
+
if event_categories:
|
|
1521
|
+
mask &= self._instants.event_category.isin(event_categories)
|
|
1522
|
+
if class_names:
|
|
1523
|
+
mask &= self._instants.class_name.isin(class_names)
|
|
1524
|
+
if not include_self:
|
|
1525
|
+
mask &= self._instants["id"] != self.id
|
|
1526
|
+
return mask
|
|
1527
|
+
|
|
1528
|
+
def _make_intervals_mask(
|
|
1529
|
+
self,
|
|
1530
|
+
event_categories: Optional[Iterable[EventCategory] | EventCategory] = None,
|
|
1531
|
+
class_names: Optional[Iterable[str] | str] = None,
|
|
1532
|
+
include_self: bool = True,
|
|
1533
|
+
) -> pd.Series:
|
|
1534
|
+
"""Creates a boolean mask for filtering intervals.
|
|
1535
|
+
|
|
1536
|
+
Args:
|
|
1537
|
+
event_categories: Filter by EventCategory(s).
|
|
1538
|
+
class_names: Filter by class name(s).
|
|
1539
|
+
include_self: If True, do not exclude this timeline's own segment representation.
|
|
1540
|
+
|
|
1541
|
+
Returns:
|
|
1542
|
+
A pandas Series boolean mask against `_intervals`.
|
|
1543
|
+
"""
|
|
1544
|
+
if self._intervals.empty:
|
|
1545
|
+
return pd.Series(dtype=bool)
|
|
1546
|
+
event_categories = expand_event_categories(event_categories)
|
|
1547
|
+
class_names = utils.make_argument_iterable(class_names)
|
|
1548
|
+
mask = pd.Series(True, index=self._intervals.index)
|
|
1549
|
+
if event_categories:
|
|
1550
|
+
mask &= self._intervals.event_category.isin(event_categories)
|
|
1551
|
+
if class_names:
|
|
1552
|
+
mask &= self._intervals.class_name.isin(class_names)
|
|
1553
|
+
if not include_self:
|
|
1554
|
+
mask &= self._intervals["id"] != self.id
|
|
1555
|
+
return mask
|
|
1556
|
+
|
|
1557
|
+
def _update_instants(self, *new_instants: DF):
|
|
1558
|
+
"""Updates the internal `_instants` DataFrame.
|
|
1559
|
+
|
|
1560
|
+
Args:
|
|
1561
|
+
*new_instants: DataFrame(s) containing new instants to add.
|
|
1562
|
+
"""
|
|
1563
|
+
if len(new_instants) == 0:
|
|
1564
|
+
return
|
|
1565
|
+
if self._instants.empty:
|
|
1566
|
+
if len(new_instants) == 1:
|
|
1567
|
+
new_inst_df = new_instants[0]
|
|
1568
|
+
else:
|
|
1569
|
+
new_inst_df = pd_concat(new_instants, meta=self.meta)
|
|
1570
|
+
else:
|
|
1571
|
+
new_inst_df = pd_concat(
|
|
1572
|
+
[self._instants] + list(new_instants), meta=self.meta
|
|
1573
|
+
)
|
|
1574
|
+
self._instants = utils.sort_instants(new_inst_df)
|
|
1575
|
+
self._update_length_from_instants()
|
|
1576
|
+
|
|
1577
|
+
def _update_intervals(self, *new_intervals: DF):
|
|
1578
|
+
"""Updates the internal `_intervals` DataFrame.
|
|
1579
|
+
|
|
1580
|
+
Args:
|
|
1581
|
+
*new_intervals: DataFrame(s) containing new intervals to add.
|
|
1582
|
+
"""
|
|
1583
|
+
if self._intervals.empty:
|
|
1584
|
+
self._intervals = pd_concat(new_intervals, meta=self.meta).sort_index()
|
|
1585
|
+
else:
|
|
1586
|
+
new_intv_df = pd_concat(
|
|
1587
|
+
[self._intervals] + list(new_intervals), meta=self.meta
|
|
1588
|
+
)
|
|
1589
|
+
self._intervals = new_intv_df.sort_index()
|
|
1590
|
+
|
|
1591
|
+
def _update_length_from_instants(self):
|
|
1592
|
+
current_length = self._instants.index.max()
|
|
1593
|
+
if current_length > self.length:
|
|
1594
|
+
self.length = current_length # setter turns the value into a coordinate
|
|
1595
|
+
|
|
1596
|
+
def _is_other_comparable(self, other: Any) -> bool:
|
|
1597
|
+
"""Checks if another object is a comparable Timeline.
|
|
1598
|
+
|
|
1599
|
+
Args:
|
|
1600
|
+
other: The object to compare.
|
|
1601
|
+
|
|
1602
|
+
Returns:
|
|
1603
|
+
True if comparable, False otherwise.
|
|
1604
|
+
"""
|
|
1605
|
+
return isinstance(other, Timeline) and other.unit == self.unit
|
|
1606
|
+
|
|
1607
|
+
def __lt__(self, other: Any) -> bool:
|
|
1608
|
+
"""Compares this timeline's length to another's if comparable.
|
|
1609
|
+
|
|
1610
|
+
Args:
|
|
1611
|
+
other: The object to compare.
|
|
1612
|
+
|
|
1613
|
+
Returns:
|
|
1614
|
+
True if this timeline is shorter, False otherwise or if not comparable.
|
|
1615
|
+
"""
|
|
1616
|
+
if not self._is_other_comparable(other):
|
|
1617
|
+
return NotImplemented
|
|
1618
|
+
return self.length < other.length
|
|
1619
|
+
|
|
1620
|
+
def __gt__(self, other: Any) -> bool:
|
|
1621
|
+
"""Compares this timeline's length to another's if comparable.
|
|
1622
|
+
|
|
1623
|
+
Args:
|
|
1624
|
+
other: The object to compare.
|
|
1625
|
+
|
|
1626
|
+
Returns:
|
|
1627
|
+
True if this timeline is longer, False otherwise or if not comparable.
|
|
1628
|
+
"""
|
|
1629
|
+
if not self._is_other_comparable(other):
|
|
1630
|
+
return NotImplemented
|
|
1631
|
+
return self.length > other.length
|
|
1632
|
+
|
|
1633
|
+
def __getitem__(
|
|
1634
|
+
self, item: str | Any
|
|
1635
|
+
) -> Any: # More specific type for item if possible
|
|
1636
|
+
"""Retrieves an object by ID if item is a string, otherwise defers to super.
|
|
1637
|
+
|
|
1638
|
+
Args:
|
|
1639
|
+
item: The ID string or other key.
|
|
1640
|
+
|
|
1641
|
+
Returns:
|
|
1642
|
+
The retrieved object or result from superclass.
|
|
1643
|
+
"""
|
|
1644
|
+
if isinstance(item, str):
|
|
1645
|
+
return get_object_by_id(item)
|
|
1646
|
+
return super().__getitem__(item)
|
|
1647
|
+
|
|
1648
|
+
|
|
1649
|
+
class CmapLine(Timeline):
|
|
1650
|
+
"""This type of timeline is specifically meant for holding :class:`ConversionMap` objects as
|
|
1651
|
+
implicit interval events. Typically constructed via :meth:`Timeline.make_cmap_line`.
|
|
1652
|
+
"""
|
|
1653
|
+
|
|
1654
|
+
def __init__(
|
|
1655
|
+
self,
|
|
1656
|
+
length: Coord = 0,
|
|
1657
|
+
unit: Optional[TimeUnit | str] = None,
|
|
1658
|
+
number_type: Optional[NumberType] = None,
|
|
1659
|
+
id_prefix: str = "cl",
|
|
1660
|
+
uid: Optional[str] = None,
|
|
1661
|
+
meta: Optional[dict] = None,
|
|
1662
|
+
):
|
|
1663
|
+
"""Initializes a CmapLine.
|
|
1664
|
+
|
|
1665
|
+
Args:
|
|
1666
|
+
length: The length of the CmapLine.
|
|
1667
|
+
unit: The time unit of the CmapLine.
|
|
1668
|
+
number_type: The number type for coordinates.
|
|
1669
|
+
id_prefix: Prefix for automatically generated ID.
|
|
1670
|
+
uid: Unique identifier.
|
|
1671
|
+
meta: Metadata dictionary.
|
|
1672
|
+
"""
|
|
1673
|
+
super().__init__(
|
|
1674
|
+
length=length,
|
|
1675
|
+
unit=unit,
|
|
1676
|
+
number_type=number_type,
|
|
1677
|
+
id_prefix=id_prefix,
|
|
1678
|
+
uid=uid,
|
|
1679
|
+
meta=meta,
|
|
1680
|
+
)
|
|
1681
|
+
|
|
1682
|
+
def get_instants(
|
|
1683
|
+
self,
|
|
1684
|
+
instant_types: Optional[Iterable[InstantType] | InstantType] = None,
|
|
1685
|
+
event_categories: Optional[
|
|
1686
|
+
Iterable[EventCategory] | EventCategory
|
|
1687
|
+
] = EventCategory.interval_events,
|
|
1688
|
+
class_names: Optional[Iterable[str] | str] = None,
|
|
1689
|
+
recursion_limit: Optional[int] = None,
|
|
1690
|
+
include_self: bool = True,
|
|
1691
|
+
) -> DF:
|
|
1692
|
+
return super().get_instants(
|
|
1693
|
+
instant_types=instant_types,
|
|
1694
|
+
event_categories=event_categories,
|
|
1695
|
+
class_names=class_names,
|
|
1696
|
+
recursion_limit=recursion_limit,
|
|
1697
|
+
include_self=include_self,
|
|
1698
|
+
)
|
|
1699
|
+
|
|
1700
|
+
def get_intervals(
|
|
1701
|
+
self,
|
|
1702
|
+
event_categories: Optional[
|
|
1703
|
+
Iterable[EventCategory] | EventCategory
|
|
1704
|
+
] = EventCategory.interval_events,
|
|
1705
|
+
class_names: Optional[Iterable[str] | str] = None,
|
|
1706
|
+
recursion_limit: Optional[int] = None,
|
|
1707
|
+
include_self: bool = False,
|
|
1708
|
+
) -> DF:
|
|
1709
|
+
return super().get_intervals(
|
|
1710
|
+
event_categories=event_categories,
|
|
1711
|
+
class_names=class_names,
|
|
1712
|
+
recursion_limit=recursion_limit,
|
|
1713
|
+
include_self=include_self,
|
|
1714
|
+
)
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
class _ContinuousMixin:
|
|
1718
|
+
"""Mixin for timelines with continuous number types (float, fraction)."""
|
|
1719
|
+
|
|
1720
|
+
_allowed_number_types = (NumberType.float, NumberType.fraction)
|
|
1721
|
+
|
|
1722
|
+
|
|
1723
|
+
class _DiscreteMixin:
|
|
1724
|
+
"""Mixin for timelines with discrete number types (int)."""
|
|
1725
|
+
|
|
1726
|
+
_allowed_number_types = NumberType.int
|
|
1727
|
+
_default_number_type = NumberType.int
|
|
1728
|
+
|
|
1729
|
+
|
|
1730
|
+
class LogicalTimeline(Timeline):
|
|
1731
|
+
"""A timeline representing musical time."""
|
|
1732
|
+
|
|
1733
|
+
_allowed_units = utils.get_time_units("musical", ("continuous", "discrete"))
|
|
1734
|
+
|
|
1735
|
+
|
|
1736
|
+
class PhysicalTimeline(Timeline):
|
|
1737
|
+
"""A timeline representing physical time."""
|
|
1738
|
+
|
|
1739
|
+
_allowed_units = utils.get_time_units("physical", ("continuous", "discrete"))
|
|
1740
|
+
|
|
1741
|
+
|
|
1742
|
+
class GraphicalTimeline(Timeline):
|
|
1743
|
+
"""A timeline representing graphical space/time."""
|
|
1744
|
+
|
|
1745
|
+
_allowed_units = utils.get_time_units("graphical", ("continuous", "discrete"))
|
|
1746
|
+
|
|
1747
|
+
|
|
1748
|
+
class ContinuousLogicalTimeline(_ContinuousMixin, LogicalTimeline):
|
|
1749
|
+
"""A musical timeline with continuous coordinates."""
|
|
1750
|
+
|
|
1751
|
+
_allowed_units = utils.get_time_units("musical", "continuous")
|
|
1752
|
+
_default_unit = TimeUnit.quarters
|
|
1753
|
+
_default_number_type = NumberType.fraction
|
|
1754
|
+
|
|
1755
|
+
|
|
1756
|
+
class DiscreteLogicalTimeline(_DiscreteMixin, LogicalTimeline):
|
|
1757
|
+
"""A musical timeline with discrete coordinates."""
|
|
1758
|
+
|
|
1759
|
+
_allowed_units = utils.get_time_units("musical", "discrete")
|
|
1760
|
+
_default_unit = TimeUnit.ticks
|
|
1761
|
+
_default_number_type = NumberType.int
|
|
1762
|
+
_default_instant_event_type = MidiInstantEvent
|
|
1763
|
+
_default_interval_event_type = MidiIntervalEvent
|
|
1764
|
+
|
|
1765
|
+
@classmethod
|
|
1766
|
+
def from_midi_file(cls, filepath: str, **kwargs) -> Self:
|
|
1767
|
+
"""Creates a DiscreteLogicalTimeline from a MIDI file.
|
|
1768
|
+
|
|
1769
|
+
Args:
|
|
1770
|
+
filepath: Path to the MIDI file.
|
|
1771
|
+
**kwargs: Additional arguments for timeline initialization.
|
|
1772
|
+
|
|
1773
|
+
Returns:
|
|
1774
|
+
A new DiscreteLogicalTimeline instance populated with MIDI events.
|
|
1775
|
+
"""
|
|
1776
|
+
event_silo = MidiDataFrameSilo.from_filepath(filepath)
|
|
1777
|
+
return event_silo.make_timeline(timeline_class=cls, **kwargs)
|
|
1778
|
+
|
|
1779
|
+
|
|
1780
|
+
class ContinuousPhysicalTimeline(_ContinuousMixin, PhysicalTimeline):
|
|
1781
|
+
"""A physical timeline with continuous coordinates."""
|
|
1782
|
+
|
|
1783
|
+
_allowed_units = utils.get_time_units("physical", "continuous")
|
|
1784
|
+
_default_unit = TimeUnit.seconds
|
|
1785
|
+
_default_number_type = NumberType.float
|
|
1786
|
+
|
|
1787
|
+
|
|
1788
|
+
class DiscretePhysicalTimeline(_DiscreteMixin, PhysicalTimeline):
|
|
1789
|
+
"""A physical timeline with discrete coordinates."""
|
|
1790
|
+
|
|
1791
|
+
_allowed_units = utils.get_time_units("physical", "discrete")
|
|
1792
|
+
_default_unit = TimeUnit.samples
|
|
1793
|
+
|
|
1794
|
+
@classmethod
|
|
1795
|
+
def from_audio_file(cls, filepath: str, **kwargs) -> DiscretePhysicalTimeline:
|
|
1796
|
+
"""Creates a DiscretePhysicalTimeline from an audio file.
|
|
1797
|
+
|
|
1798
|
+
Args:
|
|
1799
|
+
filepath: Path to the audio file.
|
|
1800
|
+
**kwargs: Additional arguments for timeline initialization.
|
|
1801
|
+
|
|
1802
|
+
Returns:
|
|
1803
|
+
A new DiscretePhysicalTimeline instance.
|
|
1804
|
+
"""
|
|
1805
|
+
silo = AudioSilo.from_filepath(filepath)
|
|
1806
|
+
return silo.make_timeline(timeline_class=cls, **kwargs)
|
|
1807
|
+
|
|
1808
|
+
|
|
1809
|
+
class ContinuousGraphicalTimeline(_ContinuousMixin, GraphicalTimeline):
|
|
1810
|
+
"""A graphical timeline with continuous coordinates."""
|
|
1811
|
+
|
|
1812
|
+
_allowed_units = utils.get_time_units("graphical", "continuous")
|
|
1813
|
+
_default_unit = TimeUnit.meters
|
|
1814
|
+
|
|
1815
|
+
|
|
1816
|
+
class DiscreteGraphicalTimeline(_DiscreteMixin, GraphicalTimeline):
|
|
1817
|
+
"""A graphical timeline with discrete coordinates."""
|
|
1818
|
+
|
|
1819
|
+
_allowed_units = utils.get_time_units("graphical", "discrete")
|
|
1820
|
+
_default_unit = TimeUnit.pixels
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
# region intervals dataframe helpers
|
|
1824
|
+
|
|
1825
|
+
|
|
1826
|
+
def replace_fractions_with_floats(intervals: list[tuple[Number, Number]]):
|
|
1827
|
+
"""This assumes that the number types of all intervals are identical so we don't need to check
|
|
1828
|
+
every type.
|
|
1829
|
+
"""
|
|
1830
|
+
if not intervals:
|
|
1831
|
+
return intervals
|
|
1832
|
+
first_iv = intervals[0]
|
|
1833
|
+
left, _ = first_iv
|
|
1834
|
+
if not isinstance(left, Fraction):
|
|
1835
|
+
return intervals
|
|
1836
|
+
return [(float(left), float(right)) for left, right in intervals]
|
|
1837
|
+
|
|
1838
|
+
|
|
1839
|
+
def make_intervals_df(
|
|
1840
|
+
rows: list[Sequence],
|
|
1841
|
+
intervals: list[tuple[Number, Number]],
|
|
1842
|
+
meta: Meta | dict,
|
|
1843
|
+
**column_meta,
|
|
1844
|
+
) -> DF:
|
|
1845
|
+
"""Creates a DataFrame with an IntervalIndex.
|
|
1846
|
+
|
|
1847
|
+
Args:
|
|
1848
|
+
rows: Data for the DataFrame rows.
|
|
1849
|
+
intervals: List of (left, right) tuples for IntervalIndex.
|
|
1850
|
+
meta: Metadata for the DataFrame.
|
|
1851
|
+
**column_meta: Additional metadata for columns.
|
|
1852
|
+
|
|
1853
|
+
Returns:
|
|
1854
|
+
A DataFrame with specified intervals.
|
|
1855
|
+
"""
|
|
1856
|
+
if isinstance(intervals, pd.IntervalIndex):
|
|
1857
|
+
iix = intervals
|
|
1858
|
+
else:
|
|
1859
|
+
intervals = replace_fractions_with_floats(intervals)
|
|
1860
|
+
iix = pd.IntervalIndex.from_tuples(intervals, closed="left")
|
|
1861
|
+
return DF(
|
|
1862
|
+
rows,
|
|
1863
|
+
columns=["id", "class_name", "event_category", "length"],
|
|
1864
|
+
index=iix,
|
|
1865
|
+
dtype="string",
|
|
1866
|
+
meta=Meta(meta),
|
|
1867
|
+
**column_meta,
|
|
1868
|
+
).rename_axis("axis")
|
|
1869
|
+
|
|
1870
|
+
|
|
1871
|
+
def make_empty_intv_df(meta: Meta | dict, **column_meta) -> DF:
|
|
1872
|
+
"""Creates an empty DataFrame suitable for interval data.
|
|
1873
|
+
|
|
1874
|
+
Args:
|
|
1875
|
+
meta: Metadata for the DataFrame.
|
|
1876
|
+
**column_meta: Additional metadata for columns.
|
|
1877
|
+
|
|
1878
|
+
Returns:
|
|
1879
|
+
An empty DataFrame for intervals.
|
|
1880
|
+
"""
|
|
1881
|
+
return make_intervals_df([], intervals=[], meta=Meta(meta), **column_meta)
|
|
1882
|
+
|
|
1883
|
+
|
|
1884
|
+
def make_intv_df_from_segment(
|
|
1885
|
+
segment: Timeline,
|
|
1886
|
+
meta: Meta | dict,
|
|
1887
|
+
start: Optional[Coordinate | Number] = None,
|
|
1888
|
+
**column_meta,
|
|
1889
|
+
) -> DF:
|
|
1890
|
+
"""Creates a single-row interval DataFrame from a segment.
|
|
1891
|
+
|
|
1892
|
+
Args:
|
|
1893
|
+
segment: The timeline segment.
|
|
1894
|
+
meta: Metadata for the DataFrame.
|
|
1895
|
+
start: Optional start offset for the segment's interval.
|
|
1896
|
+
**column_meta: Additional metadata for columns.
|
|
1897
|
+
|
|
1898
|
+
Returns:
|
|
1899
|
+
A DataFrame representing the segment as an interval.
|
|
1900
|
+
"""
|
|
1901
|
+
if start is None:
|
|
1902
|
+
start = segment.origin
|
|
1903
|
+
start_value = get_coordinate_value(start)
|
|
1904
|
+
seg_length = segment.length.value
|
|
1905
|
+
end_value = start_value + seg_length
|
|
1906
|
+
interval = (start_value, end_value)
|
|
1907
|
+
return make_intervals_df(
|
|
1908
|
+
[[segment.id, segment.class_name, EventCategory.seg, seg_length]],
|
|
1909
|
+
intervals=[interval],
|
|
1910
|
+
meta=Meta(meta),
|
|
1911
|
+
**column_meta,
|
|
1912
|
+
)
|
|
1913
|
+
|
|
1914
|
+
|
|
1915
|
+
def make_intv_df_from_intv_events(
|
|
1916
|
+
events: Iterable[IntervalEvent], meta: Meta | dict, **column_meta
|
|
1917
|
+
) -> DF:
|
|
1918
|
+
"""Creates an interval DataFrame from a collection of IntervalEvents.
|
|
1919
|
+
|
|
1920
|
+
Args:
|
|
1921
|
+
events: Iterable of IntervalEvent objects.
|
|
1922
|
+
meta: Metadata for the DataFrame.
|
|
1923
|
+
**column_meta: Additional metadata for columns.
|
|
1924
|
+
|
|
1925
|
+
Returns:
|
|
1926
|
+
A DataFrame representing the interval events.
|
|
1927
|
+
"""
|
|
1928
|
+
rows, intervals = [], []
|
|
1929
|
+
for event in events:
|
|
1930
|
+
start, end = get_coordinate_value(event.start), get_coordinate_value(event.end)
|
|
1931
|
+
if end is None:
|
|
1932
|
+
length = get_coordinate_value(event.length)
|
|
1933
|
+
end = start + length
|
|
1934
|
+
else:
|
|
1935
|
+
length = end - start
|
|
1936
|
+
event_id = ensure_registration(event)
|
|
1937
|
+
rows.append(
|
|
1938
|
+
[event_id, event.__class__.__name__, EventCategory.intv_evt, length]
|
|
1939
|
+
)
|
|
1940
|
+
intervals.append((start, end))
|
|
1941
|
+
return make_intervals_df(rows, intervals=intervals, meta=Meta(meta), **column_meta)
|
|
1942
|
+
|
|
1943
|
+
|
|
1944
|
+
def make_intv_df_from_objs_and_index(
|
|
1945
|
+
objs: Iterable[object], intervals: pd.Index, meta: Meta | dict, **column_meta
|
|
1946
|
+
) -> DF:
|
|
1947
|
+
"""Creates an interval DataFrame from a collection of IntervalEvents.
|
|
1948
|
+
|
|
1949
|
+
Args:
|
|
1950
|
+
objs: Iterable of objects.
|
|
1951
|
+
index: Index corresponding to [start, end) intervals on the corresponding timeline's axis.
|
|
1952
|
+
meta: Metadata for the DataFrame.
|
|
1953
|
+
**column_meta: Additional metadata for columns.
|
|
1954
|
+
|
|
1955
|
+
Returns:
|
|
1956
|
+
A DataFrame representing the interval events.
|
|
1957
|
+
"""
|
|
1958
|
+
rows = []
|
|
1959
|
+
for obj, intv in zip(objs, intervals):
|
|
1960
|
+
obj_id = ensure_registration(obj)
|
|
1961
|
+
rows.append(
|
|
1962
|
+
[obj_id, obj.__class__.__name__, EventCategory.intv_evt, intv.length]
|
|
1963
|
+
)
|
|
1964
|
+
return make_intervals_df(rows, intervals=intervals, meta=Meta(meta), **column_meta)
|
|
1965
|
+
|
|
1966
|
+
|
|
1967
|
+
# endregion intervals dataframe helpers
|
|
1968
|
+
# region instants dataframe helpers
|
|
1969
|
+
|
|
1970
|
+
|
|
1971
|
+
def make_instants_df(
|
|
1972
|
+
rows: list[Sequence], index: SequenceNotStr, meta: Meta | dict, **column_meta
|
|
1973
|
+
) -> DF:
|
|
1974
|
+
"""Creates a DataFrame for instant data.
|
|
1975
|
+
|
|
1976
|
+
Args:
|
|
1977
|
+
rows: Data for the DataFrame rows.
|
|
1978
|
+
index: Index values for the instants.
|
|
1979
|
+
meta: Metadata for the DataFrame.
|
|
1980
|
+
**column_meta: Additional metadata for columns.
|
|
1981
|
+
|
|
1982
|
+
Returns:
|
|
1983
|
+
A DataFrame representing instants.
|
|
1984
|
+
"""
|
|
1985
|
+
return DF(
|
|
1986
|
+
rows,
|
|
1987
|
+
columns=["id", "class_name", "instant_type", "event_category"],
|
|
1988
|
+
index=index,
|
|
1989
|
+
dtype="string",
|
|
1990
|
+
meta=meta,
|
|
1991
|
+
**column_meta,
|
|
1992
|
+
).rename_axis("axis")
|
|
1993
|
+
|
|
1994
|
+
|
|
1995
|
+
def make_empty_inst_df(meta: Meta | dict, **column_meta) -> DF:
|
|
1996
|
+
"""Creates an empty DataFrame suitable for instant data.
|
|
1997
|
+
|
|
1998
|
+
Args:
|
|
1999
|
+
meta: Metadata for the DataFrame.
|
|
2000
|
+
**column_meta: Additional metadata for columns.
|
|
2001
|
+
|
|
2002
|
+
Returns:
|
|
2003
|
+
An empty DataFrame for instants.
|
|
2004
|
+
"""
|
|
2005
|
+
return make_instants_df([], index=[], meta=Meta(meta), **column_meta)
|
|
2006
|
+
|
|
2007
|
+
|
|
2008
|
+
def make_inst_df_from_inst_events(
|
|
2009
|
+
events: Iterable[InstantEvent],
|
|
2010
|
+
meta: Meta | dict,
|
|
2011
|
+
ensure_unique_coordinate_type: bool = True,
|
|
2012
|
+
**column_meta,
|
|
2013
|
+
) -> DF:
|
|
2014
|
+
"""Creates an instant DataFrame from a collection of InstantEvents.
|
|
2015
|
+
|
|
2016
|
+
Args:
|
|
2017
|
+
events: Iterable of InstantEvent objects.
|
|
2018
|
+
meta: Metadata for the DataFrame.
|
|
2019
|
+
ensure_unique_coordinate_type: If True, assert all events share a coordinate type.
|
|
2020
|
+
**column_meta: Additional metadata for columns.
|
|
2021
|
+
|
|
2022
|
+
Returns:
|
|
2023
|
+
A DataFrame representing the instant events.
|
|
2024
|
+
"""
|
|
2025
|
+
rows, index = [], []
|
|
2026
|
+
units, number_types = set(), set()
|
|
2027
|
+
for event in events:
|
|
2028
|
+
event_id = ensure_registration(event)
|
|
2029
|
+
rows.append(
|
|
2030
|
+
[
|
|
2031
|
+
event_id,
|
|
2032
|
+
event.__class__.__name__,
|
|
2033
|
+
InstantType.inst,
|
|
2034
|
+
EventCategory.inst_evt,
|
|
2035
|
+
]
|
|
2036
|
+
)
|
|
2037
|
+
index.append(event.instant.value)
|
|
2038
|
+
units.add(event.instant.unit)
|
|
2039
|
+
number_types.add(event.instant.number_type)
|
|
2040
|
+
single_unit = len(units) == 1
|
|
2041
|
+
single_number_type = len(number_types) == 1
|
|
2042
|
+
if ensure_unique_coordinate_type:
|
|
2043
|
+
assert (
|
|
2044
|
+
single_unit
|
|
2045
|
+
), f"The events' coordinates have {len(units)} different units: {units}"
|
|
2046
|
+
assert single_number_type, (
|
|
2047
|
+
f"The events' coordinates have {len(number_types)} different number types: "
|
|
2048
|
+
f"{number_types}"
|
|
2049
|
+
)
|
|
2050
|
+
meta.update(
|
|
2051
|
+
dict(
|
|
2052
|
+
unit=units.pop() if single_unit else list(units),
|
|
2053
|
+
number_type=(
|
|
2054
|
+
number_types.pop() if single_number_type else list(number_types)
|
|
2055
|
+
),
|
|
2056
|
+
)
|
|
2057
|
+
)
|
|
2058
|
+
return make_instants_df(rows, index=index, meta=meta, **column_meta)
|
|
2059
|
+
|
|
2060
|
+
|
|
2061
|
+
def make_inst_df_from_objs_and_index(
|
|
2062
|
+
objs: Iterable[object], index: pd.Index, meta: Meta | dict, **column_meta
|
|
2063
|
+
) -> DF:
|
|
2064
|
+
"""Creates an instant DataFrame from a collection of InstantEvents.
|
|
2065
|
+
|
|
2066
|
+
Args:
|
|
2067
|
+
objs: Iterable of objects.
|
|
2068
|
+
index: Index corresponding to instants on the timeline's axis.
|
|
2069
|
+
meta: Metadata for the DataFrame.
|
|
2070
|
+
ensure_unique_coordinate_type: If True, assert all events share a coordinate type.
|
|
2071
|
+
**column_meta: Additional metadata for columns.
|
|
2072
|
+
|
|
2073
|
+
Returns:
|
|
2074
|
+
A DataFrame representing the instant events.
|
|
2075
|
+
"""
|
|
2076
|
+
rows = []
|
|
2077
|
+
for obj in objs:
|
|
2078
|
+
obj_id = ensure_registration(obj)
|
|
2079
|
+
rows.append(
|
|
2080
|
+
[obj_id, obj.__class__.__name__, InstantType.inst, EventCategory.inst_evt]
|
|
2081
|
+
)
|
|
2082
|
+
return make_instants_df(rows, index=index, meta=meta, **column_meta)
|
|
2083
|
+
|
|
2084
|
+
|
|
2085
|
+
def make_inst_df_from_intv_events(
|
|
2086
|
+
events: Iterable[IntervalEvent],
|
|
2087
|
+
meta: Meta | dict,
|
|
2088
|
+
ensure_unique_coordinate_type: bool = True,
|
|
2089
|
+
**column_meta,
|
|
2090
|
+
) -> DF:
|
|
2091
|
+
"""Creates an instant DataFrame from start/end points of IntervalEvents.
|
|
2092
|
+
|
|
2093
|
+
Args:
|
|
2094
|
+
events: Iterable of IntervalEvent objects.
|
|
2095
|
+
meta: Metadata for the DataFrame.
|
|
2096
|
+
ensure_unique_coordinate_type: If True, assert all points share a coordinate type.
|
|
2097
|
+
**column_meta: Additional metadata for columns.
|
|
2098
|
+
|
|
2099
|
+
Returns:
|
|
2100
|
+
A DataFrame representing the start and end instants of interval events.
|
|
2101
|
+
"""
|
|
2102
|
+
rows, index = [], []
|
|
2103
|
+
units, number_types = set(), set()
|
|
2104
|
+
for event in events:
|
|
2105
|
+
event_id = ensure_registration(event)
|
|
2106
|
+
rows.extend(
|
|
2107
|
+
[
|
|
2108
|
+
[
|
|
2109
|
+
event_id,
|
|
2110
|
+
event.__class__.__name__,
|
|
2111
|
+
InstantType.start,
|
|
2112
|
+
EventCategory.intv_evt,
|
|
2113
|
+
],
|
|
2114
|
+
[
|
|
2115
|
+
event_id,
|
|
2116
|
+
event.__class__.__name__,
|
|
2117
|
+
InstantType.end,
|
|
2118
|
+
EventCategory.intv_evt,
|
|
2119
|
+
],
|
|
2120
|
+
]
|
|
2121
|
+
)
|
|
2122
|
+
index.extend(
|
|
2123
|
+
[get_coordinate_value(event.start), get_coordinate_value(event.end)]
|
|
2124
|
+
)
|
|
2125
|
+
try:
|
|
2126
|
+
units.update([event.start.unit, event.end.unit])
|
|
2127
|
+
number_types.update([event.start.number_type, event.end.number_type])
|
|
2128
|
+
except AttributeError:
|
|
2129
|
+
pass # external event type
|
|
2130
|
+
single_unit = len(units) <= 1
|
|
2131
|
+
single_number_type = len(number_types) <= 1
|
|
2132
|
+
if ensure_unique_coordinate_type:
|
|
2133
|
+
assert (
|
|
2134
|
+
single_unit
|
|
2135
|
+
), f"The events' coordinates have {len(units)} different units: {units}"
|
|
2136
|
+
assert single_number_type, (
|
|
2137
|
+
f"The events' coordinates have {len(number_types)} different number types: "
|
|
2138
|
+
f"{number_types}"
|
|
2139
|
+
)
|
|
2140
|
+
if len(units):
|
|
2141
|
+
meta["unit"] = units.pop() if single_unit else list(units)
|
|
2142
|
+
else:
|
|
2143
|
+
meta["unit"] = None
|
|
2144
|
+
if len(number_types):
|
|
2145
|
+
meta["number_type"] = (
|
|
2146
|
+
number_types.pop() if single_number_type else list(number_types)
|
|
2147
|
+
)
|
|
2148
|
+
else:
|
|
2149
|
+
meta["number_type"] = None
|
|
2150
|
+
return make_instants_df(rows, index=index, meta=meta, **column_meta)
|
|
2151
|
+
|
|
2152
|
+
|
|
2153
|
+
def make_inst_df_from_segment(
|
|
2154
|
+
segment: Timeline,
|
|
2155
|
+
meta: Meta | dict,
|
|
2156
|
+
start: Optional[Coordinate | Number] = None,
|
|
2157
|
+
**column_meta,
|
|
2158
|
+
) -> DF:
|
|
2159
|
+
"""Creates a two-row instant DataFrame for a segment's start and end.
|
|
2160
|
+
|
|
2161
|
+
Args:
|
|
2162
|
+
segment: The timeline segment.
|
|
2163
|
+
meta: Metadata for the DataFrame.
|
|
2164
|
+
start: Optional start offset for the segment.
|
|
2165
|
+
**column_meta: Additional metadata for columns.
|
|
2166
|
+
|
|
2167
|
+
Returns:
|
|
2168
|
+
A DataFrame representing the segment's start and end instants.
|
|
2169
|
+
"""
|
|
2170
|
+
if start is None:
|
|
2171
|
+
start = segment.origin
|
|
2172
|
+
start_value = get_coordinate_value(start)
|
|
2173
|
+
seg_length = segment.length.value
|
|
2174
|
+
end_value = start_value + seg_length
|
|
2175
|
+
return make_instants_df(
|
|
2176
|
+
[
|
|
2177
|
+
[segment.id, segment.class_name, InstantType.start, EventCategory.seg],
|
|
2178
|
+
[segment.id, segment.class_name, InstantType.end, EventCategory.seg],
|
|
2179
|
+
],
|
|
2180
|
+
index=(start_value, end_value),
|
|
2181
|
+
meta=meta,
|
|
2182
|
+
**column_meta,
|
|
2183
|
+
)
|
|
2184
|
+
|
|
2185
|
+
|
|
2186
|
+
# endregion instants dataframe helpers
|
|
2187
|
+
|
|
2188
|
+
if __name__ == "__main__":
|
|
2189
|
+
tl = Timeline("s", float, 20)
|
|
2190
|
+
tl.add_segments(Timeline("s", float, 10), 5)
|
|
2191
|
+
print(tl._instants)
|
|
2192
|
+
print(tl._intervals)
|