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/utils.py ADDED
@@ -0,0 +1,963 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import itertools
5
+ import logging
6
+ import os.path as osp
7
+ from collections import defaultdict
8
+ from enum import Enum, StrEnum, auto
9
+ from fractions import Fraction
10
+ from functools import cache
11
+ from numbers import Number
12
+ from pathlib import Path
13
+ from typing import (
14
+ TYPE_CHECKING,
15
+ Any,
16
+ Generic,
17
+ Iterable,
18
+ Iterator,
19
+ Literal,
20
+ Optional,
21
+ Type,
22
+ TypeVar,
23
+ overload,
24
+ )
25
+
26
+ import numpy as np
27
+ import pandas as pd
28
+
29
+ if TYPE_CHECKING:
30
+ from processing.tta.conversions import Coordinate
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # region NamedSources
35
+
36
+ S = TypeVar("S")
37
+
38
+
39
+ class NamedSources(Generic[S]):
40
+ """Helper class that stores a particular type of things in a ._sources dictionary.
41
+ The specialty is that you can optionally name things but still can retrieve each thing
42
+ by its order of addition, whether it was named or not.
43
+ """
44
+
45
+ def __init__(self, **kwargs):
46
+ self._sources: dict[int | str, S] = {}
47
+ super().__init__(**kwargs)
48
+
49
+ def _get_next_consecutive_integer(self) -> int:
50
+ return len(self._sources)
51
+
52
+ def _adapt_source(self, source: Any) -> S:
53
+ return source
54
+
55
+ def _add_source(self, source: S, name: Optional[str] = None) -> int | str:
56
+ """Adds a 2d-array where one axis has shape 2, representing numerators and denominators of
57
+ a sequence of fractions.
58
+ If you assign a name you can retrieve it under that name, otherwise by the source's "name"
59
+ attribute or, if unset or undefined, by the next consecutive integer that is not already
60
+ used as a name.
61
+ """
62
+ source = self._adapt_source(source)
63
+ self.validate_source(source)
64
+ if name is None:
65
+ name = getattr(source, "name", None)
66
+ if name is None:
67
+ name = self._get_next_consecutive_integer()
68
+ if name in self._sources:
69
+ raise ValueError(
70
+ f"The integer {name} that was assigned automatically is already taken. "
71
+ f"This should not have happened."
72
+ )
73
+ else:
74
+ assert isinstance(
75
+ name, str
76
+ ), f"Name is expected to be a string, not a {type(name)!r}"
77
+ if name in self._sources:
78
+ raise ValueError(f"Name {name!r} is already taken.")
79
+ self._sources[name] = source
80
+ return name
81
+
82
+ def get_key(self, key: str | int) -> int | str:
83
+ """Get the key for a given source path or key. If the key is not found, it raises KeyError."""
84
+ if key in self._sources:
85
+ return key
86
+ if isinstance(key, int):
87
+ if key < 0:
88
+ key += len(self._sources)
89
+ if key < 0 or key >= len(self._sources):
90
+ raise IndexError(
91
+ f"Index {key} is out of bounds for sources of length {len(self._sources)}"
92
+ )
93
+ return list(self._sources.keys())[key]
94
+ raise KeyError(f"Source {key!r} not found in sources.")
95
+
96
+ def resolve_keys(
97
+ self, keys: Optional[str | int | Iterable[str | int]] = None
98
+ ) -> tuple[str | int, ...]:
99
+ """Process input arguments."""
100
+ if keys is None:
101
+ return tuple(self._sources.keys())
102
+ keys = [self.get_key(key) for key in make_argument_iterable(keys)]
103
+ assert len(keys) > 0, f"Resolved names have zero-length: {keys!r}"
104
+ return tuple(keys)
105
+
106
+ def get_source(self, key: str | int) -> S:
107
+ """Retrieve one of the previous inputs by its name."""
108
+ if key in self._sources:
109
+ return self._sources[key]
110
+ elif isinstance(key, int):
111
+ if key < 0:
112
+ key += len(self._sources)
113
+ if key < 0 or key >= len(self._sources):
114
+ raise IndexError(
115
+ f"Index {key} is out of bounds for sources of length {len(self._sources)}"
116
+ )
117
+ return list(self._sources.values())[key]
118
+ raise KeyError(
119
+ f"Name {key!r} is not found in the sources. "
120
+ f"Available names: {tuple(self._sources.keys())}"
121
+ )
122
+
123
+ def get_sources(
124
+ self, names: Optional[str | int | Iterable[str | int]] = None
125
+ ) -> tuple[S]:
126
+ if names is None:
127
+ if len(self._sources) == 0:
128
+ raise ValueError(
129
+ "No data has been added to this object. "
130
+ "Use the method ._add_source() first"
131
+ )
132
+ sources = tuple(self._sources.values())
133
+ else:
134
+ names = self.resolve_keys(names)
135
+ sources = tuple(self.get_source(name) for name in names)
136
+ return sources
137
+
138
+ def validate_source(self, source: S):
139
+ return
140
+
141
+ @overload
142
+ def __getitem__(self, names: str | int) -> S: ...
143
+
144
+ @overload
145
+ def __getitem__(self, names: Iterable[str | int]) -> tuple[S]: ...
146
+
147
+ def __getitem__(self, names: str | int | Iterable[str | int]) -> S | tuple[S]:
148
+ if isinstance(names, (str, int)):
149
+ return self.get_source(names)
150
+ return self.get_sources(names)
151
+
152
+ def __iter__(self) -> Iterator[S]:
153
+ yield from self.get_sources()
154
+
155
+
156
+ class DivMaker(NamedSources[np.ndarray[int]]):
157
+ """This is a convenient object for turning sequences of fractions into commensurate divs.
158
+ It is equivalent to concatenating all sequences, passing them to the function shown below, and splitting them again.
159
+
160
+ def fractions2divs(fracs: Iterable[Fraction]) -> np.ndarray[int]:
161
+ numerators, denominators = np.array([(item.numerator,item.denominator) for item in fracs]).T
162
+ lcm = np.lcm.reduce(denominators) # least common multiple
163
+ return (numerators * lcm / denominators).astype(int)
164
+
165
+ Example:
166
+
167
+ STAR_WARS = np.array([ # durations of the star wars theme
168
+ (1, 12),
169
+ (1, 12),
170
+ (1, 12),
171
+ (1, 2),
172
+ (1, 2),
173
+ (1, 12),
174
+ (1, 12),
175
+ (1, 12),
176
+ (1, 2),
177
+ (1, 4)
178
+ ])
179
+ div_maker = DivMaker(STAR_WARS)
180
+ div_maker[0] # yields [1, 1, 1, 6, 6, 1, 1, 1, 6, 3]
181
+
182
+ POSITIONS = [Fraction(1, 20), Fraction(1, 32)] # fractions that we need our durations to be commensurate with
183
+ div_maker.add_iterable_of_fractions(POSITIONS)
184
+ durations, pos = div_maker # object is iterable (iterates through sequences added without names)
185
+ list(durations) # yields [40, 40, 40, 240, 240, 40, 40, 40, 240, 120]
186
+
187
+ OTHER_VALUES = (Fraction(i, 7) for i in range(7))
188
+ div_maker.add_iterable_or_array(OTHER_VALUES, "other") # add the other values with a name
189
+ div_maker[(1, "other", 0)] # when retrieving we can mix assigned names and indices of nameless sequences
190
+ # OUTPUT:
191
+ # (array([168, 105]),
192
+ # array([ 0, 480, 960, 1440, 1920, 2400, 2880]),
193
+ # array([ 280, 280, 280, 1680, 1680, 280, 280, 280, 1680, 840]))
194
+
195
+ div_maker.lcm # yields 3360, the common denominator for all values (least common multiple)
196
+ """
197
+
198
+ def __init__(
199
+ self,
200
+ *iterable_or_array: Iterable[Fraction] | np.ndarray[int],
201
+ **named_iterables_or_arrays: Iterable[Fraction] | np.ndarray[int],
202
+ ):
203
+ """Pass one or several 2d-arrays (where one axis has shape 2) or one or several iterables of fractions.
204
+ By passing keyword arguments you can assign names which you can use to retrieve the respective div sequences.
205
+ """
206
+ self._sources: dict[int | str, np.ndarray[int]] = {}
207
+ for ioa in iterable_or_array:
208
+ _ = self.add_iterable_or_array(ioa)
209
+ for name, ioa in named_iterables_or_arrays.items():
210
+ _ = self.add_iterable_or_array(ioa, name)
211
+
212
+ @staticmethod
213
+ def iterable_of_fractions_to_array(
214
+ iterable_of_fractions: Iterable[Fraction],
215
+ ) -> np.ndarray[int]:
216
+ """Returns a numpy array of shape (2,n) for a given iterable of n :obj:`Fraction` objects."""
217
+ return np.array(
218
+ [(frac.numerator, frac.denominator) for frac in iterable_of_fractions]
219
+ ).T
220
+
221
+ def add_iterable_of_fractions(
222
+ self,
223
+ iterable_of_fractions: Iterable[Fraction],
224
+ name: Optional[str | int] = None,
225
+ ) -> int | str:
226
+ """Adds some iterable of :obj:`Fraction` objects that can then be retrieved as divs.
227
+ If you assign a name you can retrieve it under that name, otherwise by the integer corresponding to the
228
+ order in which it was added. Iteration over the object goes only through nameless objects in their adding
229
+ order, meaning that you can assign an integer name that will not be taken into account when iterating
230
+ through the object.
231
+ """
232
+ arr = self.iterable_of_fractions_to_array(iterable_of_fractions)
233
+ return self.add_iterable_or_array(arr, name=name)
234
+
235
+ def _adapt_source(self, arr: np.ndarray) -> np.ndarray:
236
+ arr = np.asarray(arr)
237
+ assert arr.ndim == 2, f"Expected a 2D numpy array, not {arr.ndim}D"
238
+ assert (
239
+ 2 in arr.shape
240
+ ), f"One of the 2 dimensions needs to have shape 2. Received shape: {arr.shape}"
241
+ if arr.shape[0] == 2:
242
+ return arr
243
+ return arr.T
244
+
245
+ def add_iterable_or_array(
246
+ self,
247
+ iterable_or_array: Iterable[Fraction] | np.ndarray[int],
248
+ name: Optional[str | int] = None,
249
+ ):
250
+ """Convenience function for calling either .add_iterable_of_fractions() or .add_frac_array() based on the
251
+ input.
252
+ """
253
+ if isinstance(iterable_or_array, np.ndarray):
254
+ return self._add_source(iterable_or_array, name)
255
+ return self.add_iterable_of_fractions(iterable_or_array, name)
256
+
257
+ def concatenated_frac_arrays(
258
+ self, names: Optional[str | int | Iterable[str | int]] = None
259
+ ) -> np.ndarray:
260
+ """Concatenate the requested arrays in order to compute their LCM. All arrays have shape (2, n) and so does
261
+ their concatenation ("horizontal stacking").
262
+ """
263
+ arrays = self.get_sources(names)
264
+ if len(arrays) == 1:
265
+ return arrays[0]
266
+ return np.hstack(arrays)
267
+
268
+ def get_divs(self, name: str | int) -> np.ndarray[int]:
269
+ """Retrieve one of the previous inputs as divs, based on the LCM computed for all inputs together.
270
+ Name can be a number for retrieving nameless inputs based on their input order.
271
+ """
272
+ if name not in self._sources:
273
+ raise KeyError(name)
274
+ numerators, denominators = self._sources[name]
275
+ lcm = self.least_common_multiple()
276
+ return (numerators * lcm / denominators).astype(int)
277
+
278
+ @cache
279
+ def _least_common_multiple(self, names: tuple[str | int]) -> int:
280
+ _, denominators = self.concatenated_frac_arrays(names)
281
+ return np.lcm.reduce(denominators)
282
+
283
+ def least_common_multiple(
284
+ self, names: Optional[str | int | Iterable[str | int]] = None
285
+ ) -> int:
286
+ """By default, the LCM is computed based on all sequences of fractions that this object holds.
287
+ When you retrieve divs, they are always commensurate between all sequences."""
288
+ names = self.resolve_keys(names)
289
+ return self._least_common_multiple(names)
290
+
291
+ @property
292
+ def lcm(self):
293
+ """For convenience."""
294
+ return self.least_common_multiple()
295
+
296
+ @overload
297
+ def __getitem__(self, names: str | int) -> np.ndarray: ...
298
+
299
+ @overload
300
+ def __getitem__(self, names: Iterable[str | int]) -> tuple[np.ndarray]: ...
301
+
302
+ def __getitem__(
303
+ self, names: str | int | Iterable[str | int]
304
+ ) -> np.ndarray | tuple[np.ndarray]:
305
+ if isinstance(names, (str, int)):
306
+ return self.get_divs(names)
307
+ names = tuple(names)
308
+ return tuple(self.get_divs(name) for name in names)
309
+
310
+ def __iter__(self):
311
+ existing_consecutive_integers = itertools.takewhile(
312
+ lambda x: x in self._sources, itertools.count()
313
+ )
314
+ for i in existing_consecutive_integers:
315
+ yield self.get_divs(i)
316
+
317
+
318
+ # endregion NamedSources
319
+ # region Enums
320
+
321
+
322
+ class FancyStrEnum(StrEnum):
323
+ """This enum is used to define closed vocabularies (e.g. for function arguments) allowing for abbreviation aliases.
324
+
325
+ Features:
326
+
327
+ * It can be instantiated from either value: FancyStrEnum("abbr") == FancyStrEnum.abbreviation.
328
+ * list(FancyStrEnum) returns only non-aliases
329
+ * FancyStrEnum.get_abbreviations() returns a mapping from names to abbreviations
330
+
331
+ Example:
332
+
333
+ class Vocabulary(FancyStrEnum):
334
+ abbreviation = auto() # assigns the name as value (making it lowercase as per StrEnum's default)
335
+ abbr = abbreviation # alias 1
336
+ abb = abbreviation # alias 2
337
+ """
338
+
339
+ @classmethod
340
+ def _missing_(cls, value):
341
+ """
342
+ Initialization from values, including aliases.
343
+
344
+ Args:
345
+ value: The value or name string to look up.
346
+
347
+ Returns:
348
+ The corresponding TimeUnit enum member.
349
+
350
+ Raises:
351
+ ValueError: If the value or name does not match any member or alias.
352
+ """
353
+ if isinstance(value, str):
354
+ lower_value = value.lower()
355
+ if lower_value in cls.__members__:
356
+ name = cls.__members__[lower_value]
357
+ return cls(name)
358
+ abbrv = cls.get_abbreviations(string=True)
359
+ raise ValueError(
360
+ f"'{value}' is not a valid {cls.__name__}. Available units are: {abbrv}"
361
+ )
362
+
363
+ @classmethod
364
+ def get_abbreviations(cls, string=False) -> dict[str, str | list[str]]:
365
+ """Returns a mapping from enum names/values to abbreviated alias values."""
366
+ name2values = defaultdict(list)
367
+ for value, name in cls.__members__.items():
368
+ name2values[name].append(value)
369
+ abbreviations = {}
370
+ for name, values in name2values.items():
371
+ abbreviations[name] = sorted(values, key=lambda x: len(x), reverse=True)[1:]
372
+ if not string:
373
+ return abbreviations
374
+ str_components = []
375
+ for name, values in abbreviations.items():
376
+ if not values:
377
+ str_components.append(name)
378
+ continue
379
+ abbrev_str = ", ".join(values)
380
+ str_components.append(f"{name} ({abbrev_str})")
381
+ return ", ".join(str_components)
382
+
383
+ def __repr__(self):
384
+ return f'"{self.name}"'
385
+
386
+ def __str__(self):
387
+ return self.name
388
+
389
+
390
+ class Domain(FancyStrEnum):
391
+ musical = auto()
392
+ """Logical time domain, also called logical time."""
393
+ mu = musical
394
+
395
+ physical = auto()
396
+ """Physical time domain, also called real time."""
397
+ ph = physical
398
+
399
+ graphical = auto()
400
+ """Graphical time domain, also called space or visual time."""
401
+ gr = graphical
402
+
403
+
404
+ class EventCategory(FancyStrEnum):
405
+ """Enumeration for event categories."""
406
+
407
+ segments = auto()
408
+ segment = segments
409
+ seg = segments
410
+ events = auto()
411
+ event = events
412
+ evt = events
413
+ instant_events = auto()
414
+ instant_event = instant_events
415
+ inst_evt = instant_events
416
+ inst = instant_events
417
+ interval_events = auto()
418
+ interval_event = interval_events
419
+ intv_evt = interval_events
420
+ intv = interval_events
421
+
422
+
423
+ class InstantType(FancyStrEnum):
424
+ """Enumeration for types of instants."""
425
+
426
+ instants = auto()
427
+ instant = instants
428
+ inst = instant
429
+ starts = auto()
430
+ start = starts
431
+ ends = auto()
432
+ end = ends
433
+
434
+
435
+ class IndexType(FancyStrEnum):
436
+ """Enumeration for types of indices."""
437
+
438
+ intervals = auto()
439
+ interval = intervals
440
+ intv = intervals
441
+ instants = auto()
442
+ instant = instants
443
+ inst = instants
444
+ starts = auto()
445
+ start = starts
446
+ ends = auto()
447
+ end = ends
448
+
449
+
450
+ class InterpolationType(FancyStrEnum):
451
+ linear = auto()
452
+ nearest = auto()
453
+ nearest_up = "nearest-up"
454
+ zero = auto()
455
+ slinear = auto()
456
+ quadratic = auto()
457
+ cubic = auto()
458
+ previous = auto()
459
+ next = auto()
460
+
461
+
462
+ class Missing(FancyStrEnum):
463
+ DATA_UNDEFINED = auto()
464
+ FIELD_MISSING_FROM_EVENT_DATA = auto()
465
+ FIELD_NAME_UNDEFINED = auto()
466
+
467
+
468
+ class NumberType(Enum):
469
+ """Members can be instantiated both via NumberType("name") and NumberType(value).
470
+
471
+ Example:
472
+ NumberType(int) is NumberType("int")
473
+ # True
474
+ NumberType(int).value(1.4)
475
+ # 1
476
+
477
+ """
478
+
479
+ int = int
480
+ float = float
481
+ fraction = Fraction
482
+
483
+ @classmethod
484
+ def _missing_(cls, value):
485
+ if isinstance(value, str):
486
+ for member in cls:
487
+ if member.name == value:
488
+ return member
489
+ try:
490
+ converted_numpy = convert_numpy_type_to_python_type(value)
491
+ return cls(converted_numpy)
492
+ except Exception:
493
+ pass
494
+ return None
495
+
496
+ @classmethod
497
+ def from_number(cls, number: Number):
498
+ return cls(type(number))
499
+
500
+
501
+ class PartMap(FancyStrEnum):
502
+ time_signature_map = auto()
503
+ ts_map = time_signature_map
504
+ tsm = time_signature_map
505
+
506
+ key_signature_map = auto()
507
+ ks_map = key_signature_map
508
+ ksm = key_signature_map
509
+
510
+ clef_map = auto()
511
+ cm = clef_map
512
+
513
+ measure_map = auto()
514
+ mc = measure_map
515
+
516
+ measure_number_map = auto()
517
+ mn = measure_number_map
518
+
519
+ metrical_position_map = auto()
520
+ mpm = metrical_position_map
521
+
522
+ beat_map = auto()
523
+ bm = beat_map
524
+
525
+ inv_beat_map = auto()
526
+ ibm = inv_beat_map
527
+
528
+ quarter_map = auto()
529
+ qm = quarter_map
530
+
531
+ inv_quarter_map = auto()
532
+ iqm = inv_quarter_map
533
+
534
+ quarter_duration_map = auto()
535
+ qdm = quarter_duration_map
536
+
537
+
538
+ class Quantization(FancyStrEnum):
539
+ continuous = auto()
540
+ """Accomodating arbitrarily precise coordinates."""
541
+
542
+ discrete = auto()
543
+ """Accomodating only integer coordinates."""
544
+
545
+
546
+ class TimeUnit(FancyStrEnum):
547
+ """"""
548
+
549
+ # generic
550
+ number = auto()
551
+
552
+ # musical
553
+ beats = auto()
554
+ """beats"""
555
+ b = beats # b is an alias for beats
556
+ """beats"""
557
+
558
+ measures = auto()
559
+ """measures"""
560
+ m = measures # m is an alias for measures
561
+ """measures"""
562
+
563
+ quarters = auto()
564
+ """quarter notes"""
565
+ q = quarters # q is an alias for quarters
566
+ """quarter notes"""
567
+
568
+ ticks = auto()
569
+ """ticks (MIDI's time unit)"""
570
+ pulses = ticks
571
+ """ticks (MIDI's time unit)"""
572
+
573
+ # physical
574
+ milliseconds = auto()
575
+ """milliseconds"""
576
+ ms = milliseconds # ms is an alias for milliseconds
577
+ """milliseconds"""
578
+
579
+ seconds = auto()
580
+ """seconds"""
581
+ s = seconds # s is an alias for seconds
582
+ """seconds"""
583
+
584
+ minutes = auto()
585
+ """minutes"""
586
+
587
+ samples = auto()
588
+ """samples"""
589
+
590
+ # graphical
591
+ pixels = auto()
592
+ """pixels"""
593
+ px = pixels # px is an alias for pixels
594
+ """pixels"""
595
+
596
+ meters = auto()
597
+ """meters"""
598
+ centimeters = auto()
599
+ """centimeters"""
600
+ cm = centimeters # cm is an alias for centimeters
601
+ """centimeters"""
602
+ millimeters = auto()
603
+ """millimeters"""
604
+ mm = millimeters # mm is an alias for millimeters
605
+ """millimeters"""
606
+
607
+ inches = auto()
608
+ """inches"""
609
+
610
+ points = auto()
611
+ """points"""
612
+ pt = points # pt is an alias for points
613
+ """points"""
614
+
615
+
616
+ class TraversalOrder(FancyStrEnum):
617
+ """Enumeration for traversal orders."""
618
+
619
+ breadth_first = auto()
620
+ depth_first = auto()
621
+ sorted = auto()
622
+
623
+
624
+ # endregion Enums
625
+ # region helper functions
626
+
627
+
628
+ def convert_numpy_type_to_python_type(np_type) -> Type:
629
+ """Turns a numpy dtype into a native Python type"""
630
+ return type(np.zeros(1, np_type).tolist()[0])
631
+
632
+
633
+ def get_time_units(
634
+ domain=Domain | Iterable[Domain], quantization=Quantization | Iterable[Quantization]
635
+ ) -> tuple[TimeUnit]:
636
+ result = []
637
+ if isinstance(domain, str):
638
+ domain = Domain(domain)
639
+ domain_dicts = [LINEAR_TIME_UNITS[domain]]
640
+ else:
641
+ domains = [Domain(d) for d in domain]
642
+ domain_dicts = [LINEAR_TIME_UNITS[d] for d in domains]
643
+ if isinstance(quantization, str):
644
+ quantization = Quantization(quantization)
645
+ for dd in domain_dicts:
646
+ result.extend(dd[quantization])
647
+ else:
648
+ quantizations = [Quantization(q) for q in quantization]
649
+ for dd in domain_dicts:
650
+ for q in quantizations:
651
+ result.extend(dd[q])
652
+ return tuple(result)
653
+
654
+
655
+ def calculate_file_checksum(
656
+ filepath: Path | str,
657
+ hash_algorithm: Literal["sha256", "md5", "sha1", "sha512"] = "sha256",
658
+ chunk_size=8192,
659
+ ):
660
+ """
661
+ Calculates a system-agnostic checksum of a file.
662
+
663
+ Args:
664
+ filepath: The path to the file.
665
+ hash_algorithm: The hashing algorithm to use (e.g., 'md5', 'sha1', 'sha256', 'sha512').
666
+ Defaults to 'sha256'.
667
+ chunk_size: The size of chunks (in bytes) to read the file.
668
+ Larger chunks can be faster but use more memory.
669
+
670
+ Returns:
671
+ str: The hexadecimal digest of the file's checksum, or None if the file is not found.
672
+ """
673
+ if not osp.isfile(filepath):
674
+ logger.error(f"File not found at {filepath}")
675
+ return None
676
+
677
+ try:
678
+ # Get the hash constructor from hashlib
679
+ hasher = hashlib.new(hash_algorithm)
680
+ except ValueError:
681
+ logger.error(
682
+ f"Error: Unsupported hash algorithm '{hash_algorithm}'. "
683
+ f"Available algorithms: {hashlib.algorithms_available}"
684
+ )
685
+ return None
686
+
687
+ try:
688
+ with open(filepath, "rb") as f:
689
+ while True:
690
+ chunk = f.read(chunk_size)
691
+ if not chunk:
692
+ break # End of file
693
+ hasher.update(chunk)
694
+ return hasher.hexdigest()
695
+ except IOError as e:
696
+ logger.error(f"Error reading file {filepath}: {e}")
697
+ return None
698
+
699
+
700
+ @overload
701
+ def make_argument_iterable(arg: Iterable) -> Iterable: ...
702
+
703
+
704
+ @overload
705
+ def make_argument_iterable(arg: Any) -> tuple: ...
706
+
707
+
708
+ def make_argument_iterable(
709
+ arg, make_iterable_singular: bool = False
710
+ ) -> tuple | Iterable:
711
+ """make_iterable_singular can be set to True for cases where the result is expected to be an
712
+ iterable of length one, e.g. "one tuple", in which case any iterable of length > 1 will be wrapped.
713
+ """
714
+ if arg is None:
715
+ return tuple()
716
+ if isinstance(arg, str):
717
+ return (arg,)
718
+ if not isinstance(arg, Iterable):
719
+ return (arg,)
720
+ try:
721
+ if make_iterable_singular and len(arg) > 1:
722
+ return (arg,)
723
+ except TypeError:
724
+ # arg probably a generator
725
+ pass
726
+ return arg
727
+
728
+
729
+ def new_interval_index(
730
+ iix: pd.IntervalIndex,
731
+ mask: pd.Series,
732
+ new_left: Optional[int | float] = None,
733
+ new_right: Optional[int | float] = None,
734
+ new_closed=None,
735
+ ) -> pd.IntervalIndex:
736
+ """New values that are of type Fraction will be converted to float."""
737
+ closed = new_closed if new_closed else iix.closed
738
+ if new_left is None and new_right is None:
739
+ return pd.IntervalIndex(iix[mask], closed=closed)
740
+ iv_list = iix.to_numpy().tolist()
741
+ (update_positions,) = np.where(mask)
742
+ for i in update_positions:
743
+ interval = iv_list[i]
744
+ if new_left is None:
745
+ left = interval.left
746
+ else:
747
+ left = to_float_if_fraction(new_left)
748
+ if new_right is None:
749
+ right = interval.right
750
+ else:
751
+ right = to_float_if_fraction(new_right)
752
+ iv_list[i] = pd.Interval(left, right, closed)
753
+ return pd.IntervalIndex(iv_list, name=iix.name, closed=closed)
754
+
755
+
756
+ @overload
757
+ def to_float_if_fraction(shift_by: Number) -> Number: ...
758
+
759
+
760
+ @overload
761
+ def to_float_if_fraction(shift_by: Fraction) -> float: ...
762
+
763
+
764
+ def to_float_if_fraction(shift_by: Number) -> Number:
765
+ if isinstance(shift_by, Fraction):
766
+ shift_by = float(shift_by)
767
+ return shift_by
768
+
769
+
770
+ def shift_interval_index(
771
+ iix: pd.IntervalIndex, shift_by=int | float
772
+ ) -> pd.IntervalIndex:
773
+ shift_by = to_float_if_fraction(shift_by)
774
+ new_left = iix.left + shift_by
775
+ new_right = iix.right + shift_by
776
+ return pd.IntervalIndex.from_arrays(
777
+ new_left, new_right, name=iix.name, closed=iix.closed
778
+ )
779
+
780
+
781
+ def longer_intervals_first(
782
+ iix: pd.IntervalIndex,
783
+ ) -> pd.Series:
784
+ """Helper to be used in .sort_index(key=longer_intervals_first)."""
785
+ return pd.Series(list(zip(iix.left.values, -iix.length.values)))
786
+
787
+
788
+ def sort_interval_index_longer_first(
789
+ iix: pd.IntervalIndex,
790
+ ) -> pd.IntervalIndex:
791
+ return iix[longer_intervals_first(iix).argsort()]
792
+
793
+
794
+ def sort_instants(df):
795
+ """
796
+ Sorts instants according to index and 'instant_type' column such that end instants
797
+ precede coinciding start instants.
798
+ """
799
+ sort_order = pd.Index(
800
+ df.instant_type.items()
801
+ ).argsort() # sorting by (index, instant_type) tuples
802
+ return df.iloc[sort_order]
803
+
804
+
805
+ def make_unbounded_interval_index(
806
+ iix: pd.IntervalIndex,
807
+ left_unbounded: bool = False,
808
+ right_unbounded: bool = True,
809
+ ):
810
+ """Takes a monotonically increasing IntervalIndex and extends the first and the last interval
811
+ to -inf and inf so that it returns the first or last value for out-of-bounds queries. By default,
812
+ only the right interval is replaced with one right-bounded by inf. Set left_unbounded=True to also
813
+ replace the first interval with one left-bounded by -inf.
814
+ """
815
+ assert iix.is_non_overlapping_monotonic
816
+ intervals = iix.to_list()
817
+ first, last = intervals[0], intervals[-1]
818
+ assert first.left < last.right
819
+ infinite = float("inf")
820
+ if left_unbounded:
821
+ intervals[0] = pd.Interval(-infinite, first.right, closed=first.closed)
822
+ if right_unbounded:
823
+ intervals[-1] = pd.Interval(last.left, infinite, closed=last.closed)
824
+ return pd.IntervalIndex(intervals, name=iix.name, closed=iix.closed)
825
+
826
+
827
+ def replace_interval_index_with_unbounded_one(
828
+ interval_map: pd.DataFrame | pd.Series,
829
+ left_unbounded: bool = False,
830
+ right_unbounded: bool = True,
831
+ ):
832
+ if not (left_unbounded or right_unbounded):
833
+ return interval_map
834
+ unbounded_index = make_unbounded_interval_index(
835
+ interval_map.index,
836
+ left_unbounded=left_unbounded,
837
+ right_unbounded=right_unbounded,
838
+ )
839
+ return interval_map.set_axis(unbounded_index)
840
+
841
+
842
+ def validate_interval_args(
843
+ start: Coordinate,
844
+ end: Optional[Coordinate | Number] = None,
845
+ length: Optional[Coordinate | Number] = None,
846
+ ):
847
+ assert not (
848
+ end is None and length is None
849
+ ), "At least one of 'end' or 'length' must be defined."
850
+ if end is not None and length is not None:
851
+ assert (
852
+ end == start + length
853
+ ), f"The given {end=} does not correspond to {start+length=}"
854
+
855
+
856
+ def python_type_to_numpy_dtype(py_type):
857
+ if isinstance(py_type, np.dtype):
858
+ return py_type
859
+ if py_type is int:
860
+ return np.dtype(int)
861
+ elif py_type is float:
862
+ return np.dtype(float)
863
+ elif py_type is bool:
864
+ return np.dtype(bool)
865
+ elif py_type is str:
866
+ return np.dtype(object)
867
+ elif py_type is list:
868
+ return np.dtype(object)
869
+ elif py_type is tuple:
870
+ return np.dtype(object)
871
+ elif py_type is dict:
872
+ return np.dtype(object)
873
+ elif py_type is complex:
874
+ return np.dtype(complex)
875
+ elif py_type is bytes:
876
+ return np.dtype(bytes)
877
+ elif py_type is type(None):
878
+ return np.dtype(object)
879
+ else:
880
+ return np.dtype(object)
881
+
882
+
883
+ def compute_active_intervals(df_or_s: pd.DataFrame | pd.Series) -> pd.Series:
884
+ """Based on the column 'instant_type' consisting of the values 'starts', 'ends', and 'instants',
885
+ return a column of integers that represents the number of active intervals at any given point.
886
+ The function assumes that the events are chronologically ordered such that 'ends' precede any
887
+ co-occurring 'starts'. This is what guarantees that 0 values mark those moments where no
888
+ intervals are active. If no 0-values are returned, chances are that there are intervals covering
889
+ the entire time range, such as event-category 'segments'.
890
+ """
891
+ if isinstance(df_or_s, pd.Series):
892
+ ser = df_or_s
893
+ elif "instant_type" in df_or_s.columns:
894
+ ser = df_or_s.instant_type
895
+ else:
896
+ assert isinstance(
897
+ df_or_s.index, pd.IntervalIndex
898
+ ), "DataFrame needs to have an 'instant_type' column or a pd.IntervalIndex"
899
+ raise NotImplementedError("pd.IntervalIndex needs a different function.")
900
+ inst_types = set(INSTANT_TYPE_ACTIVITY_CHANGE.keys())
901
+ assert all(
902
+ val in inst_types for val in ser.unique()
903
+ ), f"Series has values that are not instant types: {ser.unique()}"
904
+ return ser.map(INSTANT_TYPE_ACTIVITY_CHANGE).cumsum()
905
+
906
+
907
+ def treat_variadic_argument(*args) -> list:
908
+ """Catch cases where a single iterable was given accidentally without unpacking."""
909
+ if len(args) == 1:
910
+ elem = args[0]
911
+ if isinstance(elem, Iterable) and not isinstance(elem, (str, bytes)):
912
+ logger.debug(
913
+ f"Variadic argument with a single {type(elem).__name__} has been unpacked."
914
+ )
915
+ return elem
916
+ return args
917
+
918
+
919
+ def get_boolean_mask_for_intervals(
920
+ values: np.ndarray, iix: pd.IntervalIndex
921
+ ) -> np.ndarray:
922
+ """
923
+ Returns a boolean mask for a NumPy array where True indicates that the value of the
924
+ array at that position falls within any interval in IntervalIndex J.
925
+ """
926
+ mask = np.full(values.shape, False, dtype=bool)
927
+ for interval in iix:
928
+ mask = mask | ((values >= interval.left) & (values <= interval.right))
929
+ return mask
930
+
931
+
932
+ # endregion helper functions
933
+ # region globals
934
+
935
+ # this controls the outputs of get_time_units() which, in return, controls the
936
+ # _allowed_units class attributes of the various Timeline classes.
937
+ LINEAR_TIME_UNITS = dict(
938
+ musical=dict(
939
+ continuous=[TimeUnit.quarters],
940
+ discrete=[TimeUnit.ticks],
941
+ ),
942
+ physical=dict(
943
+ continuous=[TimeUnit.milliseconds, TimeUnit.seconds],
944
+ discrete=[TimeUnit.samples],
945
+ ),
946
+ graphical=dict(
947
+ continuous=[
948
+ TimeUnit.meters,
949
+ TimeUnit.centimeters,
950
+ TimeUnit.millimeters,
951
+ TimeUnit.inches,
952
+ TimeUnit.points,
953
+ ],
954
+ discrete=[TimeUnit.pixels],
955
+ ),
956
+ )
957
+
958
+ INSTANT_TYPE_ACTIVITY_CHANGE = dict(
959
+ instants=0,
960
+ starts=1,
961
+ ends=-1,
962
+ )
963
+ # endregion globals