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.
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)