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/conversions.py ADDED
@@ -0,0 +1,3777 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import sys
5
+ import warnings
6
+ from abc import ABC, abstractmethod
7
+ from copy import deepcopy
8
+ from dataclasses import dataclass
9
+ from fractions import Fraction
10
+ from functools import cache
11
+ from itertools import product
12
+ from numbers import Number
13
+ from typing import (
14
+ Any,
15
+ Callable,
16
+ Dict,
17
+ Generic,
18
+ Iterable,
19
+ Literal,
20
+ Optional,
21
+ Self,
22
+ Sequence,
23
+ Type,
24
+ TypeAlias,
25
+ Union,
26
+ )
27
+
28
+ import numpy as np
29
+ import pandas as pd
30
+ from pandas.core.dtypes.inference import is_scalar
31
+ from partitura.utils.generic import interp1d as pt_interp1d
32
+ from typing_extensions import TypeVar
33
+
34
+ from processing.metaframes import DF, DS, Meta, pd_concat
35
+ from processing.tta import utils
36
+ from processing.tta.common import D, RegisteredObject
37
+ from processing.tta.registry import ID_str, get_cmap, get_object_by_id, register_cmap
38
+ from processing.tta.utils import NumberType, TimeUnit, make_argument_iterable
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ # region Coordinates
44
+ @dataclass(frozen=True)
45
+ class CoordinateType:
46
+ """Represents a specific type of coordinate, defined by a unit and a number type.
47
+
48
+ Attributes:
49
+ unit: The time unit of the coordinate.
50
+ number_type: The numerical type of the coordinate's value.
51
+ """
52
+
53
+ unit: TimeUnit | str
54
+ number_type: NumberType
55
+
56
+ def __post_init__(self):
57
+ object.__setattr__(self, "unit", TimeUnit(self.unit))
58
+ object.__setattr__(self, "number_type", NumberType(self.number_type))
59
+
60
+ @property
61
+ def key(self):
62
+ """A unique string key for this coordinate type."""
63
+ return get_key(self.unit, self.number_type)
64
+
65
+
66
+ class CoordinateTypeFactory:
67
+ """
68
+ This Flyweight Factory creates and manages the CoordinateType objects. It ensures
69
+ that flyweights are shared correctly. When the client requests a CoordinateType,
70
+ the factory either returns an existing instance or creates a new one, if it
71
+ doesn't exist yet.
72
+ """
73
+
74
+ _coordinate_types: Dict[str, CoordinateType] = {}
75
+
76
+ def __init__(self, initial_types: Iterable[tuple[TimeUnit, NumberType]]) -> None:
77
+ """Initializes the factory with a set of predefined coordinate types.
78
+
79
+ Args:
80
+ initial_types: An iterable of (TimeUnit, NumberType) tuples.
81
+ """
82
+ for dtype in initial_types:
83
+ self._coordinate_types[self.get_key(*dtype)] = CoordinateType(*dtype)
84
+
85
+ def get_key(self, unit: TimeUnit | str, number_type: NumberType) -> str:
86
+ """Generates a unique string key for a given unit and number type.
87
+
88
+ Args:
89
+ unit: The TimeUnit or its string representation.
90
+ number_type: The NumberType.
91
+
92
+ Returns:
93
+ A string key.
94
+ """
95
+ number_type = NumberType(number_type)
96
+ return f"{unit}_{number_type.name}"
97
+
98
+ def get_coordinate_type(
99
+ self, dtype: tuple[TimeUnit, NumberType] | str
100
+ ) -> CoordinateType:
101
+ """Retrieves or creates a CoordinateType instance.
102
+
103
+ Args:
104
+ dtype: A (TimeUnit, NumberType) tuple, a string key, or a CoordinateType instance.
105
+
106
+ Returns:
107
+ The corresponding CoordinateType instance.
108
+
109
+ Raises:
110
+ ValueError: If dtype is None.
111
+ """
112
+ if dtype is None:
113
+ raise ValueError("dtype cannot be None")
114
+ if isinstance(dtype, CoordinateType):
115
+ return dtype
116
+ if isinstance(dtype, str):
117
+ if coord_type := self._coordinate_types.get(dtype):
118
+ return coord_type
119
+ unit, number_type = dtype.split("_")
120
+ return CoordinateType(unit, number_type)
121
+
122
+ key = self.get_key(*dtype)
123
+ if coord_type := self._coordinate_types.get(key):
124
+ return coord_type
125
+ new_coord_type = CoordinateType(*dtype)
126
+ self._coordinate_types[key] = new_coord_type
127
+ return new_coord_type
128
+
129
+ def list_dtypes(self, as_string=False) -> list[str] | str:
130
+ """Lists all managed coordinate type keys.
131
+
132
+ Args:
133
+ as_string: If True, returns a comma-separated string; otherwise, a list.
134
+
135
+ Returns:
136
+ A list of coordinate type keys or a single string.
137
+ """
138
+ dtypes = list(self._coordinate_types.keys())
139
+ if as_string:
140
+ return ", ".join(dtypes)
141
+ return dtypes
142
+
143
+
144
+ unit_dtype_pairs = product(TimeUnit, NumberType)
145
+ COORDINATE_TYPES = CoordinateTypeFactory(unit_dtype_pairs)
146
+
147
+
148
+ def get_coordinate_type(dtype):
149
+ """Retrieves a CoordinateType instance from the global factory.
150
+
151
+ Args:
152
+ dtype: The coordinate type identifier.
153
+
154
+ Returns:
155
+ A CoordinateType instance.
156
+ """
157
+ global COORDINATE_TYPES
158
+ return COORDINATE_TYPES.get_coordinate_type(dtype)
159
+
160
+
161
+ def get_coordinate_value(coordinate: Coordinate | Number) -> Number:
162
+ """Extracts the numerical value from a Coordinate or returns the number itself.
163
+
164
+ Args:
165
+ coordinate: The coordinate or number.
166
+
167
+ Returns:
168
+ The numerical value.
169
+ """
170
+ if isinstance(coordinate, Coordinate):
171
+ return coordinate.value
172
+ return coordinate
173
+
174
+
175
+ def treat_instants_argument(
176
+ instants: Iterable[Coord] | Coord, ensure_unit: Optional[TimeUnit | str] = None
177
+ ):
178
+ """This is a combination of make_argument_iterable with get_coordinate_value.
179
+ If `ensure_unit` is specified and any of the input values are Coordinates, the function raises
180
+ a ValueError if they have different units or if their unit differs from the given ensure_unit.
181
+ """
182
+ if ensure_unit is None:
183
+ return [get_coordinate_value(n) for n in utils.make_argument_iterable(instants)]
184
+ result, units = [], set()
185
+ for inst in utils.make_argument_iterable(instants):
186
+ if isinstance(inst, Coordinate):
187
+ result.append(inst.value)
188
+ units.add(inst.unit)
189
+ else:
190
+ result.append(inst)
191
+ if len(units) > 1:
192
+ raise ValueError(f"Coordinates need to have unit {ensure_unit}; got: {units}")
193
+ if len(units) == 1:
194
+ coordinate_unit = units.pop()
195
+ if ensure_unit != coordinate_unit:
196
+ raise ValueError(
197
+ f"Coordinates need to have unit {ensure_unit}; got: {coordinate_unit}"
198
+ )
199
+ return result
200
+
201
+
202
+ def treat_intervals_argument(
203
+ intervals: (
204
+ pd.IntervalIndex
205
+ | Iterable[tuple[Coord, Coord]]
206
+ | tuple[Coord, Coord]
207
+ | pd.Interval
208
+ ),
209
+ ensure_unit: Optional[TimeUnit | str] = None,
210
+ make_iterable_singular: bool = False,
211
+ ) -> pd.IntervalIndex:
212
+ if isinstance(intervals, pd.IntervalIndex):
213
+ if intervals.closed != "left":
214
+ warnings.warn(
215
+ f"Received a pd.IntervalIndex whose intervals are not 'left'- but "
216
+ f"{intervals.closed!r}-closed. Assuming this is on purpose."
217
+ )
218
+ return intervals
219
+ intervals = utils.make_argument_iterable(
220
+ intervals, make_iterable_singular=make_iterable_singular
221
+ )
222
+ intervals_are_pd = [isinstance(iv, pd.Interval) for iv in intervals]
223
+ if all(intervals_are_pd):
224
+ iix = pd.IntervalIndex(intervals, closed="left")
225
+ else:
226
+ if any(intervals_are_pd):
227
+ intervals = [
228
+ (iv.left, iv.right) if isinstance(iv, pd.Interval) else iv
229
+ for iv in intervals
230
+ ]
231
+ lefts, rights = zip(*intervals)
232
+ lefts = treat_instants_argument(lefts, ensure_unit=ensure_unit)
233
+ rights = treat_instants_argument(rights, ensure_unit=ensure_unit)
234
+ iix = pd.IntervalIndex.from_arrays(lefts, rights, closed="left")
235
+ return iix
236
+
237
+
238
+ def get_key(unit: TimeUnit | str, number_type: NumberType) -> str:
239
+ """Generates a unique string key for a unit and number type using the global factory.
240
+
241
+ Args:
242
+ unit: The TimeUnit or its string representation.
243
+ number_type: The NumberType.
244
+
245
+ Returns:
246
+ A string key.
247
+ """
248
+ global COORDINATE_TYPES
249
+ return COORDINATE_TYPES.get_key(unit, number_type)
250
+
251
+
252
+ def _safely_get_dtype_from_number_type(number_type: NumberType | Type) -> Type:
253
+ dtype = number_type
254
+ try:
255
+ return dtype.value
256
+ except AttributeError:
257
+ return dtype
258
+
259
+
260
+ @dataclass(frozen=True)
261
+ class Coordinate:
262
+ """Represents a numerical value associated with a specific CoordinateType.
263
+
264
+ The numerical value is automatically converted to the specified number type upon instantiation.
265
+
266
+ Attributes:
267
+ value: The numerical value of the coordinate.
268
+ dtype: The CoordinateType, (TimeUnit, NumberType) tuple, or string key defining the coordinate's type.
269
+ """
270
+
271
+ value: Number
272
+ dtype: CoordinateType | tuple[TimeUnit, NumberType] | str
273
+
274
+ def __post_init__(self):
275
+ object.__setattr__(self, "dtype", get_coordinate_type(self.dtype))
276
+ object.__setattr__(self, "value", self.number_type.value(self.value))
277
+
278
+ @property
279
+ def unit(self):
280
+ """The TimeUnit of the coordinate."""
281
+ return self.dtype.unit
282
+
283
+ @property
284
+ def number_type(self) -> NumberType:
285
+ """The NumberType of the coordinate's value."""
286
+ return self.dtype.number_type
287
+
288
+ @property
289
+ def coordinate_type(self) -> str:
290
+ """The string key representing the coordinate's type."""
291
+ return get_key(self.unit, self.number_type)
292
+
293
+ def _check_unit(self, other: Any, operation_name: str):
294
+ """Validates unit compatibility for an operation.
295
+
296
+ Args:
297
+ other: The other operand.
298
+ operation_name: Name of the operation for error messages.
299
+
300
+ Returns:
301
+ The numerical value of the other operand.
302
+
303
+ Raises:
304
+ TypeError: If units are incompatible.
305
+ NotImplementedError: If the other operand type is unsupported.
306
+ """
307
+ if isinstance(other, Coordinate):
308
+ if self.unit != other.unit:
309
+ raise TypeError(
310
+ f"Cannot perform {operation_name} on Coordinates with different units: "
311
+ f"{self.unit} and {other.unit}"
312
+ )
313
+ value = other.value
314
+ elif isinstance(other, Number):
315
+ value = other
316
+ else:
317
+ raise NotImplementedError(
318
+ f"Cannot perform {operation_name} between Coordinate and {other!r} ("
319
+ f"{type(other)})"
320
+ )
321
+
322
+ return value
323
+
324
+ def __repr__(self):
325
+ return f"C({self.value} {self.unit})"
326
+
327
+ def __str__(self):
328
+ return f"{self.value} {self.unit}"
329
+
330
+ def __add__(self, other: Any) -> Self:
331
+ other_value = self._check_unit(other, "addition")
332
+ return Coordinate(self.value + other_value, self.coordinate_type)
333
+
334
+ def __radd__(self, other: Any) -> Self:
335
+ return self.__add__(other)
336
+
337
+ def __sub__(self, other: Any) -> Self:
338
+ other_value = self._check_unit(other, "subtraction")
339
+ return Coordinate(self.value - other_value, self.coordinate_type)
340
+
341
+ def __rsub__(self, other: Any) -> Any:
342
+ other_value = self._check_unit(other, "subtraction")
343
+ return Coordinate(other_value - self.value, self.coordinate_type)
344
+
345
+ def __mul__(self, other: Any) -> Any:
346
+ if isinstance(other, Number):
347
+ return Coordinate(self.value * other, self.coordinate_type)
348
+ raise NotImplementedError(f"Cannot multiply Coordinate with type {type(other)}")
349
+
350
+ def __rmul__(self, other: Any) -> Any:
351
+ return self.__mul__(other)
352
+
353
+ def __truediv__(self, other: Any) -> Any:
354
+ if isinstance(other, Number):
355
+ if other == 0:
356
+ raise ZeroDivisionError("division by zero")
357
+ return Coordinate(self.value / other, self.coordinate_type)
358
+ raise NotImplementedError(f"Cannot divide Coordinate by type {type(other)}")
359
+
360
+ def __rtruediv__(self, other: Any) -> Any:
361
+ if isinstance(other, Number):
362
+ if other == 0:
363
+ raise ZeroDivisionError("division by zero")
364
+ return Coordinate(other / self.value, self.coordinate_type)
365
+ raise NotImplementedError(f"Cannot divide Coordinate by type {type(other)}")
366
+
367
+ def __floordiv__(self, other: Any) -> Any:
368
+ if isinstance(other, Number):
369
+ if other == 0:
370
+ raise ZeroDivisionError("integer division by zero")
371
+ return Coordinate(self.value // other, self.coordinate_type)
372
+ raise NotImplementedError(f"Cannot divide Coordinate by type {type(other)}")
373
+
374
+ def __rfloordiv__(self, other: Any) -> Any:
375
+ raise NotImplementedError(
376
+ "Cannot divide by Coordinate (you could use its .value instead)."
377
+ )
378
+
379
+ def __mod__(self, other: Any) -> Any:
380
+ if isinstance(other, Number):
381
+ if other == 0:
382
+ raise ZeroDivisionError("modulo by zero")
383
+ return Coordinate(self.value % other, self.coordinate_type)
384
+ raise NotImplementedError(f"Cannot divide Coordinate by type {type(other)}")
385
+
386
+ def __rmod__(self, other: Any) -> Any:
387
+ raise NotImplementedError(
388
+ "Cannot divide by Coordinate (you could use its .value instead)."
389
+ )
390
+
391
+ def __pow__(self, other: Any) -> Any:
392
+ other_value = self._check_unit(other, "exponentiation")
393
+ return Coordinate(self.value**other_value, self.coordinate_type)
394
+
395
+ def __rpow__(self, other: Any) -> Any:
396
+ raise NotImplementedError(
397
+ "Cannot perform exponentiation with a Coordinate (you could use its .value instead)."
398
+ )
399
+
400
+ def __neg__(self) -> Self:
401
+ return Coordinate(-self.value, self.coordinate_type)
402
+
403
+ def __pos__(self) -> Self:
404
+ return Coordinate(+self.value, self.coordinate_type)
405
+
406
+ def __abs__(self) -> Self:
407
+ return Coordinate(abs(self.value), self.coordinate_type)
408
+
409
+ def __eq__(self, other: Any) -> bool:
410
+ other_value = self._check_unit(other, "== comparison")
411
+ return self.value == other_value
412
+
413
+ def _compare(self, other: Any, op: str) -> bool:
414
+ """Helper for comparison operations."""
415
+ other_value = self._check_unit(other, f"{op} comparison")
416
+
417
+ if op == "<":
418
+ return self.value < other_value
419
+ if op == "<=":
420
+ return self.value <= other_value
421
+ if op == ">":
422
+ return self.value > other_value
423
+ if op == ">=":
424
+ return self.value >= other_value
425
+
426
+ def __lt__(self, other: Any) -> bool:
427
+ return self._compare(other, "<")
428
+
429
+ def __le__(self, other: Any) -> bool:
430
+ return self._compare(other, "<=")
431
+
432
+ def __gt__(self, other: Any) -> bool:
433
+ return self._compare(other, ">")
434
+
435
+ def __ge__(self, other: Any) -> bool:
436
+ return self._compare(other, ">=")
437
+
438
+
439
+ C = Coordinate # alias
440
+ Coord: TypeAlias = Union[Number, Coordinate]
441
+
442
+
443
+ def convert_coordinate(
444
+ coordinate: Coord,
445
+ dtype: CoordinateType | tuple[TimeUnit, NumberType] | str,
446
+ ) -> Coordinate:
447
+ """Converts a numerical value or an existing Coordinate to a new Coordinate of the specified dtype.
448
+
449
+ If the input is a Coordinate, its original unit and number type are ignored,
450
+ and only its numerical value is used for creating the new Coordinate.
451
+
452
+ Args:
453
+ coordinate: The numerical value or Coordinate instance to convert.
454
+ dtype: The target CoordinateType, (TimeUnit, NumberType) tuple, or string key.
455
+
456
+ Returns:
457
+ A new Coordinate object of the specified dtype.
458
+ """
459
+ coordinate_type = get_coordinate_type(dtype)
460
+ value = coordinate.value if isinstance(coordinate, Coordinate) else coordinate
461
+ return Coordinate(value, coordinate_type)
462
+
463
+
464
+ def make_coordinate(
465
+ value: Coord,
466
+ dtype: Optional[tuple[TimeUnit, NumberType] | str | CoordinateType] = None,
467
+ unit: Optional[TimeUnit] = None,
468
+ number_type: Optional[NumberType | Type[Number]] = None,
469
+ default_unit: Optional[TimeUnit] = None,
470
+ default_number_type: Optional[NumberType] = None,
471
+ ) -> Coordinate:
472
+ """Creates a Coordinate instance with flexible input options.
473
+
474
+ Prioritizes `dtype` if provided. Otherwise, combines `unit` and `number_type`.
475
+ Uses `default_unit` and `default_number_type` if `value` is a raw number and
476
+ specific types are not given. If `value` is already a Coordinate, its properties
477
+ can be overridden by explicit `unit` or `number_type` arguments.
478
+
479
+ Args:
480
+ value: The numerical value or an existing Coordinate.
481
+ dtype: Optional target CoordinateType, (TimeUnit, NumberType) tuple, or string key.
482
+ unit: Optional target TimeUnit or its string representation.
483
+ number_type: Optional target NumberType or Python numeric type.
484
+ default_unit: Default TimeUnit if `value` is a number and `unit` is not set.
485
+ default_number_type: Default NumberType if `value` is a number and `number_type` is not set.
486
+
487
+ Returns:
488
+ A new Coordinate object.
489
+
490
+ Raises:
491
+ AssertionError: If final unit or number type cannot be determined.
492
+ """
493
+ unit_final, number_type_final = default_unit, default_number_type
494
+ if isinstance(value, Coordinate):
495
+ value_final = value.value
496
+ unit_final, number_type_final = value.unit, value.number_type
497
+ else:
498
+ value_final = value
499
+ if dtype is not None:
500
+ dtype_resolved = get_coordinate_type(dtype)
501
+ unit_final, number_type_final = dtype_resolved.unit, dtype_resolved.number_type
502
+ if unit is not None:
503
+ unit_final = unit
504
+ if number_type is not None:
505
+ number_type_final = number_type
506
+ assert (
507
+ unit_final is not None and number_type_final is not None
508
+ ), f"Both unit and number type need to be specified for value {value}"
509
+ try:
510
+ return Coordinate(value_final, (unit_final, number_type_final))
511
+ except TypeError as e:
512
+ msg = f"{e}. Got: {type(value)}."
513
+ if value_final != value:
514
+ msg += f" Type after conversion: {type(value_final)}"
515
+ raise TypeError(msg)
516
+
517
+
518
+ # endregion Coordinates
519
+ # region ConversionMap base class
520
+
521
+
522
+ def add_offset_arguments(
523
+ offset_a: Optional[Number], offset_b: Optional[Number]
524
+ ) -> Optional[Number]:
525
+ """Adds two optional offset numbers.
526
+
527
+ Args:
528
+ offset_a: First offset.
529
+ offset_b: Second offset.
530
+
531
+ Returns:
532
+ The sum of offsets, or one if the other is None, or None if both are None.
533
+ """
534
+ no_a = offset_a is None
535
+ no_b = offset_b is None
536
+ if no_a and no_b:
537
+ return
538
+ if no_a:
539
+ return offset_b
540
+ if no_b:
541
+ return offset_a
542
+ return offset_a + offset_b
543
+
544
+
545
+ MT = TypeVar(
546
+ "MT"
547
+ ) # atomic data type that a map outputs; can be a tuple of atomic times for CombinationMaps
548
+
549
+
550
+ class ConversionMap(ABC, RegisteredObject[MT], Generic[MT]):
551
+ _default_column_name: Optional[str] = None
552
+ _cmap_category: str = None
553
+ """The superclasses grouping classes which behave in the same way in concatenation name
554
+ themselves in the _cmap_category class attribute so that their children inherit the info.
555
+ This is used by the ConcatenationMap to see whether all maps have the same type.
556
+ """
557
+ targets_relative_to_origin: Optional[bool] = None
558
+ """Currently not in use. This is meant to distinguish LinearMaps mapping to values that are relative
559
+ to the same origin as the source values, from those that map to "absolute" values. Currently,
560
+ when LinearMaps are concatenated, the former meaning is assumed, meaning that the scalar map
561
+
562
+ [0, 2) 0.5
563
+ [2, 3) 1.5
564
+
565
+ will map the value 2.5 to 1.75: the converted length of the first region (2 * 0.5 = 1) plus the
566
+ relative offset from the second region (0.5 * 1.5 = 0.75). If the scalar map was created from
567
+ "absolute" LinearMaps, it would simply convert to 2.5 * 1.5 = 3.75. Instead of using a class
568
+ attribute, there should probably be two different subclasses for the two behaviours.
569
+ """
570
+
571
+ def __init_subclass__(cls, **kwargs):
572
+ super().__init_subclass__(**kwargs)
573
+ try:
574
+ # register those conversion maps that can be instantiated with just the default arguments
575
+ register_cmap(cls())
576
+ except Exception:
577
+ pass
578
+
579
+ def __init__(
580
+ self,
581
+ target_unit: Optional[str] = None,
582
+ column_name: Optional[str] = None,
583
+ conversion_function: Optional[
584
+ Callable[[Union[Number, np.ndarray]], Union[Number, np.ndarray]]
585
+ ] = None,
586
+ id_prefix: str = "cmap",
587
+ uid: Optional[str] = None,
588
+ **kwargs,
589
+ ):
590
+ super().__init__(id_prefix=id_prefix, uid=uid, **kwargs)
591
+ self._target_unit = None
592
+ self.target_unit = target_unit
593
+ self._custom_conversion_function = conversion_function
594
+ self._column_name = None
595
+ self.column_name = column_name
596
+
597
+ def get_meta(self) -> Meta:
598
+ """Generates metadata for converted timestamp Series/DataFrames.
599
+
600
+ Returns:
601
+ A Meta object.
602
+ """
603
+ return Meta(
604
+ map_id=self.id,
605
+ map_type=self.class_name,
606
+ target_unit=self.target_unit,
607
+ column_name=self.column_name,
608
+ )
609
+
610
+ def __call__(self, data: Any, **kwargs) -> Any:
611
+ return self.convert(data, **kwargs)
612
+
613
+ @property
614
+ def column_name(self) -> str:
615
+ if self._column_name is not None:
616
+ return self._column_name
617
+ return self.id
618
+
619
+ @column_name.setter
620
+ def column_name(self, name: Optional[str]):
621
+ if not (name is None or isinstance(name, str)):
622
+ raise ValueError(f"Invalid column name {name} ({type(name)}).")
623
+ if name is None:
624
+ if self._default_column_name is None:
625
+ self._column_name = None
626
+ return
627
+ self._column_name = f"{self.id}_{self._default_column_name}"
628
+ return
629
+ if not name.startswith(self.id):
630
+ name = f"{self.id}_{name}"
631
+ self._column_name = name
632
+
633
+ @property
634
+ def source_unit(self) -> None:
635
+ """For compatibility. None means any source unit is acceptable."""
636
+ return
637
+
638
+ @property
639
+ def target_unit(self) -> Optional[str]:
640
+ return self._target_unit
641
+
642
+ @target_unit.setter
643
+ def target_unit(self, unit: Optional[str]):
644
+ self._target_unit = unit
645
+
646
+ def conversion_function(
647
+ self, data: Number | np.ndarray, *args, **kwargs
648
+ ) -> MT | np.ndarray[MT]:
649
+ """Retrieves the appropriate conversion function and applies it after inferring
650
+ and setting :attr:`source_type` from the data.
651
+
652
+ Args:
653
+ data: Input data to infer source number type from.
654
+ *args: Positional arguments for the conversion function.
655
+ **kwargs: Keyword arguments for the conversion function.
656
+
657
+ Returns:
658
+ The converted value as a number or a numpy array according to the input.
659
+ """
660
+ if self._custom_conversion_function:
661
+ func = self._custom_conversion_function
662
+ else:
663
+ func = self.default_conversion_function
664
+ return func(data, *args, **kwargs)
665
+
666
+ def convert(
667
+ self, data: pd.Series | np.ndarray | Sequence[Coord] | Coord, **kwargs
668
+ ) -> DF | np.ndarray[MT] | pd.Series[MT] | tuple[MT, ...]:
669
+ """Converts data from the source to the target coordinate types of each of the combined maps.
670
+
671
+ Args:
672
+ data: The data to convert.
673
+ **kwargs: Additional arguments for the conversion function.
674
+
675
+ Returns:
676
+ The converted data, type matching input (Series, ndarray, or Coordinate).
677
+
678
+ Raises:
679
+ TypeError: For unsupported input data types.
680
+ """
681
+ if isinstance(data, Coordinate):
682
+ if data.unit != self.source_unit:
683
+ raise ValueError(
684
+ f"Input Coordinate unit '{data.unit}' does not match "
685
+ f"CoordinatesMap source unit '{self.source_unit}'."
686
+ )
687
+ return self.convert_number(data.value, **kwargs)
688
+ elif isinstance(data, Number):
689
+ return self.convert_number(data, **kwargs)
690
+ elif isinstance(data, pd.Series):
691
+ return self.convert_series(data, **kwargs)
692
+ elif isinstance(data, pd.Index):
693
+ return self.convert_index(data, **kwargs)
694
+ elif isinstance(data, pd.DataFrame):
695
+ return self.convert_dataframe(data, **kwargs)
696
+ elif isinstance(data, np.ndarray):
697
+ return self.convert_array(data, **kwargs)
698
+ elif isinstance(data, Iterable) and not isinstance(data, (str, bytes)):
699
+ return [self.convert_number(elem, **kwargs) for elem in data]
700
+ else:
701
+ raise TypeError(f"Unsupported data type for conversion: {type(data)}")
702
+
703
+ def _convert_array(self, data: np.ndarray, **kwargs) -> np.ndarray[MT]:
704
+ return self.conversion_function(data, **kwargs)
705
+
706
+ def convert_array(self, data: np.ndarray, **kwargs) -> np.ndarray[MT]:
707
+ return self._convert_array(data, **kwargs)
708
+
709
+ def convert_number(self, data: Number, **kwargs) -> MT:
710
+ return self.conversion_function(data, **kwargs)
711
+
712
+ def convert_index(self, data: pd.Index, **kwargs) -> pd.Index:
713
+ """Converts a pandas Index.
714
+
715
+ Args:
716
+ data: The pandas Index to convert.
717
+ **kwargs: Additional arguments for the conversion function.
718
+
719
+ Returns:
720
+ A converted Index.
721
+ """
722
+ if data.size == 0:
723
+ return pd.Index([])
724
+ converted = self.convert_array(data.values)
725
+ return pd.Index(converted)
726
+
727
+ def convert_series(
728
+ self,
729
+ data: pd.Series,
730
+ name: Optional[str] = None,
731
+ as_dataframe: bool | dict[str, Literal] = False,
732
+ **kwargs,
733
+ ) -> DS | DF:
734
+ """Converts a pandas Series.
735
+
736
+ Args:
737
+ data: The pandas Series to convert.
738
+ name: Optional name for the resulting Series.
739
+ as_dataframe: If True, returns a 1-column DataFrame.
740
+ **kwargs: Additional arguments for the conversion function.
741
+
742
+ Returns:
743
+ A converted pandas Series or DataFrame.
744
+ """
745
+ if data.size == 0:
746
+ return pd.Series()
747
+ converted = self.convert_array(data.values, **kwargs)
748
+ result = DF(converted, index=data.index).iloc[
749
+ :, 0
750
+ ] # creating series right away would fail for
751
+ # structured arrays
752
+ if name is None:
753
+ result = result.rename(self.column_name)
754
+ else:
755
+ result = result.rename(name)
756
+ if as_dataframe:
757
+ return result.to_frame()
758
+ return result
759
+
760
+ def convert_dataframe(self, data: pd.DataFrame, **kwargs) -> DF:
761
+ """Converts a pandas DataFrame."""
762
+ converted_series = [
763
+ self.convert_series(column, name=kwargs.pop("column_name", name), **kwargs)
764
+ for name, column in data.items()
765
+ ]
766
+ return pd_concat(converted_series, axis=1)
767
+
768
+ def _make_inverse_column_name(
769
+ self,
770
+ ):
771
+ if "_" not in self.column_name:
772
+ return self.column_name
773
+ id_part, name_part = self.column_name.split("_", maxsplit=1)
774
+ if not name_part:
775
+ return id_part
776
+ try:
777
+ # if the name is a unit, replace it with the new target unit
778
+ _ = TimeUnit(name_part)
779
+ return f"{id_part}_{self.source_unit}"
780
+ except ValueError:
781
+ # otherwise, leave as is
782
+ return self.column_name
783
+
784
+ @abstractmethod
785
+ def get_inverse(self) -> Self:
786
+ # ToDo: factor out calling ConversionMap._make_inverse_column_name() and assigning
787
+ # id_prefix="imap"
788
+ raise NotImplementedError
789
+
790
+ @abstractmethod
791
+ def default_conversion_function(self, value, kwargs):
792
+ """Default conversion logic, to be implemented by subclasses.
793
+
794
+ Args:
795
+ value: The numerical value or array to convert.
796
+ **kwargs: Additional arguments.
797
+
798
+ Raises:
799
+ NotImplementedError: If not overridden by a subclass or provided via `conversion_function`.
800
+ """
801
+ raise NotImplementedError(
802
+ f"{self.__class__.__name__} must implement default_conversion_function "
803
+ "or be instantiated with a conversion_function."
804
+ )
805
+
806
+ def _repr_additional_properties(self) -> list[str]:
807
+ return [
808
+ f"id={self.id!r}",
809
+ f"target_unit={self.target_unit}",
810
+ f"column_name={self.column_name!r}",
811
+ ]
812
+
813
+ def __repr__(self):
814
+ repr_string = f"{self.__class__.__name__}("
815
+ additional_properties = self._repr_additional_properties()
816
+ if additional_properties:
817
+ repr_string += "\n\t"
818
+ repr_string += ",\n\t".join(additional_properties)
819
+ repr_string += "\n"
820
+ repr_string += ")"
821
+ return repr_string
822
+
823
+
824
+ class _TargetTypeMixin:
825
+ """Mixin for composing ConversionMaps that have a target type.
826
+ Adds methods and properties for getting the type in multiple ways.
827
+ """
828
+
829
+ _default_target_type: Optional[NumberType] = None
830
+
831
+ def __init__(self, target_type=None, *args, **kwargs):
832
+ self._target_type = None
833
+ self.target_type = target_type
834
+ super().__init__(*args, **kwargs)
835
+
836
+ @property
837
+ def target_type(self) -> Type[CT]:
838
+ return self._target_type
839
+
840
+ @target_type.setter
841
+ def target_type(self, target_type: Optional[Type]):
842
+ if target_type is None:
843
+ self._target_type = self._default_target_type
844
+ else:
845
+ try:
846
+ target_type = NumberType(target_type)
847
+ except ValueError:
848
+ pass
849
+ self._target_type = target_type
850
+
851
+ @property
852
+ def target_dtype(self) -> np.dtype:
853
+ """For constructing numpy arrays."""
854
+ return utils.python_type_to_numpy_dtype(self._target_type)
855
+
856
+ @property
857
+ def target_fields(self) -> list[tuple[str, np.dtype | str]]:
858
+ return [(self.column_name, self.target_dtype)]
859
+
860
+ def convert_array(self, data: np.ndarray, **kwargs) -> np.ndarray[MT]:
861
+ """Returns a structured array based on column_name and target type.
862
+ To obtain a plain numpy array, use the _convert_array() method.
863
+ """
864
+ converted_array = super().convert_array(data, **kwargs)
865
+ return np.array(converted_array, dtype=self.target_fields)
866
+
867
+
868
+ class _SourceTypeMixin:
869
+
870
+ def __init__(self, *args, **kwargs):
871
+ self._source_type = None
872
+ super().__init__(*args, **kwargs)
873
+
874
+ @property
875
+ def source_type(self) -> NumberType:
876
+ """The NumberType of the source values (inferred at conversion time)."""
877
+ if self._source_type is None:
878
+ raise ValueError(
879
+ f"source_type for {self.id} is not yet inferred (call convert first)."
880
+ )
881
+ return self._source_type
882
+
883
+ def conversion_function(
884
+ self, data: Number | np.ndarray, *args, **kwargs
885
+ ) -> Number | np.ndarray:
886
+ self._infer_source_type(data)
887
+ self.logger.debug(
888
+ f"Source type after inferring from {data.__class__.__name__}: {self.source_type}"
889
+ )
890
+ return super().conversion_function(data, *args, **kwargs)
891
+
892
+ def _infer_source_type(
893
+ self, data: pd.Series | np.ndarray | Sequence[Coord] | Number | Coordinate
894
+ ) -> None:
895
+ """Infers and sets the :attr:`source_type` based on the input data.
896
+
897
+ Args:
898
+ data: The input data.
899
+ """
900
+ if isinstance(data, (pd.Series, pd.Index, np.ndarray, Sequence)):
901
+ try:
902
+ item = data[0]
903
+ except KeyError:
904
+ # the error appears (hopefully) because it's a Series
905
+ item = data.iloc[0]
906
+ else:
907
+ item = data
908
+ if isinstance(item, Coordinate):
909
+ self._source_type = item.number_type
910
+ else:
911
+ self._source_type = NumberType(item.__class__)
912
+
913
+
914
+ # endregion ConversionMap base class
915
+ # region ConstantMap
916
+
917
+ CT = TypeVar(
918
+ "CT", bound=Union[int, float, complex, bool, str, bytes, list, tuple, type(None)]
919
+ )
920
+
921
+
922
+ class ConstantMap(_TargetTypeMixin, ConversionMap[CT], Generic[CT]):
923
+ """
924
+ A CoordinatesMap that maps any input value to a single constant value (coordinate or other).
925
+
926
+ """
927
+
928
+ _cmap_category: str = "ConstantMap"
929
+
930
+ targets_relative_to_origin: Optional[bool] = False
931
+
932
+ def __init__(
933
+ self,
934
+ constant: CT,
935
+ target_type: Optional[Type] = None,
936
+ target_unit: Optional[str] = None,
937
+ column_name: Optional[str] = None,
938
+ conversion_function: Optional[
939
+ Callable[[Union[Number, np.ndarray]], Union[Number, np.ndarray]]
940
+ ] = None,
941
+ id_prefix: str = "cmap",
942
+ uid: Optional[str] = None,
943
+ **kwargs,
944
+ ):
945
+ """
946
+ Initializes a ConstantMap.
947
+
948
+ Args:
949
+ constant:
950
+ The target constant that all inputs will map to. It could be a string, e.g. a filename,
951
+ or a coordinate.
952
+ target_type:
953
+ The type of the constant. Will typically not be specified because it can be inferred.
954
+ If specified, the constant value will be converted accordingly.
955
+ id_prefix: Prefix for the map's ID.
956
+ uid: Unique identifier for the map.
957
+ """
958
+ super().__init__(
959
+ target_type=target_type,
960
+ target_unit=target_unit,
961
+ column_name=column_name,
962
+ conversion_function=conversion_function,
963
+ id_prefix=id_prefix,
964
+ uid=uid,
965
+ **kwargs,
966
+ )
967
+ self._constant: CT = None
968
+ self.constant = constant
969
+
970
+ def get_meta(self) -> Meta:
971
+ """Generates metadata for converted timestamp Series/DataFrames.
972
+
973
+ Returns:
974
+ A Meta object.
975
+ """
976
+ return Meta(
977
+ map_id=self.id,
978
+ map_type=self.class_name,
979
+ constant=self.constant,
980
+ target_unit=self.target_unit,
981
+ column_name=self.column_name,
982
+ )
983
+
984
+ @property
985
+ def constant(self) -> CT:
986
+ return self._constant
987
+
988
+ @constant.setter
989
+ def constant(self, constant: CT):
990
+ if self._target_type is None:
991
+ self._target_type = type(constant)
992
+ else:
993
+ if isinstance(self._target_type, NumberType):
994
+ tt = self._target_type.value
995
+ else:
996
+ tt = self._target_type
997
+ if not isinstance(constant, tt):
998
+ constant = self._target_type(constant)
999
+ self._constant = constant
1000
+
1001
+ def default_conversion_function(
1002
+ self, value: Union[Number, np.ndarray], **kwargs
1003
+ ) -> Union[Number, np.ndarray]:
1004
+ """
1005
+ Returns the constant value, ignoring the input `value`.
1006
+
1007
+ If the input `value` is an array-like structure, an array of the constant
1008
+ value with the same shape/size as the input `value` is returned, using
1009
+ the number type of the `constant` Coordinate.
1010
+
1011
+ Args:
1012
+ value: The input numerical value or array. This is used to determine
1013
+ the shape of the output if it's an array, but its content is ignored.
1014
+ **kwargs: Additional arguments (not used by this function).
1015
+
1016
+ Returns:
1017
+ The constant value, or an array filled with the constant value.
1018
+ """
1019
+ constant_value = self.constant
1020
+ if is_scalar(value):
1021
+ return constant_value
1022
+ else:
1023
+ dtype = self.target_dtype
1024
+ return np.full_like(value, constant_value, dtype=dtype)
1025
+
1026
+ def get_inverse(self) -> Optional[CoordinatesMap]:
1027
+ """
1028
+ Inverse CoordinatesMap is not defined for ConstantMap.
1029
+
1030
+ Raises:
1031
+ NotImplementedError: Always, as inverse is not meaningful for ConstantMap.
1032
+ """
1033
+ raise NotImplementedError("Cannot map from a constant to the original values.")
1034
+
1035
+ def _repr_additional_properties(self) -> list[str]:
1036
+ """
1037
+ Provides additional properties for the __repr__ string.
1038
+
1039
+ Returns:
1040
+ A list of formatted property strings.
1041
+ """
1042
+ result = super()._repr_additional_properties()
1043
+ result.append(f"constant={self._constant!r}")
1044
+ return result
1045
+
1046
+
1047
+ # endregion ConstantMap
1048
+ # region RegionMap
1049
+
1050
+
1051
+ class RegionMap(_TargetTypeMixin, ConversionMap[CT], Generic[CT]):
1052
+ """This the equivalent of a :class:`ConstantMap` but with different constants for different regions.
1053
+ It is also equivalent to a :class:`ConcatenationMap` of :class:`ConstantMap` instances but
1054
+ without the need to create individual instances.
1055
+
1056
+ The conversion is defined by a pandas Series with an IntervalIndex mapping
1057
+ coordinate ranges (accourding to the source) to constants.
1058
+ """
1059
+
1060
+ @classmethod
1061
+ def from_breaks(
1062
+ cls, breaks: Iterable[Number], constants: Iterable[Any], **kwargs
1063
+ ) -> Self:
1064
+ """Creates the intervals for the given cmaps using pd.IntervalIndex.from_breaks().
1065
+ This requires n+1 breaks for n constants. E.g.: breaks [0,8,15] => [[0,8), [8,15]).
1066
+ """
1067
+ iix = pd.IntervalIndex.from_breaks(breaks, closed="left")
1068
+ series = pd.Series(constants, index=iix, dtype=object)
1069
+ return cls(series, **kwargs)
1070
+
1071
+ @classmethod
1072
+ def from_arrays(
1073
+ cls,
1074
+ left: Iterable[Number],
1075
+ right: Iterable[Number],
1076
+ constants: Iterable[Any],
1077
+ **kwargs,
1078
+ ) -> Self:
1079
+ """Creates the intervals for the given cmaps using pd.IntervalIndex.from_arrays().
1080
+ left, right, and constants need to have the same number of elements.
1081
+ """
1082
+ iix = pd.IntervalIndex.from_arrays(left, right, closed="left")
1083
+ series = pd.Series(constants, index=iix, dtype=object)
1084
+ return cls(series, **kwargs)
1085
+
1086
+ def __init__(
1087
+ self,
1088
+ region_map: pd.Series,
1089
+ offset_source_map: Optional[pd.Series] = None,
1090
+ offset_target_map: Optional[pd.Series] = None,
1091
+ **kwargs,
1092
+ ):
1093
+ """Initializes a MultiScalarCoordinatesMap.
1094
+
1095
+ Args:
1096
+ region_map: Series with IntervalIndex mapping source ranges to scalars.
1097
+ offset_source_map: Optional Series mapping source ranges to source offsets.
1098
+ offset_target_map: Optional Series mapping source ranges to target offsets.
1099
+ **kwargs: Arguments for CoordinatesMap.
1100
+ """
1101
+ super().__init__(**kwargs)
1102
+ self._region_map = None
1103
+ self.region_map = region_map
1104
+ self._offset_source_map = None
1105
+ self._offset_target_map = None
1106
+ if offset_source_map is not None:
1107
+ self.offset_source_map = offset_source_map
1108
+ if offset_target_map is not None:
1109
+ self.offset_target_map = offset_target_map
1110
+
1111
+ @property
1112
+ def offset_source_map(self):
1113
+ """Series mapping source coordinate ranges to source offsets."""
1114
+ return self._offset_source_map
1115
+
1116
+ @offset_source_map.setter
1117
+ def offset_source_map(self, offset_source_map: pd.Series):
1118
+ self.validate_input_series(offset_source_map, "offset_source_map", self.length)
1119
+ self._offset_source_map = offset_source_map
1120
+
1121
+ @property
1122
+ def offset_target_map(self):
1123
+ """Series mapping source coordinate ranges to target offsets."""
1124
+ return self._offset_target_map
1125
+
1126
+ @offset_target_map.setter
1127
+ def offset_target_map(self, offset_target_map: pd.Series):
1128
+ # Target offset map's length isn't directly tied to source length like scalar/source_offset maps
1129
+ self.validate_input_series(offset_target_map, "offset_target_map")
1130
+ self._offset_target_map = offset_target_map
1131
+
1132
+ @property
1133
+ def region_map(self):
1134
+ """Series with IntervalIndex mapping source ranges to scalar values."""
1135
+ return self._region_map
1136
+
1137
+ @region_map.setter
1138
+ def region_map(self, scalar_map: pd.Series):
1139
+ self.validate_input_series(scalar_map, "scalar_map")
1140
+ self._region_map = scalar_map
1141
+ self.length = scalar_map.index.right.max()
1142
+
1143
+ def default_conversion_function(
1144
+ self,
1145
+ value: Union[Number, np.ndarray],
1146
+ left_unbounded: bool = False,
1147
+ right_unbounded: bool = True,
1148
+ **kwargs,
1149
+ ) -> Union[Number, np.ndarray]:
1150
+ """Applies interval-based offsets and scalar multiplication.
1151
+
1152
+ Args:
1153
+ value: Input value(s).
1154
+ left_unbounded: If True, first interval of maps is left-unbounded.
1155
+ right_unbounded: If True, last interval of maps is right-unbounded.
1156
+ **kwargs: Not used.
1157
+
1158
+ Returns:
1159
+ Converted value(s).
1160
+ """
1161
+ self.logger.debug(
1162
+ f"{self.class_name}.default_conversion_function({left_unbounded=}, {right_unbounded=}, "
1163
+ f"{kwargs=})"
1164
+ )
1165
+ converted_value = value
1166
+ osm = self.get_offset_source_map(
1167
+ left_unbounded=left_unbounded, right_unbounded=right_unbounded
1168
+ )
1169
+ if osm is not None:
1170
+ offset_source = get_values_from_interval_map(osm, value)
1171
+ converted_value = converted_value + offset_source
1172
+ sc = self.get_region_map(
1173
+ left_unbounded=left_unbounded, right_unbounded=right_unbounded
1174
+ )
1175
+ converted_value = get_values_from_interval_map(sc, converted_value)
1176
+ otm = self.get_offset_target_map(
1177
+ left_unbounded=left_unbounded, right_unbounded=right_unbounded
1178
+ )
1179
+ if otm is not None:
1180
+ offset_target = get_values_from_interval_map(otm, value)
1181
+ converted_value = converted_value + offset_target
1182
+ return converted_value
1183
+
1184
+ def get_region_map(
1185
+ self, left_unbounded: bool = False, right_unbounded: bool = True
1186
+ ) -> pd.Series:
1187
+ """Retrieves the scalar map, optionally with unbounded intervals.
1188
+
1189
+ Args:
1190
+ left_unbounded: If True, makes the first interval left-unbounded.
1191
+ right_unbounded: If True, makes the last interval right-unbounded.
1192
+
1193
+ Returns:
1194
+ The scalar map Series.
1195
+ """
1196
+ sm = self._region_map
1197
+ if sm is None or not (right_unbounded or left_unbounded):
1198
+ return sm
1199
+ return utils.replace_interval_index_with_unbounded_one(
1200
+ sm, left_unbounded=left_unbounded, right_unbounded=right_unbounded
1201
+ )
1202
+
1203
+ def get_offset_source_map(
1204
+ self, left_unbounded: bool = False, right_unbounded: bool = True
1205
+ ) -> Optional[pd.Series]:
1206
+ """Retrieves the source offset map, optionally with unbounded intervals.
1207
+
1208
+ Args:
1209
+ left_unbounded: If True, makes the first interval left-unbounded.
1210
+ right_unbounded: If True, makes the last interval right-unbounded.
1211
+
1212
+ Returns:
1213
+ The source offset map Series, or None.
1214
+ """
1215
+ osm = self._offset_source_map
1216
+ if osm is None or not (right_unbounded or left_unbounded):
1217
+ return osm
1218
+ return utils.replace_interval_index_with_unbounded_one(
1219
+ osm, left_unbounded=left_unbounded, right_unbounded=right_unbounded
1220
+ )
1221
+
1222
+ def get_offset_target_map(
1223
+ self, left_unbounded: bool = False, right_unbounded: bool = True
1224
+ ) -> Optional[pd.Series]:
1225
+ """Retrieves the target offset map, optionally with unbounded intervals.
1226
+
1227
+ Args:
1228
+ left_unbounded: If True, makes the first interval left-unbounded.
1229
+ right_unbounded: If True, makes the last interval right-unbounded.
1230
+
1231
+ Returns:
1232
+ The target offset map Series, or None.
1233
+ """
1234
+ otm = self._offset_target_map
1235
+ if otm is None or not (right_unbounded or left_unbounded):
1236
+ return otm
1237
+ return utils.replace_interval_index_with_unbounded_one(
1238
+ otm, left_unbounded=left_unbounded, right_unbounded=right_unbounded
1239
+ )
1240
+
1241
+ def validate_input_series(
1242
+ self, s_map: pd.Series, arg_name: str, target_length: Optional[Number] = None
1243
+ ):
1244
+ """Validates an input Series for use in this map.
1245
+
1246
+ Args:
1247
+ s_map: The Series to validate.
1248
+ arg_name: Name of the argument for error messages.
1249
+ target_length: Expected maximum right boundary of the IntervalIndex.
1250
+
1251
+ Raises:
1252
+ ValueError: If Series is empty.
1253
+ AssertionError: If index is not IntervalIndex, not non-overlapping/monotonic, or length mismatches.
1254
+ """
1255
+ if s_map.size == 0:
1256
+ raise ValueError(f"{arg_name} cannot be empty")
1257
+ assert isinstance(
1258
+ s_map.index, pd.IntervalIndex
1259
+ ), f"The index of a {arg_name} needs to be an IntervalIndex."
1260
+ assert s_map.index.is_non_overlapping_monotonic, (
1261
+ f"The IntervalIndex of a {arg_name} needs to be "
1262
+ f"non-overlapping and monotonic."
1263
+ )
1264
+ if target_length is not None:
1265
+ this_length = s_map.index.right.max()
1266
+ assert this_length == target_length, (
1267
+ f"The index of the {arg_name} has length {this_length} "
1268
+ f"{self.source_unit} but should have {target_length}, "
1269
+ f"like the scalar_map."
1270
+ )
1271
+
1272
+ def get_inverse(self) -> Optional["CoordinatesMap"]:
1273
+ raise NotImplementedError("Cannot get inverse for constants.")
1274
+
1275
+ def _repr_additional_properties(self):
1276
+ result = super()._repr_additional_properties()
1277
+ region_map_str = self._region_map.to_string(max_rows=3, length=True)
1278
+ region_map_str = "\t" + "\n\t".join(region_map_str.split("\n"))
1279
+ result.append(f"region_map=\n{region_map_str}\n")
1280
+ return result
1281
+
1282
+
1283
+ # endregion RegionMap
1284
+ # region CoordinatesMap
1285
+
1286
+
1287
+ class CoordinatesMap(_TargetTypeMixin, _SourceTypeMixin, ConversionMap[Coordinate]):
1288
+ """Converts coordinate values of a :class:`timeline` to another representation.
1289
+
1290
+ Instantiated maps are callable as a shorthand for their .convert() method.
1291
+ Subclasses can define default units, number types, and inverse converters.
1292
+ """
1293
+
1294
+ _default_source_unit: TimeUnit = TimeUnit.number
1295
+ _default_target_unit: TimeUnit = TimeUnit.number
1296
+ _default_target_type: Optional[NumberType] = None
1297
+ _default_inverse_class: Optional[Type["CoordinatesMap"]] = None
1298
+ targets_relative_to_origin: Optional[bool] = None
1299
+
1300
+ @classmethod
1301
+ def from_conversion_map(
1302
+ cls,
1303
+ cmap: CoordinatesMap,
1304
+ source_unit: Optional[TimeUnit | str] = None,
1305
+ target_unit: Optional[TimeUnit | str] = None,
1306
+ target_type: Optional[NumberType] = None,
1307
+ conversion_function: Optional[
1308
+ Callable[[Union[Number, np.ndarray]], Union[Number, np.ndarray]]
1309
+ ] = None,
1310
+ column_name: Optional[str] = None,
1311
+ id_prefix: str = "cmap",
1312
+ uid: Optional[str] = None,
1313
+ **kwargs,
1314
+ ):
1315
+ """Creates a new CoordinatesMap instance, inheriting properties from an existing one.
1316
+
1317
+ Args:
1318
+ cls: The class to instantiate.
1319
+ cmap: The existing CoordinatesMap to base properties on.
1320
+ source_unit: Overrides source unit.
1321
+ target_unit: Overrides target unit.
1322
+ target_type: Overrides target number type.
1323
+ conversion_function: Overrides conversion function.
1324
+ id_prefix: ID prefix for the new map.
1325
+ uid: UID for the new map.
1326
+ **kwargs: Additional arguments for the new map's __init__.
1327
+
1328
+ Returns:
1329
+ A new CoordinatesMap instance.
1330
+ """
1331
+ args = dict(locals())
1332
+ del args["cls"]
1333
+ del args["cmap"]
1334
+ del args["kwargs"]
1335
+ init_args = {}
1336
+ for arg, val in args.items():
1337
+ init_args[arg] = getattr(cmap, arg, val) if val is None else val
1338
+ init_args.update(kwargs)
1339
+ return cls(**init_args)
1340
+
1341
+ def __init__(
1342
+ self,
1343
+ source_unit: Optional[TimeUnit | str] = None,
1344
+ target_type: Optional[Type] = None,
1345
+ target_unit: Optional[str] = None,
1346
+ column_name: Optional[str] = None,
1347
+ conversion_function: Optional[
1348
+ Callable[[Union[Number, np.ndarray]], Union[Number, np.ndarray]]
1349
+ ] = None,
1350
+ id_prefix: str = "cmap",
1351
+ uid: Optional[str] = None,
1352
+ **kwargs,
1353
+ ):
1354
+ """Initializes a CoordinatesMap.
1355
+
1356
+ Args:
1357
+ source_unit: The unit of the source coordinates. Defaults to `_default_source_unit`.
1358
+ target_unit: The unit of the target coordinates. Defaults to `_default_target_unit`.
1359
+ target_type: The number type of the target coordinates. Defaults to `_default_target_type`.
1360
+ If None, the source number type is preserved.
1361
+ conversion_function: Custom function for conversion logic.
1362
+ id_prefix: Prefix for the map's ID.
1363
+ uid: Unique identifier for the map.
1364
+ """
1365
+ super().__init__(
1366
+ target_type=target_type,
1367
+ target_unit=target_unit,
1368
+ column_name=column_name,
1369
+ conversion_function=conversion_function,
1370
+ id_prefix=id_prefix,
1371
+ uid=uid,
1372
+ **kwargs,
1373
+ )
1374
+ self._length = None
1375
+ self._source_unit = None
1376
+ self.source_unit = source_unit
1377
+ register_cmap(self)
1378
+
1379
+ def get_meta(self) -> Meta:
1380
+ """Generates metadata for converted timestamp Series/DataFrames.
1381
+
1382
+ Returns:
1383
+ A Meta object.
1384
+ """
1385
+ return Meta(
1386
+ map_id=self.id,
1387
+ map_type=self.class_name,
1388
+ source_unit=self.source_unit,
1389
+ target_unit=self.target_unit,
1390
+ column_name=self.column_name,
1391
+ )
1392
+
1393
+ @property
1394
+ def length(self) -> Coordinate:
1395
+ """The length associated with this conversion map, in source units."""
1396
+ return self._length
1397
+
1398
+ @length.setter
1399
+ def length(self, length: Coord):
1400
+ if self._source_type is None:
1401
+ if isinstance(length, Coordinate):
1402
+ self._source_type = length.number_type
1403
+ log_msg_detail = "length coordinate"
1404
+ else:
1405
+ self._source_type = NumberType.from_number(length)
1406
+ log_msg_detail = "length value"
1407
+ log_msg = (
1408
+ f"_source_type has been set to {self._source_type} "
1409
+ f"for {self.class_name} {self.id!r} based on the given {log_msg_detail}."
1410
+ )
1411
+ self.logger.debug(log_msg)
1412
+ self._length = self.make_coordinate(length)
1413
+
1414
+ @property
1415
+ def target_length(self) -> Coordinate:
1416
+ """The length converted to the target unit."""
1417
+ return self.convert(self.length, right_unbounded=True)
1418
+
1419
+ @property
1420
+ def source_coordinate_type(self) -> CoordinateType:
1421
+ """The full CoordinateType of the source."""
1422
+ if self._source_unit is None:
1423
+ raise ValueError(f"source_unit is None for {self.id}")
1424
+ if self._source_type is None:
1425
+ raise ValueError(
1426
+ f"source_type is None for {self.id} (inferred at conversion time)"
1427
+ )
1428
+ return get_coordinate_type((self._source_unit, self._source_type))
1429
+
1430
+ @property
1431
+ def source_unit(self) -> TimeUnit:
1432
+ """The TimeUnit of the source values."""
1433
+ return self._source_unit
1434
+
1435
+ @source_unit.setter
1436
+ def source_unit(self, source_unit: Optional[TimeUnit | str]):
1437
+ if source_unit is None:
1438
+ self._source_unit = TimeUnit(self._default_source_unit)
1439
+ else:
1440
+ self._source_unit = TimeUnit(source_unit)
1441
+
1442
+ @property
1443
+ def target_coordinate_type(self) -> CoordinateType:
1444
+ """The full CoordinateType of the target."""
1445
+ if self._target_unit is None:
1446
+ raise ValueError(f"target_unit is None for {self.id}")
1447
+ effective_target_num_type = self.effective_target_type
1448
+ if effective_target_num_type is None:
1449
+ raise ValueError(f"Cannot determine effective target_type for {self.id}")
1450
+ return get_coordinate_type((self._target_unit, effective_target_num_type))
1451
+
1452
+ @property
1453
+ def effective_target_type(self) -> NumberType:
1454
+ """The NumberType used for the output. Uses target_type if set, else source_type."""
1455
+ if self._target_type is not None:
1456
+ return self._target_type
1457
+ if self._source_type is None:
1458
+ raise ValueError(
1459
+ f"Cannot determine effective target number type for {self.id} as source_type is not inferred."
1460
+ )
1461
+ return self.source_type
1462
+
1463
+ @property
1464
+ def target_unit(self) -> TimeUnit:
1465
+ """The TimeUnit of the target values."""
1466
+ return self._target_unit
1467
+
1468
+ @target_unit.setter
1469
+ def target_unit(self, target_unit: Optional[TimeUnit | str]):
1470
+ if target_unit is None:
1471
+ self._target_unit = TimeUnit(self._default_target_unit)
1472
+ else:
1473
+ self._target_unit = TimeUnit(target_unit)
1474
+
1475
+ def get_inverse_class(self) -> Optional[Type["CoordinatesMap"]]:
1476
+ """Retrieves the class designated as the inverse of this map.
1477
+
1478
+ Returns:
1479
+ The inverse CoordinatesMap class, or None.
1480
+ """
1481
+ if self._default_inverse_class is None:
1482
+ return
1483
+ if isinstance(self._default_inverse_class, str):
1484
+ current_module = sys.modules[self.__class__.__module__]
1485
+ return getattr(current_module, self._default_inverse_class)
1486
+ return self._default_inverse_class
1487
+
1488
+ def get_inverse(self) -> Optional["CoordinatesMap"]:
1489
+ """Creates an instance of the inverse conversion map.
1490
+
1491
+ Returns:
1492
+ An inverse CoordinatesMap instance, or None.
1493
+
1494
+ Raises:
1495
+ NotImplementedError: If no inverse is defined.
1496
+ """
1497
+ if cls := self.get_inverse_class() is not None:
1498
+ return cls.from_conversion_map(
1499
+ self, column_name=self._make_inverse_column_name(), id_prefix="imap"
1500
+ )
1501
+ raise NotImplementedError(
1502
+ f"No inverse CoordinatesMap has been defined for {self.__class__.__name__}."
1503
+ )
1504
+
1505
+ def make_coordinate(self, value: Coord) -> Coordinate:
1506
+ """Creates a Coordinate in the map's source unit and inferred source number type.
1507
+
1508
+ Args:
1509
+ value: The numerical value or an existing Coordinate.
1510
+
1511
+ Returns:
1512
+ A new Coordinate object.
1513
+ """
1514
+ return convert_coordinate(value, (self.source_unit, self.source_type))
1515
+
1516
+ def make_target_coordinate(self, value: Coord) -> Coordinate:
1517
+ """Creates a Coordinate in the map's target unit and effective target number type.
1518
+
1519
+ Args:
1520
+ value: The numerical value or an existing Coordinate.
1521
+
1522
+ Returns:
1523
+ A new Coordinate object.
1524
+ """
1525
+ return convert_coordinate(value, (self.target_unit, self.effective_target_type))
1526
+
1527
+ def get_values_from_coordinates(
1528
+ self, data: DS | pd.Series | np.ndarray
1529
+ ) -> np.ndarray:
1530
+ """Extracts numerical values from an array/series of Coordinates, ensuring unit consistency.
1531
+
1532
+ Args:
1533
+ data: The input data containing Coordinate objects.
1534
+
1535
+ Returns:
1536
+ A NumPy array of numerical values.
1537
+
1538
+ Raises:
1539
+ ValueError: If coordinates have multiple units or mismatch the map's source unit.
1540
+ """
1541
+
1542
+ def get_unit(coordinate: Coordinate):
1543
+ return coordinate.unit
1544
+
1545
+ def get_value(coordinate: Coordinate):
1546
+ return coordinate.value
1547
+
1548
+ units = np.vectorize(get_unit)(data)
1549
+ n_units = np.unique(units).size
1550
+ if n_units > 1:
1551
+ raise ValueError(f"The coordinates have more than one unit: {units}")
1552
+ if units[0] != self.source_unit and self.source_unit != TimeUnit.number:
1553
+ warnings.warn(
1554
+ f"Expecting {self.source_unit!r} coordinates but these are {units[0]!r} coordinates."
1555
+ )
1556
+ values = np.vectorize(get_value)(data)
1557
+ return values
1558
+
1559
+ def _array_to_target_type(self, converted_values: np.ndarray) -> np.ndarray:
1560
+ """Casts a NumPy array to the effective target number type if necessary.
1561
+
1562
+ Args:
1563
+ converted_values: The array after unit conversion.
1564
+
1565
+ Returns:
1566
+ The array, potentially cast to a new dtype.
1567
+ """
1568
+ if self._target_type is not None and self._target_type != self.source_type:
1569
+ dtype = _safely_get_dtype_from_number_type(
1570
+ self.effective_target_type
1571
+ ) # Workaround for strings
1572
+ return converted_values.astype(dtype)
1573
+ return converted_values
1574
+
1575
+ def conversion_function(
1576
+ self, data: Number | np.ndarray, *args, **kwargs
1577
+ ) -> Number | np.ndarray:
1578
+ self._infer_source_type(data)
1579
+ return super().conversion_function(data, *args, **kwargs)
1580
+
1581
+ def convert_array(self, data: np.ndarray, **kwargs) -> np.ndarray:
1582
+ """Converts a NumPy array.
1583
+
1584
+ Args:
1585
+ data: The NumPy array to convert.
1586
+ **kwargs: Additional arguments for the conversion function.
1587
+
1588
+ Returns:
1589
+ A new NumPy array with converted values.
1590
+ """
1591
+ if data.size == 0:
1592
+ return np.array([], dtype=self.effective_target_type.value)
1593
+ values = data
1594
+ if data.dtype == object and isinstance(data[0], Coordinate):
1595
+ values = self.get_values_from_coordinates(data)
1596
+ converted_values = self.conversion_function(values, **kwargs)
1597
+ return self._array_to_target_type(converted_values)
1598
+
1599
+ def convert_number(self, data: Number, **kwargs) -> Coordinate:
1600
+ converted_value = self.conversion_function(data, **kwargs)
1601
+ return self.make_target_coordinate(converted_value)
1602
+
1603
+ def convert_series(
1604
+ self,
1605
+ data: pd.Series,
1606
+ name: Optional[str] = None,
1607
+ as_dataframe: bool | dict[str, Literal] = False,
1608
+ **kwargs,
1609
+ ) -> DS | DF:
1610
+ """Converts a pandas Series.
1611
+
1612
+ Args:
1613
+ data: The pandas Series to convert.
1614
+ name: Optional name for the resulting Series.
1615
+ as_dataframe: If True, returns a DataFrame. If a dict, adds columns from dict.
1616
+ **kwargs: Additional arguments for the conversion function.
1617
+
1618
+ Returns:
1619
+ A converted pandas Series or DataFrame.
1620
+ """
1621
+ if data.size == 0:
1622
+ return data
1623
+ converted = self.convert_array(data.values, **kwargs)
1624
+ meta = deepcopy(getattr(data, "_meta", {}))
1625
+ meta.update(self.get_meta())
1626
+ if "length" in meta:
1627
+ meta["source_length"] = meta["length"]
1628
+ meta["length"] = self.convert(meta["source_length"])
1629
+ try:
1630
+ dtype = _safely_get_dtype_from_number_type(self.effective_target_type)
1631
+ if dtype == int:
1632
+ dtype = "Int64"
1633
+ elif dtype == Fraction:
1634
+ dtype = "object"
1635
+ converted = [Fraction(val) for val in converted]
1636
+ elif dtype == str:
1637
+ dtype = "string"
1638
+ try:
1639
+ result = DS(converted, dtype=dtype, index=data.index, meta=meta)
1640
+ except TypeError:
1641
+ if dtype == "Int64":
1642
+ result = DS(converted, dtype=int, index=data.index, meta=meta)
1643
+ else:
1644
+ raise
1645
+ except AttributeError:
1646
+ result = DS(converted, index=data.index, meta=meta)
1647
+ self.logger.warning(
1648
+ f"{self.effective_target_type=}, conversion result has dtype {result.dtype}"
1649
+ )
1650
+ if name is None:
1651
+ result = result.rename(self.column_name)
1652
+ else:
1653
+ result = result.rename(name)
1654
+ if as_dataframe:
1655
+ df_data = dict({result.name: result.values}, **self.get_meta())
1656
+ if isinstance(as_dataframe, dict):
1657
+ df_data.update(**as_dataframe)
1658
+ result = DF(df_data, index=result.index)
1659
+ return result
1660
+
1661
+ def convert_dataframe(self, data: pd.DataFrame, **kwargs) -> DF:
1662
+ """Converts a pandas DataFrame."""
1663
+ converted_series = [
1664
+ self.convert_series(column, **kwargs) for _, column in data.items()
1665
+ ]
1666
+ return pd_concat(converted_series, axis=1)
1667
+
1668
+ def convert_index(self, data: pd.Index, **kwargs) -> pd.Index:
1669
+ """Converts a pandas Index.
1670
+
1671
+ Args:
1672
+ data: The pandas Index to convert.
1673
+ **kwargs: Additional arguments for the conversion function.
1674
+
1675
+ Returns:
1676
+ A new pandas Index with converted values.
1677
+ """
1678
+ if data.size == 0:
1679
+ return data.__class__([])
1680
+ if isinstance(data, pd.IntervalIndex):
1681
+ converted_lefts = self.convert_array(data.left, **kwargs)
1682
+ converted_rights = self.convert_array(data.right, **kwargs)
1683
+ return pd.IntervalIndex.from_arrays(
1684
+ converted_lefts, converted_rights, closed=data.closed
1685
+ )
1686
+ converted = self.convert_array(data.values, **kwargs)
1687
+ return data.__class__(converted)
1688
+
1689
+ def _repr_additional_properties(self) -> list[str]:
1690
+ """Provides additional properties for the __repr__ string.
1691
+
1692
+ Returns:
1693
+ A list of formatted property strings.
1694
+ """
1695
+ result = super()._repr_additional_properties()
1696
+ result.extend(
1697
+ [f"source_unit={self.source_unit!r}", f"target_unit={self.target_unit!r}"]
1698
+ )
1699
+ if self._target_type is not None:
1700
+ result.append(f"target_type={self.target_type}")
1701
+ if self._custom_conversion_function:
1702
+ result.append(
1703
+ f"custom_conversion_function={self._custom_conversion_function.__name__}"
1704
+ )
1705
+ return result
1706
+
1707
+
1708
+ class InterpolationMap(CoordinatesMap):
1709
+ _cmap_type: str = "InterpolationMap"
1710
+
1711
+ @classmethod
1712
+ def from_scalar_map(cls, scalar_map: pd.Series, **kwargs) -> Self:
1713
+ """From a Series with a non-overlapping monotonically increasing pd.IntervalIndex mapping
1714
+ coordinate regions to scalars.
1715
+ """
1716
+ converted_segment_lengths = scalar_map.index.length * scalar_map
1717
+ converted_segment_ends = converted_segment_lengths.cumsum()
1718
+ left_breaks = scalar_map.index.left.tolist() + [scalar_map.index[-1].right]
1719
+ right_breaks = [0] + converted_segment_ends.tolist()
1720
+ coordinate_map = pd.Series(
1721
+ right_breaks,
1722
+ index=left_breaks,
1723
+ )
1724
+ return cls(coordinate_map, **kwargs)
1725
+
1726
+ def __init__(
1727
+ self,
1728
+ coordinate_map: pd.Series,
1729
+ kind: utils.InterpolationType | str = "linear",
1730
+ fill_value: Optional[Number] = None,
1731
+ source_unit: Optional[TimeUnit | str] = None,
1732
+ target_type: Optional[Type] = None,
1733
+ target_unit: Optional[str] = None,
1734
+ column_name: Optional[str] = None,
1735
+ conversion_function: Optional[
1736
+ Callable[[Union[Number, np.ndarray]], Union[Number, np.ndarray]]
1737
+ ] = None,
1738
+ id_prefix: str = "cmap",
1739
+ uid: Optional[str] = None,
1740
+ **kwargs,
1741
+ ):
1742
+ """Initializes a ShiftMap.
1743
+
1744
+ Args:
1745
+ offset_source: Offset applied before core conversion (in source units).
1746
+ offset_target: Offset applied after core conversion (in target units).
1747
+ **kwargs: Arguments for the base CoordinatesMap.
1748
+ """
1749
+ super().__init__(
1750
+ source_unit=source_unit,
1751
+ target_type=target_type,
1752
+ target_unit=target_unit,
1753
+ column_name=column_name,
1754
+ conversion_function=conversion_function,
1755
+ id_prefix=id_prefix,
1756
+ uid=uid,
1757
+ **kwargs,
1758
+ )
1759
+ self._coordinate_map = None
1760
+ self.coordinate_map = coordinate_map
1761
+ self._kind = None
1762
+ self.kind = kind
1763
+ self._fill_value = None
1764
+ self.fill_value = fill_value
1765
+
1766
+ @property
1767
+ def coordinate_map(self) -> DS:
1768
+ return self._coordinate_map
1769
+
1770
+ @coordinate_map.setter
1771
+ def coordinate_map(self, coordinate_map: pd.Series):
1772
+ if isinstance(coordinate_map, DS):
1773
+ new_value = coordinate_map
1774
+ else:
1775
+ new_value = DS(coordinate_map)
1776
+ self.validate_coordinate_map(coordinate_map)
1777
+ if self._target_type is None:
1778
+ self._infer_target_type(coordinate_map)
1779
+ else:
1780
+ new_value = new_value.astype(self.target_dtype)
1781
+ self._coordinate_map = new_value
1782
+
1783
+ @property
1784
+ def kind(self) -> utils.InterpolationType:
1785
+ return self._kind
1786
+
1787
+ @kind.setter
1788
+ def kind(self, kind: utils.InterpolationType | str):
1789
+ self._kind = utils.InterpolationType(kind)
1790
+
1791
+ @property
1792
+ def fill_value(self) -> Optional[Number]:
1793
+ return self._fill_value
1794
+
1795
+ @fill_value.setter
1796
+ def fill_value(self, fill_value: Optional[Number]):
1797
+ self._fill_value = fill_value
1798
+
1799
+ def _infer_target_type(self, coordinate_map: pd.Series) -> None:
1800
+ """Infers and sets the :attr:`target_type` based on the coordinate_map.
1801
+
1802
+ Args:
1803
+ coordinate_map: The coordinate_map.
1804
+ """
1805
+ self._target_type = utils.convert_numpy_type_to_python_type(
1806
+ coordinate_map.dtype
1807
+ )
1808
+
1809
+ def validate_coordinate_map(self, coordinate_map):
1810
+ if not coordinate_map.index.is_monotonic_increasing:
1811
+ warnings.warn(
1812
+ f"The coordinate map index of a {self.id} is not monotonically increasing."
1813
+ )
1814
+ if not coordinate_map.is_monotonic_increasing:
1815
+ warnings.warn(
1816
+ f"The coordinate map values of {self.id} are not monotically increasing."
1817
+ )
1818
+
1819
+ def get_interpolator(self, kind=None, fill_value=None):
1820
+ kind_arg = self.kind if kind is None else kind
1821
+ fill_value_arg = self.fill_value if fill_value is None else fill_value
1822
+ return pt_interp1d(
1823
+ x=self.coordinate_map.index.values,
1824
+ y=self.coordinate_map.values,
1825
+ dtype=self.target_dtype,
1826
+ kind=kind_arg,
1827
+ fill_value=fill_value_arg,
1828
+ )
1829
+
1830
+ def default_conversion_function(
1831
+ self, value: Union[Number, np.ndarray], **kwargs
1832
+ ) -> Union[Number, np.ndarray]:
1833
+ """Applies source offset, then applies target offset.
1834
+
1835
+ Args:
1836
+ value: Input value(s).
1837
+ **kwargs: Not used.
1838
+
1839
+ Returns:
1840
+ Converted value(s).
1841
+ """
1842
+ interpolator = self.get_interpolator(**kwargs)
1843
+ if isinstance(value, np.ndarray):
1844
+ if value.dtype == object:
1845
+ if self.target_dtype is None or self.target_dtype == object:
1846
+ value = pd.to_numeric(value).values
1847
+ else:
1848
+ value = value.astype(self.target_dtype)
1849
+ elif self.target_dtype != object and value.dtype != self.target_dtype:
1850
+ value = value.astype(self.target_dtype)
1851
+ return interpolator(value)
1852
+
1853
+
1854
+ class ShiftMap(CoordinatesMap):
1855
+ """A CoordinatesMap that applies source and target offsets. This is useful because a ShiftMap
1856
+ can be combined with other maps and, depending on whether source and/or target offset is
1857
+ defined, coordinates can be either shifted before conversion (source unit) or after conversion
1858
+ (target unit).
1859
+ """
1860
+
1861
+ targets_relative_to_origin: Optional[bool] = False
1862
+ _cmap_category = "ShiftMap"
1863
+
1864
+ @classmethod
1865
+ def from_conversion_map(
1866
+ cls,
1867
+ cmap: CoordinatesMap,
1868
+ offset_source: Optional[Number] = None,
1869
+ offset_target: Optional[Number] = None,
1870
+ **kwargs,
1871
+ ):
1872
+ """Creates a ShiftMap from an existing CoordinatesMap, adding offsets.
1873
+
1874
+ Args:
1875
+ cls: The ShiftMap class.
1876
+ cmap: The base CoordinatesMap.
1877
+ offset_source: Offset to apply to source values.
1878
+ offset_target: Offset to apply to target values.
1879
+ **kwargs: Additional arguments for ShiftMap initialization.
1880
+
1881
+ Returns:
1882
+ A new ShiftMap instance.
1883
+ """
1884
+ init_args = {}
1885
+ if offset_source is None:
1886
+ init_args["offset_source"] = getattr(cmap, "source_unit", None)
1887
+ else:
1888
+ init_args["offset_source"] = offset_source
1889
+ if offset_target is None:
1890
+ init_args["offset_target"] = getattr(cmap, "target_unit", None)
1891
+ else:
1892
+ init_args["offset_target"] = offset_target
1893
+ init_args.update(kwargs)
1894
+ return super().from_conversion_map(cmap, **init_args)
1895
+
1896
+ def __init__(
1897
+ self,
1898
+ offset_source: Optional[Number] = None,
1899
+ offset_target: Optional[Number] = None,
1900
+ source_unit: Optional[TimeUnit | str] = None,
1901
+ target_type: Optional[Type | NumberType | str] = None,
1902
+ target_unit: Optional[str] = None,
1903
+ column_name: Optional[str] = None,
1904
+ conversion_function: Optional[
1905
+ Callable[[Union[Number, np.ndarray]], Union[Number, np.ndarray]]
1906
+ ] = None,
1907
+ id_prefix: str = "cmap",
1908
+ uid: Optional[str] = None,
1909
+ **kwargs,
1910
+ ):
1911
+ """Initializes a ShiftMap.
1912
+
1913
+ Args:
1914
+ offset_source: Offset applied before core conversion (in source units).
1915
+ offset_target: Offset applied after core conversion (in target units).
1916
+ **kwargs: Arguments for the base CoordinatesMap.
1917
+ """
1918
+ super().__init__(
1919
+ source_unit=source_unit,
1920
+ target_type=target_type,
1921
+ target_unit=target_unit,
1922
+ column_name=column_name,
1923
+ conversion_function=conversion_function,
1924
+ id_prefix=id_prefix,
1925
+ uid=uid,
1926
+ **kwargs,
1927
+ )
1928
+ self._offset_target = offset_target
1929
+ self._offset_source = offset_source
1930
+
1931
+ @property
1932
+ def offset_source(self) -> Optional[Number]:
1933
+ """Offset applied to source values before the main conversion logic."""
1934
+ return self._offset_source
1935
+
1936
+ @property
1937
+ def offset_target(self) -> Optional[Number]:
1938
+ """Offset applied to target values after the main conversion logic."""
1939
+ return self._offset_target
1940
+
1941
+ def default_conversion_function(
1942
+ self, value: Union[Number, np.ndarray], **kwargs
1943
+ ) -> Union[Number, np.ndarray]:
1944
+ """Applies source offset, then applies target offset.
1945
+
1946
+ Args:
1947
+ value: Input value(s).
1948
+ **kwargs: Not used.
1949
+
1950
+ Returns:
1951
+ Converted value(s).
1952
+ """
1953
+ if self._offset_source is not None:
1954
+ value = value + self._offset_source
1955
+ if self._offset_target is not None:
1956
+ value = value + self._offset_target
1957
+ return value
1958
+
1959
+ def get_inverse(self) -> Optional["CoordinatesMap"]:
1960
+ """Calculates the inverse of this ShiftMap.
1961
+
1962
+ Returns:
1963
+ An inverse CoordinatesMap instance.
1964
+ """
1965
+ # Ensure source_type is available for the inverse's target_type
1966
+ # This might require a dummy conversion if no conversion has happened yet.
1967
+ if self._source_type is None:
1968
+ self.logger.debug(
1969
+ f"Temporarily inferring source_type for {self.id} to create inverse."
1970
+ )
1971
+ self._infer_source_type(0) # Infer with a dummy value
1972
+
1973
+ cls = self.get_inverse_class()
1974
+ if cls is None:
1975
+ cls = self.__class__
1976
+
1977
+ # Offsets are negated and swapped
1978
+ inv_offset_source = (
1979
+ None if self._offset_target is None else -self._offset_target
1980
+ )
1981
+ inv_offset_target = (
1982
+ None if self._offset_source is None else -self._offset_source
1983
+ )
1984
+
1985
+ return cls(
1986
+ offset_source=inv_offset_source,
1987
+ offset_target=inv_offset_target,
1988
+ source_unit=self.target_unit,
1989
+ target_unit=self.source_unit,
1990
+ target_type=self.source_type,
1991
+ column_name=self._make_inverse_column_name(),
1992
+ id_prefix="imap",
1993
+ )
1994
+
1995
+ def _repr_additional_properties(self):
1996
+ result = super()._repr_additional_properties()
1997
+ result.append(f"{self._offset_source=}, {self._offset_target=}")
1998
+ return result
1999
+
2000
+ def __add__(self, other):
2001
+ if isinstance(other, ShiftMap):
2002
+ offset_source = add_offset_arguments(
2003
+ self._offset_source, other._offset_source
2004
+ )
2005
+ offset_target = add_offset_arguments(
2006
+ self._offset_target, other._offset_target
2007
+ )
2008
+ return self.__class__.from_conversion_map(
2009
+ self, offset_source=offset_source, offset_target=offset_target
2010
+ )
2011
+ return NotImplemented
2012
+
2013
+
2014
+ class LinearMap(ShiftMap):
2015
+ """Base class for all conversions based on multiplication or division with a single scalar.
2016
+ Subclasses will give appropriate variable names to the init arg for usability.
2017
+ """
2018
+
2019
+ targets_relative_to_origin: Optional[bool] = True
2020
+ _cmap_category: str = "LinearMap"
2021
+
2022
+ @classmethod
2023
+ def from_conversion_map(cls, cmap: CoordinatesMap, scalar: Number = None, **kwargs):
2024
+ """Creates a ScalarCoordinatesMap from an existing map, adding/overriding the scalar.
2025
+
2026
+ Args:
2027
+ cls: The ScalarCoordinatesMap class.
2028
+ cmap: The base CoordinatesMap.
2029
+ scalar: The scalar value for conversion.
2030
+ **kwargs: Additional arguments.
2031
+
2032
+ Returns:
2033
+ A new ScalarCoordinatesMap instance.
2034
+ """
2035
+ init_args = {}
2036
+ init_args["scalar"] = (
2037
+ getattr(cmap, "scalar", None) if scalar is None else scalar
2038
+ )
2039
+ init_args.update(kwargs)
2040
+ return super().from_conversion_map(cmap, **init_args)
2041
+
2042
+ def __init__(
2043
+ self,
2044
+ scalar: Number = 1,
2045
+ offset_source: Optional[Number] = None,
2046
+ offset_target: Optional[Number] = None,
2047
+ source_unit: Optional[TimeUnit | str] = None,
2048
+ target_unit: Optional[TimeUnit | str] = None,
2049
+ **kwargs,
2050
+ ):
2051
+ """Initializes a ScalarCoordinatesMap.
2052
+
2053
+ Args:
2054
+ scalar: The scalar value for multiplication/division. Must be positive.
2055
+ offset_source: Source offset.
2056
+ offset_target: Target offset.
2057
+ **kwargs: Arguments for ShiftMap.
2058
+
2059
+ Raises:
2060
+ ValueError: If scalar is not positive.
2061
+ """
2062
+ super().__init__(
2063
+ offset_source=offset_source,
2064
+ offset_target=offset_target,
2065
+ source_unit=source_unit,
2066
+ target_unit=target_unit,
2067
+ **kwargs,
2068
+ )
2069
+ self._scalar = None
2070
+ self.scalar = scalar
2071
+
2072
+ def get_meta(self) -> Meta:
2073
+ """Generates metadata for converted timestamp Series/DataFrames.
2074
+
2075
+ Returns:
2076
+ A Meta object.
2077
+ """
2078
+ return Meta(
2079
+ map_id=self.id,
2080
+ map_type=self.class_name,
2081
+ scalar=self._scalar,
2082
+ source_unit=self.source_unit,
2083
+ target_unit=self.target_unit,
2084
+ column_name=self.column_name,
2085
+ )
2086
+
2087
+ def _validate_scalar(self, scalar: Number):
2088
+ if scalar is None:
2089
+ raise ValueError("scalar cannot be None")
2090
+ if not isinstance(scalar, Number):
2091
+ raise ValueError(f"scalar must be a number, not {type(scalar)}")
2092
+ if scalar == 0:
2093
+ raise ValueError("scalar must be a non-zero number.")
2094
+
2095
+ @property
2096
+ def scalar(self):
2097
+ """The scalar value used for conversion."""
2098
+ return self._scalar
2099
+
2100
+ @scalar.setter
2101
+ def scalar(self, scalar: Number):
2102
+ if isinstance(scalar, Coordinate):
2103
+ scalar = scalar.value
2104
+ if self._target_unit is None:
2105
+ self.target_unit = scalar.unit
2106
+ if self._target_type is None:
2107
+ self.target_type = scalar.number_type
2108
+ self._validate_scalar(scalar)
2109
+ self._scalar = scalar
2110
+
2111
+ def get_inverse(self) -> Optional["CoordinatesMap"]:
2112
+ """Calculates the inverse of this ScalarCoordinatesMap.
2113
+
2114
+ Returns:
2115
+ An inverse CoordinatesMap instance.
2116
+ """
2117
+ cls = self.get_inverse_class()
2118
+ scalar = self._scalar
2119
+ if cls is None:
2120
+ cls = self.__class__
2121
+ scalar = 1 / scalar
2122
+ offset_source = None if self._offset_target is None else -self._offset_target
2123
+ offset_target = None if self._offset_source is None else -self._offset_source
2124
+ return cls(
2125
+ scalar,
2126
+ offset_source=offset_source,
2127
+ offset_target=offset_target,
2128
+ source_unit=self.target_unit,
2129
+ target_unit=self.source_unit,
2130
+ target_type=self._source_type,
2131
+ column_name=self._make_inverse_column_name(),
2132
+ id_prefix="imap",
2133
+ )
2134
+
2135
+ def _repr_additional_properties(self):
2136
+ result = super()._repr_additional_properties()
2137
+ result.append(f"{self._scalar=}")
2138
+ return result
2139
+
2140
+
2141
+ class ScalarMultiplicationMap(LinearMap):
2142
+ """Converts by multiplying by a scalar, with optional offsets."""
2143
+
2144
+ _default_inverse_class = "ScalarDivisionMap"
2145
+
2146
+ def default_conversion_function(
2147
+ self, value: Union[Number, np.ndarray], **kwargs
2148
+ ) -> Union[Number, np.ndarray]:
2149
+ """Applies source offset, multiplies by scalar, then applies target offset.
2150
+
2151
+ Args:
2152
+ value: Input value(s).
2153
+ **kwargs: Not used.
2154
+
2155
+ Returns:
2156
+ Converted value(s).
2157
+ """
2158
+ if self._offset_source is not None:
2159
+ value = value + self._offset_source
2160
+ result = value * self._scalar
2161
+ if self._offset_target is not None:
2162
+ result = result + self._offset_target
2163
+ return result
2164
+
2165
+
2166
+ class ScalarDivisionMap(LinearMap):
2167
+ """Converts by dividing by a scalar, with optional offsets."""
2168
+
2169
+ _default_inverse_class = "ScalarMultiplicationMap"
2170
+
2171
+ def default_conversion_function(
2172
+ self, value: Union[Number, np.ndarray], **kwargs
2173
+ ) -> Union[Number, np.ndarray]:
2174
+ """Applies source offset, divides by scalar, then applies target offset.
2175
+
2176
+ Args:
2177
+ value: Input value(s).
2178
+ **kwargs: Not used.
2179
+
2180
+ Returns:
2181
+ Converted value(s).
2182
+ """
2183
+ if self._offset_source is not None:
2184
+ value = value + self._offset_source
2185
+ result = value / self._scalar
2186
+ if self._offset_target is not None:
2187
+ result = result + self._offset_target
2188
+ return result
2189
+
2190
+
2191
+ class SecondsToMilliseconds(ScalarMultiplicationMap):
2192
+ """Converts seconds to milliseconds."""
2193
+
2194
+ _default_source_unit: TimeUnit = TimeUnit.seconds
2195
+ _default_target_unit: TimeUnit = TimeUnit.milliseconds
2196
+ _default_inverse_class = "MillisecondsToSeconds"
2197
+ targets_relative_to_origin: Optional[bool] = False
2198
+
2199
+ def __init__(self, scalar: Number = 1000, **kwargs):
2200
+ """Initializes map for seconds to milliseconds.
2201
+
2202
+ Args:
2203
+ scalar: Conversion factor (default 1000).
2204
+ **kwargs: Additional arguments.
2205
+ """
2206
+ super().__init__(scalar, **kwargs)
2207
+
2208
+
2209
+ class MillisecondsToSeconds(ScalarDivisionMap):
2210
+ """Converts milliseconds to seconds."""
2211
+
2212
+ _default_source_unit: TimeUnit = TimeUnit.milliseconds
2213
+ _default_target_unit: TimeUnit = TimeUnit.seconds
2214
+ _default_inverse_class = SecondsToMilliseconds
2215
+ targets_relative_to_origin: Optional[bool] = False
2216
+
2217
+ def __init__(self, scalar: Number = 1000, **kwargs):
2218
+ """Initializes map for milliseconds to seconds.
2219
+
2220
+ Args:
2221
+ scalar: Conversion factor (default 1000).
2222
+ **kwargs: Additional arguments.
2223
+ """
2224
+ super().__init__(scalar, **kwargs)
2225
+
2226
+
2227
+ class SecondsToMinutes(ScalarDivisionMap):
2228
+ """Converts seconds to milliseconds."""
2229
+
2230
+ _default_source_unit: TimeUnit = TimeUnit.seconds
2231
+ _default_target_unit: TimeUnit = TimeUnit.minutes
2232
+ _default_inverse_class = "MinutesToSeconds"
2233
+ targets_relative_to_origin: Optional[bool] = False
2234
+
2235
+ def __init__(self, scalar: Number = 60, **kwargs):
2236
+ """Initializes map for seconds to minutes.
2237
+
2238
+ Args:
2239
+ scalar: Conversion factor (default 60).
2240
+ **kwargs: Additional arguments.
2241
+ """
2242
+ super().__init__(scalar, **kwargs)
2243
+
2244
+
2245
+ class MinutesToSeconds(ScalarDivisionMap):
2246
+ """Converts seconds to milliseconds."""
2247
+
2248
+ _default_source_unit: TimeUnit = TimeUnit.minutes
2249
+ _default_target_unit: TimeUnit = TimeUnit.seconds
2250
+ _default_inverse_class = SecondsToMinutes
2251
+ targets_relative_to_origin: Optional[bool] = False
2252
+
2253
+ def __init__(self, scalar: Number = 60, **kwargs):
2254
+ """Initializes map for minutes to seconds.
2255
+
2256
+ Args:
2257
+ scalar: Conversion factor (default 60).
2258
+ **kwargs: Additional arguments.
2259
+ """
2260
+ super().__init__(scalar, **kwargs)
2261
+
2262
+
2263
+ class SamplesToSeconds(ScalarDivisionMap):
2264
+ """Converts audio samples to seconds."""
2265
+
2266
+ _default_source_unit: TimeUnit = TimeUnit.samples
2267
+ _default_target_unit: TimeUnit = TimeUnit.seconds
2268
+ _default_target_type = NumberType.float
2269
+ _default_inverse_class = "SecondsToSamples"
2270
+
2271
+ def __init__(self, sample_rate: float, **kwargs):
2272
+ """Initializes map for samples to seconds.
2273
+
2274
+ Args:
2275
+ sample_rate: The sample rate (samples per second).
2276
+ **kwargs: Additional arguments.
2277
+ """
2278
+ assert "scalar" not in kwargs
2279
+ super().__init__(sample_rate, **kwargs)
2280
+
2281
+ @property
2282
+ def sample_rate(self):
2283
+ """The sample rate used for conversion."""
2284
+ return self._scalar
2285
+
2286
+
2287
+ class SecondsToSamples(ScalarMultiplicationMap):
2288
+ """Converts seconds to audio samples."""
2289
+
2290
+ _default_source_unit: TimeUnit = TimeUnit.seconds
2291
+ _default_target_unit: TimeUnit = TimeUnit.samples
2292
+ _default_target_type = NumberType.int
2293
+ _default_inverse_class = SamplesToSeconds
2294
+
2295
+ def __init__(self, sample_rate: float, **kwargs):
2296
+ """Initializes map for seconds to samples.
2297
+
2298
+ Args:
2299
+ sample_rate: The sample rate (samples per second).
2300
+ **kwargs: Additional arguments.
2301
+ """
2302
+ assert "scalar" not in kwargs
2303
+ super().__init__(sample_rate, **kwargs)
2304
+
2305
+ @property
2306
+ def sample_rate(self):
2307
+ """The sample rate used for conversion."""
2308
+ return self._scalar
2309
+
2310
+
2311
+ # endregion CoordinatesMap
2312
+ # region MultiMap
2313
+
2314
+
2315
+ class _CmapsMixin:
2316
+ """Can be composed with a class to include a self._cmaps dict
2317
+ and methods for managing it. Used in FixedCoordinateTypeObject (and thereby Timeline and Event)
2318
+ as well as MultiMap.
2319
+ """
2320
+
2321
+ def __init__(self, *args, **kwargs):
2322
+ self._cmaps: Dict[
2323
+ tuple[TimeUnit, Optional[NumberType]] | TimeUnit, CoordinatesMap
2324
+ ] = {}
2325
+ super().__init__(*args, **kwargs)
2326
+
2327
+ @property
2328
+ def n_maps(self):
2329
+ return len(self._cmaps)
2330
+
2331
+ def add_conversion_maps(self, *cmaps: ConversionMap[MT]):
2332
+ cmaps = utils.treat_variadic_argument(*cmaps)
2333
+ for cmap in cmaps:
2334
+ self.validate_cmap(cmap)
2335
+ self._cmaps[cmap.id] = self._make_adapted_cmap(cmap)
2336
+ self.logger.debug(
2337
+ f"Added {cmap.class_name} {cmap.id} to {self.class_name} {self.id}"
2338
+ )
2339
+
2340
+ def add_cmaps(self, *cmaps: ConversionMap[MT]):
2341
+ """Alias for :meth:`add_conversion_maps`."""
2342
+ return self.add_conversion_maps(*cmaps)
2343
+
2344
+ def get_conversion_maps(
2345
+ self, target_units: Optional[Iterable[TimeUnit] | TimeUnit] = None
2346
+ ) -> list[ConversionMap]:
2347
+ """Retrieves :class:`ConversionMap` objects for one or several target types.
2348
+
2349
+ Filters added maps and adds default maps from the global registry.
2350
+
2351
+ Args:
2352
+ target_units: The target TimeUnits.
2353
+
2354
+ Returns:
2355
+ A CoordinatesMap instance, or None if no suitable map is found and no custom function provided.
2356
+ """
2357
+ cmaps = {}
2358
+ if target_units is not None:
2359
+ target_units = utils.make_argument_iterable(target_units)
2360
+ for cmap_id, cmap in self._cmaps.items():
2361
+ if target_units is None or cmap.target_unit in target_units:
2362
+ cmaps[cmap_id] = cmap
2363
+ if target_units is not None:
2364
+ # add default cmap if defined
2365
+ for unit in target_units:
2366
+ if (
2367
+ default_cmap := get_cmap(
2368
+ self.unit,
2369
+ unit,
2370
+ )
2371
+ ) is not None and default_cmap.id not in cmaps:
2372
+ cmaps[default_cmap.id] = default_cmap
2373
+ return list(cmaps.values())
2374
+
2375
+ def get_cmaps(
2376
+ self, target_units: Iterable[TimeUnit] | TimeUnit
2377
+ ) -> list[ConversionMap]:
2378
+ """Alias for :meth:`get_conversion_maps`."""
2379
+ return self.get_conversion_maps(target_units=target_units)
2380
+
2381
+ def get_inverse_maps(
2382
+ self, source_units: Optional[Iterable[TimeUnit] | TimeUnit] = None
2383
+ ) -> list[ConversionMap]:
2384
+ """Retrieves inverse :class:`ConversionMap` objects for converting a given source unit to this object's unit.
2385
+
2386
+ Args:
2387
+ source_units: The source TimeUnit of external data.
2388
+
2389
+ Returns:
2390
+ A CoordinatesMap instance, or None.
2391
+ """
2392
+ result = []
2393
+ for cmap in self.get_conversion_maps(target_units=source_units):
2394
+ inverted = cmap.get_inverse()
2395
+ result.append(inverted)
2396
+ return result
2397
+
2398
+ def iter_cmaps(self):
2399
+ yield from self._cmaps.values()
2400
+
2401
+ def validate_cmap(self, cmap: ConversionMap):
2402
+ """Stores a CoordinatesMap for this object which will be automatically included in any
2403
+ timestamps generated from it.
2404
+
2405
+ Args:
2406
+ cmap: The CoordinatesMap to store.
2407
+
2408
+ Raises:
2409
+ ValueError: If the cmap is incompatible with this object's unit.
2410
+ """
2411
+ if not isinstance(cmap, ConversionMap):
2412
+ raise ValueError(
2413
+ f"Cannot add {cmap.__class__.__name__!r}, expected a ConversionMap."
2414
+ )
2415
+
2416
+ def _make_adapted_cmap(self, cmap: CoordinatesMap):
2417
+ """This method may create a cmap based on the one to be added. Typically, this could be
2418
+ an inverted cmap if the one to be added converts into the "wrong direction" ("correct"
2419
+ default: from the object's unit to another one).
2420
+ """
2421
+ return cmap
2422
+
2423
+
2424
+ class MultiMap(_CmapsMixin, ConversionMap[MT], Generic[MT]):
2425
+ """Abstract superclass for all maps that are composed of multiple ConversionMaps."""
2426
+
2427
+ _cmap_category: str = "MultiMap"
2428
+
2429
+ def __init__(
2430
+ self,
2431
+ cmaps: Optional[Iterable[ConversionMap[MT]] | ConversionMap[MT]] = None,
2432
+ target_unit: Optional[str] = None,
2433
+ column_name: Optional[str] = None,
2434
+ conversion_function: Optional[
2435
+ Callable[[Union[Number, np.ndarray]], Union[Number, np.ndarray]]
2436
+ ] = None,
2437
+ id_prefix: str = "cmap",
2438
+ uid: Optional[str] = None,
2439
+ **kwargs,
2440
+ ):
2441
+ super().__init__(
2442
+ target_unit=target_unit,
2443
+ column_name=column_name,
2444
+ conversion_function=conversion_function,
2445
+ id_prefix=id_prefix,
2446
+ uid=uid,
2447
+ **kwargs,
2448
+ )
2449
+ if cmaps is not None:
2450
+ self.add_conversion_maps(*utils.make_argument_iterable(cmaps))
2451
+
2452
+ @property
2453
+ @abstractmethod
2454
+ def target_unit(self) -> tuple[TimeUnit, ...]:
2455
+ """Subclasses need a mechanism to decide the target_unit based on their cmaps.
2456
+ Note that a setter needs to be implemented because TimeUnit.__init__() uses it.
2457
+ """
2458
+ raise NotImplementedError
2459
+
2460
+ def get_inverse_class(self) -> Self:
2461
+ """Creates an inverse MultiMap by combining the inverse maps."""
2462
+ return self.__class__
2463
+
2464
+ def get_inverse(self) -> Self:
2465
+ cls = self.get_inverse_class()
2466
+ if self.n_maps == 0:
2467
+ return cls() # empty MultiMap
2468
+ cmaps = self.get_inverse_maps()
2469
+ return cls(
2470
+ cmaps, column_name=self._make_inverse_column_name(), id_prefix="imap"
2471
+ )
2472
+
2473
+ def default_conversion_function(self, value, kwargs):
2474
+ raise NotImplementedError(
2475
+ "Combination map doesn't have its own conversion function."
2476
+ )
2477
+
2478
+ def _repr_additional_properties(self):
2479
+ result = super()._repr_additional_properties()
2480
+ result.append(f"n_maps={self.n_maps}")
2481
+ return result
2482
+
2483
+
2484
+ # endregion MultiMap
2485
+ # region ConcatenationMap
2486
+
2487
+ # ToDo: MultiScalarCoordinatesMap should be generalized to ConcatenationMap
2488
+ # Right now it is tailored to the special case TicksToSeconds
2489
+
2490
+ CMT = TypeVar("CMT", bound=ConversionMap)
2491
+
2492
+
2493
+ class ConcatenationMap(MultiMap[CMT], Generic[CMT]):
2494
+ """
2495
+ A ConcatenationMap maps instants to :class:`ConversionMap` objects and converts them accordingly.
2496
+ Typically used for creating a cmap for a timeline based on cmaps of its segments, e.g. via a
2497
+ :class:`CmapLine`.
2498
+
2499
+ In essence, a ConcatenationMap consists of a non-overlapping IntervalIndex associated with
2500
+ cmap IDs. By default, gaps in the IntervalIndex result in missing values and mean that no
2501
+ continuous target coordinate system can be computed which is based on a cumulative sum of
2502
+ target values. This can be addressed via interpolation.
2503
+ ToDo: Add functionality to fill gaps based on specially created interpolation maps.
2504
+
2505
+ General case: Heterogeneous conversion maps
2506
+ -------------------------------------------
2507
+
2508
+ This describes the case where different types of ConversionMaps (ScalarMultiplicationMaps,
2509
+ ConstantMaps, ChainMaps, etc.) convert to the same target unit. In other words,
2510
+ they cannot be combined into a simplified cmap and, instead, need to be applied individually
2511
+ for coordinates falling into the respective region(s).
2512
+
2513
+ Since a cmap starting at coordinate c (i.e., which comes from a segment with origin c)
2514
+ assumes coordinates to originate at c for conversion, the respective c deltas need to be
2515
+ subtracted prior to conversion which is achieved by adding a offset_source_map.
2516
+ The converted coordinates then are relative to their respective origins, which is why a
2517
+ offset_target_map needs to be added which is computed as a cumulative sum of the
2518
+ converted offset_source_map (i.e., segment start coordinates). It is required for this
2519
+ computation that the region intervals are non-overlapping and monotonic.
2520
+
2521
+ Special case: ConstantMaps only
2522
+ -------------------------------
2523
+
2524
+ No shifts have to be applied for constant maps. If the region index has gaps, the conversion,
2525
+ appropriately, has the corresponding gaps which can be filled via a forward-, backward-, or
2526
+ value fill.
2527
+
2528
+ Special case: InterpolationMaps only
2529
+ ------------------------------------
2530
+
2531
+ If all cmaps are InterpolationMaps, this can be simplified to a single InterpolationMap
2532
+ at conversion time.
2533
+
2534
+ """
2535
+
2536
+ @classmethod
2537
+ def from_breaks(
2538
+ cls, breaks: Iterable[Number], cmaps: Iterable[ConversionMap], **kwargs
2539
+ ) -> Self:
2540
+ """Creates the intervals for the given cmaps using pd.IntervalIndex.from_breaks().
2541
+ This requires n+1 breaks for n cmaps. E.g.: breaks [0,8,15] => [[0,8), [8,15]).
2542
+ """
2543
+ iix = pd.IntervalIndex.from_breaks(breaks, closed="left")
2544
+ series = pd.Series(cmaps, index=iix, dtype=object)
2545
+ return cls(series, **kwargs)
2546
+
2547
+ @classmethod
2548
+ def from_arrays(
2549
+ cls,
2550
+ left: Iterable[Number],
2551
+ right: Iterable[Number],
2552
+ cmaps: Iterable[ConversionMap],
2553
+ **kwargs,
2554
+ ) -> Self:
2555
+ """Creates the intervals for the given cmaps using pd.IntervalIndex.from_arrays().
2556
+ left, right, and cmaps need to have the same number of elements.
2557
+ """
2558
+ iix = pd.IntervalIndex.from_arrays(left, right, closed="left")
2559
+ series = pd.Series(cmaps, index=iix, dtype=object)
2560
+ return cls(series, **kwargs)
2561
+
2562
+ def __init__(
2563
+ self,
2564
+ cmaps: (
2565
+ pd.Series | dict[pd.Interval | tuple[Coord, Coord], ID_str | ConversionMap]
2566
+ ),
2567
+ target_unit: Optional[str] = None,
2568
+ column_name: Optional[str] = None,
2569
+ conversion_function: Optional[
2570
+ Callable[[Union[Number, np.ndarray]], Union[Number, np.ndarray]]
2571
+ ] = None,
2572
+ id_prefix: str = "cmap",
2573
+ uid: Optional[str] = None,
2574
+ **kwargs,
2575
+ ):
2576
+ """Initializes a ConcatenationMap.
2577
+
2578
+ Args:
2579
+ cmaps:
2580
+ A mapping of left-closed, right-open intervals to :class:`ConversionMap` objects
2581
+ or IDs. Can be
2582
+
2583
+ - a Series with a pd.IntervalIndex and ID values which resolve to cmaps;
2584
+ - a dict where the keys are pd.Intervals or pairs of numbers or coordinates and
2585
+ the values are cmaps or cmap IDs.
2586
+
2587
+ """
2588
+ if isinstance(cmaps, pd.Series):
2589
+ index = treat_intervals_argument(cmaps.index)
2590
+ cmaps_dict = treat_cmaps_argument(cmaps.values)
2591
+ else:
2592
+ index = treat_intervals_argument(cmaps.keys())
2593
+ cmaps_dict = treat_cmaps_argument(cmaps.values())
2594
+ super().__init__(
2595
+ cmaps_dict.values(),
2596
+ target_unit=target_unit,
2597
+ column_name=column_name,
2598
+ conversion_function=conversion_function,
2599
+ id_prefix=id_prefix,
2600
+ uid=uid,
2601
+ **kwargs,
2602
+ )
2603
+ region_data = [
2604
+ dict(
2605
+ id=cmap.id,
2606
+ class_name=cmap.class_name,
2607
+ targets_relative_to_origin=cmap.targets_relative_to_origin,
2608
+ cmap_category=cmap._cmap_category,
2609
+ )
2610
+ for cmap in cmaps_dict.values()
2611
+ ]
2612
+ self._region_map = pd.DataFrame.from_records(
2613
+ region_data, index=index
2614
+ ).sort_index()
2615
+ assert (
2616
+ self._region_map.index.is_non_overlapping_monotonic
2617
+ ), "Intervals need to be non-overlapping and monotonically increasing."
2618
+ self._offset_source_map = None
2619
+ self._offset_target_map = None
2620
+
2621
+ def validate_cmap(self, cmap: MT):
2622
+ """Makes sure all added cmaps have the same target time. For this purpose, the first cmap
2623
+ added is decisive.
2624
+ """
2625
+ if self.target_unit is None:
2626
+ if cmap.target_unit is not None:
2627
+ self.target_unit = cmap.target_unit
2628
+ elif cmap.target_unit is not None:
2629
+ assert cmap.target_unit == self.target_unit, (
2630
+ f"Cannot concatenate {cmap.__class__.__name__} with target type {cmap.target_unit} to "
2631
+ f"a {self.class_name} with target type {self._target_unit}."
2632
+ )
2633
+
2634
+ def _make_adapted_cmap(self, cmap: CoordinatesMap):
2635
+ """This method is called by :meth:`add_conversion_maps` and is responsible for adapting
2636
+ time shifts.
2637
+ """
2638
+ return cmap
2639
+
2640
+ @property
2641
+ def offset_source_map(self):
2642
+ """Series mapping source coordinate ranges to source offsets."""
2643
+ return self._offset_source_map
2644
+
2645
+ @offset_source_map.setter
2646
+ def offset_source_map(self, offset_source_map: pd.Series):
2647
+ self.validate_input_series(offset_source_map, "offset_source_map", self.length)
2648
+ self._offset_source_map = offset_source_map
2649
+
2650
+ @property
2651
+ def offset_target_map(self):
2652
+ """Series mapping source coordinate ranges to target offsets."""
2653
+ return self._offset_target_map
2654
+
2655
+ @offset_target_map.setter
2656
+ def offset_target_map(self, offset_target_map: pd.Series):
2657
+ # Target offset map's length isn't directly tied to source length like scalar/source_offset maps
2658
+ self.validate_input_series(offset_target_map, "offset_target_map")
2659
+ self._offset_target_map = offset_target_map
2660
+
2661
+ @property
2662
+ def target_unit(self) -> tuple[str | TimeUnit, ...]:
2663
+ return self._target_unit
2664
+
2665
+ @target_unit.setter
2666
+ def target_unit(self, target_unit: str):
2667
+ if self.target_unit is not None and target_unit != self.target_unit:
2668
+ raise ValueError(
2669
+ f"The target unit of this {self.class_name} has already been set "
2670
+ f"to {self._target_unit} and cannot be changed to {target_unit}."
2671
+ )
2672
+ self._target_unit = target_unit
2673
+
2674
+ def _convert_selection_with_single_cmap(
2675
+ self, result: pd.Series, cmap: ConversionMap, selection_mask: pd.Series
2676
+ ):
2677
+ result.loc[selection_mask] = cmap(result[selection_mask])
2678
+
2679
+ def _convert_with_heterogeneous_cmaps(
2680
+ self,
2681
+ result=pd.Series,
2682
+ left_unbounded: bool = False,
2683
+ right_unbounded: bool = True,
2684
+ ):
2685
+ """
2686
+ Iterates over concatenated maps and applies each individually to those coordinates that
2687
+ fall into its interval.
2688
+ """
2689
+ for cmap_id, group in self.get_region_map(
2690
+ left_unbounded=left_unbounded, right_unbounded=right_unbounded
2691
+ ).groupby("id"):
2692
+ cmap = self._cmaps[cmap_id]
2693
+ selection_mask = utils.get_boolean_mask_for_intervals(
2694
+ result.index, group.index
2695
+ )
2696
+ self._convert_selection_with_single_cmap(result, cmap, selection_mask)
2697
+ return result
2698
+
2699
+ def default_conversion_function(
2700
+ self,
2701
+ value: Union[Number, np.ndarray],
2702
+ left_unbounded: bool = False,
2703
+ right_unbounded: bool = False,
2704
+ **kwargs,
2705
+ ) -> Union[Number, np.ndarray]:
2706
+ """Applies interval-based offsets and scalar multiplication.
2707
+
2708
+ Args:
2709
+ value: Input value(s).
2710
+ left_unbounded: If True, first interval of maps is left-unbounded.
2711
+ right_unbounded: If True, last interval of maps is right-unbounded.
2712
+ **kwargs: Not used.
2713
+
2714
+ Returns:
2715
+ Converted value(s).
2716
+ """
2717
+ self.logger.debug(
2718
+ f"{self.class_name}.default_conversion_function({left_unbounded=}, {right_unbounded=}, "
2719
+ f"{kwargs=})"
2720
+ )
2721
+ if not self.n_maps:
2722
+ raise ValueError(f"No maps have been added to this {self.class_name}.")
2723
+ region_map = self.get_region_map(
2724
+ left_unbounded=left_unbounded, right_unbounded=right_unbounded
2725
+ )
2726
+ # osm = self.get_offset_source_map(
2727
+ # left_unbounded=left_unbounded,
2728
+ # right_unbounded=right_unbounded
2729
+ # )
2730
+ # if (osm != 0).any():
2731
+ # offset_source = get_values_from_interval_map(osm, value)
2732
+ # result += offset_source
2733
+ cmap_types = region_map.cmap_category.value_counts(dropna=False)
2734
+ if len(cmap_types) == 1:
2735
+ uniform_type = cmap_types.index[0]
2736
+ if uniform_type == "ConstantMap":
2737
+ return self._convert_with_constant_maps(
2738
+ value,
2739
+ left_unbounded=left_unbounded,
2740
+ right_unbounded=right_unbounded,
2741
+ )
2742
+ elif uniform_type == "ShiftMap":
2743
+ return self._convert_with_shift_maps(
2744
+ value,
2745
+ left_unbounded=left_unbounded,
2746
+ right_unbounded=right_unbounded,
2747
+ )
2748
+ elif uniform_type == "LinearMap":
2749
+ return self._convert_with_linear_maps(
2750
+ value,
2751
+ left_unbounded=left_unbounded,
2752
+ right_unbounded=right_unbounded,
2753
+ )
2754
+ else:
2755
+ raise NotImplementedError(
2756
+ f"Don't know how to concatenate {uniform_type}s :(("
2757
+ )
2758
+ else:
2759
+ self._convert_with_heterogeneous_cmaps(
2760
+ value, left_unbounded=left_unbounded, right_unbounded=right_unbounded
2761
+ )
2762
+
2763
+ def _convert_with_linear_maps(
2764
+ self,
2765
+ result: pd.Series,
2766
+ left_unbounded: bool = False,
2767
+ right_unbounded: bool = False,
2768
+ ):
2769
+ cmap2scalar = {}
2770
+ for cmap in self.iter_cmaps():
2771
+ if isinstance(cmap, ScalarMultiplicationMap):
2772
+ cmap2scalar[cmap.id] = cmap.scalar
2773
+ elif isinstance(cmap, ScalarDivisionMap):
2774
+ cmap2scalar[cmap.id] = 1 / cmap.scalar
2775
+ scalar_map = self._region_map["id"].map(cmap2scalar)
2776
+ if left_unbounded or right_unbounded:
2777
+ fill_value = "extrapolate"
2778
+ if not left_unbounded or not right_unbounded:
2779
+ warnings.warn(
2780
+ "For InterpolationMaps, extrapolation is always allowed for left and right."
2781
+ )
2782
+ else:
2783
+ fill_value = np.nan
2784
+ imap = InterpolationMap.from_scalar_map(scalar_map, fill_value=fill_value)
2785
+ return imap(result)
2786
+
2787
+ def _convert_with_shift_maps(
2788
+ self,
2789
+ result: pd.Series,
2790
+ left_unbounded: bool = False,
2791
+ right_unbounded: bool = False,
2792
+ ):
2793
+ source_offsets = {
2794
+ cmap.id: 0 if not cmap.offset_source else cmap.offset_source
2795
+ for cmap in self.iter_cmaps()
2796
+ }
2797
+ target_offsets = {
2798
+ cmap.id: 0 if not cmap.offset_target else cmap.offset_target
2799
+ for cmap in self.iter_cmaps()
2800
+ }
2801
+ region_map = self.get_region_map(
2802
+ left_unbounded=left_unbounded, right_unbounded=right_unbounded
2803
+ )
2804
+ source_shifts = region_map["id"].map(source_offsets)
2805
+ target_shifts = region_map["id"].map(target_offsets)
2806
+ return (
2807
+ result
2808
+ + get_values_from_interval_map(source_shifts, result)
2809
+ + get_values_from_interval_map(target_shifts, result)
2810
+ )
2811
+
2812
+ def _convert_with_constant_maps(
2813
+ self,
2814
+ result: pd.Series,
2815
+ left_unbounded: bool = False,
2816
+ right_unbounded: bool = False,
2817
+ ):
2818
+ constants = {cmap.id: cmap.constant for cmap in self.iter_cmaps()}
2819
+ region_map = self.get_region_map(
2820
+ left_unbounded=left_unbounded, right_unbounded=right_unbounded
2821
+ )
2822
+ constant_map = region_map["id"].map(constants)
2823
+ return get_values_from_interval_map(constant_map, result)
2824
+
2825
+ # def get_inverse(self) -> Self:
2826
+ # """Cannot currently compute inverse for ConcatenationMaps."""
2827
+ # raise NotImplementedError(
2828
+ # "Cannot currently compute inverse for ConcatenationMaps."
2829
+ # )
2830
+
2831
+ def get_offset_source_map(
2832
+ self, left_unbounded: bool = False, right_unbounded: bool = True
2833
+ ) -> Optional[pd.Series]:
2834
+ """Retrieves the source offset map, optionally with unbounded intervals.
2835
+
2836
+ Args:
2837
+ left_unbounded: If True, makes the first interval left-unbounded.
2838
+ right_unbounded: If True, makes the last interval right-unbounded.
2839
+
2840
+ Returns:
2841
+ The source offset map Series, or None.
2842
+ """
2843
+ if self._offset_source_map is not None:
2844
+ raise NotImplementedError(
2845
+ "Cannot currently combin the default shifts with a given one."
2846
+ )
2847
+ if len(self._region_map) == 0:
2848
+ return pd.Series([], index=pd.IntervalIndex([]))
2849
+ region_intervals = self._region_map.index
2850
+ region_starts = pd.Series(region_intervals.left, index=region_intervals)
2851
+ region_starts = region_starts.where(
2852
+ self._region_map.targets_relative_to_origin, 0
2853
+ )
2854
+ shifts = pd.Series(-region_starts.cumsum(), index=region_intervals)
2855
+ if not (right_unbounded or left_unbounded):
2856
+ return shifts
2857
+ return utils.replace_interval_index_with_unbounded_one(
2858
+ shifts, left_unbounded=left_unbounded, right_unbounded=right_unbounded
2859
+ )
2860
+
2861
+ def get_offset_target_map(
2862
+ self, left_unbounded: bool = False, right_unbounded: bool = True
2863
+ ) -> Optional[pd.Series]:
2864
+ """Retrieves the target offset map, optionally with unbounded intervals.
2865
+
2866
+ Args:
2867
+ left_unbounded: If True, makes the first interval left-unbounded.
2868
+ right_unbounded: If True, makes the last interval right-unbounded.
2869
+
2870
+ Returns:
2871
+ The target offset map Series, or None.
2872
+ """
2873
+ otm = self._offset_target_map
2874
+ if otm is None or not (right_unbounded or left_unbounded):
2875
+ return otm
2876
+ return utils.replace_interval_index_with_unbounded_one(
2877
+ otm, left_unbounded=left_unbounded, right_unbounded=right_unbounded
2878
+ )
2879
+
2880
+ @cache
2881
+ def get_region_map(
2882
+ self, left_unbounded: bool = False, right_unbounded: bool = False
2883
+ ) -> Optional[pd.Series]:
2884
+ """Retrieves the region map mapping intervals to converison maps,
2885
+ optionally with unbounded intervals to convert any value, even out of bounds.
2886
+
2887
+ Args:
2888
+ left_unbounded: If True, makes the first interval left-unbounded.
2889
+ right_unbounded: If True, makes the last interval right-unbounded.
2890
+
2891
+ Returns:
2892
+ The target offset map Series, or None.
2893
+ """
2894
+ region_map = self._region_map
2895
+ if region_map is None or not (right_unbounded or left_unbounded):
2896
+ return region_map
2897
+ return utils.replace_interval_index_with_unbounded_one(
2898
+ region_map, left_unbounded=left_unbounded, right_unbounded=right_unbounded
2899
+ )
2900
+
2901
+ def validate_input_series(
2902
+ self, s_map: pd.Series, arg_name: str, target_length: Optional[Number] = None
2903
+ ):
2904
+ """Validates an input Series for use in this map.
2905
+
2906
+ Args:
2907
+ s_map: The Series to validate.
2908
+ arg_name: Name of the argument for error messages.
2909
+ target_length: Expected maximum right boundary of the IntervalIndex.
2910
+
2911
+ Raises:
2912
+ ValueError: If Series is empty.
2913
+ AssertionError: If index is not IntervalIndex, not non-overlapping/monotonic, or length mismatches.
2914
+ """
2915
+ if s_map.size == 0:
2916
+ raise ValueError(f"{arg_name} cannot be empty")
2917
+ assert isinstance(
2918
+ s_map.index, pd.IntervalIndex
2919
+ ), f"The index of a {arg_name} needs to be an IntervalIndex."
2920
+ assert s_map.index.is_non_overlapping_monotonic, (
2921
+ f"The IntervalIndex of a {arg_name} needs to be "
2922
+ f"non-overlapping and monotonic."
2923
+ )
2924
+ if target_length is not None:
2925
+ this_length = s_map.index.right.max()
2926
+ assert this_length == target_length, (
2927
+ f"The index of the {arg_name} has length {this_length} "
2928
+ f"{self.source_unit} but should have {target_length}, "
2929
+ f"like the scalar_map."
2930
+ )
2931
+
2932
+
2933
+ class MultiScalarCoordinatesMap(RegionMap, CoordinatesMap):
2934
+ """Converts using different scalars for different coordinate regions.
2935
+
2936
+ The conversion is defined by a pandas Series with an IntervalIndex mapping
2937
+ coordinate ranges (in source units) to scalar values.
2938
+ """
2939
+
2940
+ _default_target_type = NumberType.float
2941
+
2942
+ def __init__(
2943
+ self,
2944
+ scalar_map: pd.Series,
2945
+ offset_source_map: Optional[pd.Series] = None,
2946
+ offset_target_map: Optional[pd.Series] = None,
2947
+ **kwargs,
2948
+ ):
2949
+ """Initializes a MultiScalarCoordinatesMap.
2950
+
2951
+ Args:
2952
+ scalar_map: Series with IntervalIndex mapping source ranges to scalars.
2953
+ offset_source_map: Optional Series mapping source ranges to source offsets.
2954
+ offset_target_map: Optional Series mapping source ranges to target offsets.
2955
+ **kwargs: Arguments for CoordinatesMap.
2956
+ """
2957
+ super().__init__(
2958
+ region_map=scalar_map,
2959
+ offset_source_map=offset_source_map,
2960
+ offset_target_map=offset_target_map,
2961
+ **kwargs,
2962
+ )
2963
+
2964
+ @property
2965
+ def scalar_map(self):
2966
+ """Series with IntervalIndex mapping source ranges to scalar values."""
2967
+ return self._region_map
2968
+
2969
+ @scalar_map.setter
2970
+ def scalar_map(self, scalar_map: pd.Series):
2971
+ self.validate_input_series(scalar_map, "scalar_map")
2972
+ self._region_map = scalar_map
2973
+ self.length = scalar_map.index.right.max()
2974
+
2975
+ def get_inverse(self) -> Optional["CoordinatesMap"]:
2976
+ """Calculates the inverse of this MultiScalarCoordinatesMap.
2977
+
2978
+ Returns:
2979
+ An inverse MultiScalarCoordinatesMap instance.
2980
+ """
2981
+ cls = self.get_inverse_class()
2982
+ scalar_map_values = self._region_map.values
2983
+ if cls is None:
2984
+ cls = self.__class__
2985
+ scalar_map_values = 1 / scalar_map_values
2986
+ left_values = self._region_map.index.left
2987
+ right_values = self._region_map.index.right.values
2988
+ converted_lefts = self.convert_array(left_values)
2989
+ converted_rights = self.convert(right_values, right_unbounded=True)
2990
+ converted_index = pd.IntervalIndex.from_arrays(
2991
+ converted_lefts, converted_rights, closed="left"
2992
+ )
2993
+ inverse_map = pd.Series(scalar_map_values, index=converted_index)
2994
+ if self._offset_source_map is None:
2995
+ offset_target_map = None
2996
+ else:
2997
+ offset_target_map = pd.Series(
2998
+ -self._offset_source_map.values, index=converted_index
2999
+ )
3000
+ if self._offset_target_map is None:
3001
+ offset_source_map = None
3002
+ else:
3003
+ offset_source_map = pd.Series(
3004
+ -self._offset_target_map.values, index=converted_index
3005
+ )
3006
+ return cls(
3007
+ inverse_map,
3008
+ offset_source_map=offset_source_map,
3009
+ offset_target_map=offset_target_map,
3010
+ source_unit=self.target_unit,
3011
+ target_unit=self.source_unit,
3012
+ target_type=self.source_type,
3013
+ column_name=self._make_inverse_column_name(),
3014
+ id_prefix="imap",
3015
+ )
3016
+
3017
+
3018
+ def get_values_from_interval_map(
3019
+ intv_map: pd.Series, values: Union[Number, np.ndarray]
3020
+ ) -> Union[Number, np.ndarray]:
3021
+ """Retrieves values from an interval-indexed Series corresponding to input values.
3022
+
3023
+ Args:
3024
+ intv_map: The interval-indexed Series.
3025
+ values: A scalar or array of values to look up.
3026
+
3027
+ Returns:
3028
+ Corresponding scalar or array of values from the intv_map.
3029
+ """
3030
+ result = intv_map.loc[values]
3031
+ return result if is_scalar(result) else result.values
3032
+
3033
+
3034
+ class MultiScalarMultiplicationMap(MultiScalarCoordinatesMap):
3035
+ """MultiScalarCoordinatesMap that multiplies by scalars."""
3036
+
3037
+ _default_inverse_class = "MultiScalarDivisionMap"
3038
+
3039
+ def default_conversion_function(
3040
+ self,
3041
+ value: Union[Number, np.ndarray],
3042
+ left_unbounded: bool = False,
3043
+ right_unbounded: bool = True,
3044
+ **kwargs,
3045
+ ) -> Union[Number, np.ndarray]:
3046
+ """Applies interval-based offsets and scalar multiplication.
3047
+
3048
+ Args:
3049
+ value: Input value(s).
3050
+ left_unbounded: If True, first interval of maps is left-unbounded.
3051
+ right_unbounded: If True, last interval of maps is right-unbounded.
3052
+ **kwargs: Not used.
3053
+
3054
+ Returns:
3055
+ Converted value(s).
3056
+ """
3057
+ self.logger.debug(
3058
+ f"{self.class_name}.default_conversion_function({left_unbounded=}, {right_unbounded=}, "
3059
+ f"{kwargs=})"
3060
+ )
3061
+ converted_value = value
3062
+ osm = self.get_offset_source_map(
3063
+ left_unbounded=left_unbounded, right_unbounded=right_unbounded
3064
+ )
3065
+ if osm is not None:
3066
+ offset_source = get_values_from_interval_map(osm, value)
3067
+ converted_value = converted_value + offset_source
3068
+ sc = self.get_region_map(
3069
+ left_unbounded=left_unbounded, right_unbounded=right_unbounded
3070
+ )
3071
+ scalars = get_values_from_interval_map(sc, value)
3072
+ converted_value = converted_value * scalars
3073
+ otm = self.get_offset_target_map(
3074
+ left_unbounded=left_unbounded, right_unbounded=right_unbounded
3075
+ )
3076
+ if otm is not None:
3077
+ offset_target = get_values_from_interval_map(otm, value)
3078
+ converted_value = converted_value + offset_target
3079
+ if self.target_type == NumberType.int:
3080
+ converted_value = np.round(converted_value)
3081
+ return converted_value
3082
+
3083
+
3084
+ class MultiScalarDivisionMap(MultiScalarCoordinatesMap):
3085
+ """MultiScalarCoordinatesMap that divides by scalars."""
3086
+
3087
+ _default_inverse_class = "MultiScalarMultiplicationMap"
3088
+
3089
+ def default_conversion_function(
3090
+ self,
3091
+ value: Union[Number, np.ndarray],
3092
+ left_unbounded: bool = False,
3093
+ right_unbounded: bool = True,
3094
+ **kwargs,
3095
+ ) -> Union[Number, np.ndarray]:
3096
+ """Applies interval-based offsets and scalar division.
3097
+
3098
+ Args:
3099
+ value: Input value(s).
3100
+ left_unbounded: If True, first interval of maps is left-unbounded.
3101
+ right_unbounded: If True, last interval of maps is right-unbounded.
3102
+ **kwargs: Not used.
3103
+
3104
+ Returns:
3105
+ Converted value(s).
3106
+ """
3107
+ converted_value = value
3108
+
3109
+ osm = self.get_offset_source_map(
3110
+ left_unbounded=left_unbounded, right_unbounded=right_unbounded
3111
+ )
3112
+ if osm is not None:
3113
+ offset_source = get_values_from_interval_map(osm, value)
3114
+ converted_value = converted_value + offset_source
3115
+ sc = self.get_region_map(
3116
+ left_unbounded=left_unbounded, right_unbounded=right_unbounded
3117
+ )
3118
+ scalars = get_values_from_interval_map(sc, value)
3119
+ converted_value = converted_value / scalars
3120
+ otm = self.get_offset_target_map(
3121
+ left_unbounded=left_unbounded, right_unbounded=right_unbounded
3122
+ )
3123
+ if otm is not None:
3124
+ offset_target = get_values_from_interval_map(otm, value)
3125
+ converted_value = converted_value + offset_target
3126
+ if self.target_type == NumberType.int:
3127
+ converted_value = np.round(converted_value)
3128
+ return converted_value
3129
+
3130
+
3131
+ class TicksToSeconds(MultiScalarMultiplicationMap):
3132
+ """Converts musical ticks to seconds using a tempo map."""
3133
+
3134
+ _default_source_unit: TimeUnit = TimeUnit.ticks
3135
+ _default_target_unit: TimeUnit = TimeUnit.seconds
3136
+ _default_target_type = NumberType.float
3137
+ _default_inverse_class = "SecondsToTicks"
3138
+
3139
+ def __init__(
3140
+ self,
3141
+ seconds_per_tick_map: pd.Series,
3142
+ offset_source_map: Optional[pd.Series] = None,
3143
+ offset_target_map: Optional[pd.Series] = None,
3144
+ **kwargs,
3145
+ ):
3146
+ """Initializes TicksToSeconds map.
3147
+
3148
+ Args:
3149
+ seconds_per_tick_map: Series mapping tick intervals to seconds-per-tick scalars.
3150
+ offset_source_map: Optional source offset map.
3151
+ offset_target_map: Optional target offset map.
3152
+ **kwargs: Additional arguments.
3153
+ """
3154
+ super().__init__(
3155
+ scalar_map=seconds_per_tick_map,
3156
+ offset_source_map=offset_source_map,
3157
+ offset_target_map=offset_target_map,
3158
+ **kwargs,
3159
+ )
3160
+
3161
+
3162
+ class SecondsToTicks(MultiScalarDivisionMap):
3163
+ """Converts seconds to musical ticks using a tempo map."""
3164
+
3165
+ _default_source_unit: TimeUnit = TimeUnit.seconds
3166
+ _default_target_unit: TimeUnit = TimeUnit.ticks
3167
+ _default_target_type = NumberType.int
3168
+ _default_inverse_class = TicksToSeconds
3169
+
3170
+ def __init__(
3171
+ self,
3172
+ seconds_per_tick_map: pd.Series, # This map is still seconds_per_tick for division
3173
+ offset_source_map: Optional[pd.Series] = None,
3174
+ offset_target_map: Optional[pd.Series] = None,
3175
+ **kwargs,
3176
+ ):
3177
+ """Initializes SecondsToTicks map.
3178
+
3179
+ Args:
3180
+ seconds_per_tick_map: Series mapping second intervals to seconds-per-tick scalars (for division).
3181
+ offset_source_map: Optional source offset map.
3182
+ offset_target_map: Optional target offset map.
3183
+ **kwargs: Additional arguments.
3184
+ """
3185
+ super().__init__(
3186
+ scalar_map=seconds_per_tick_map,
3187
+ offset_source_map=offset_source_map,
3188
+ offset_target_map=offset_target_map,
3189
+ **kwargs,
3190
+ )
3191
+
3192
+
3193
+ def seconds_per_tick_scalar_map_from_midi_tempos(
3194
+ tempo_change_ticks: pd.Series,
3195
+ midi_tempos: pd.Series,
3196
+ right_boundary: int,
3197
+ ticks_per_quarter: int,
3198
+ ) -> pd.Series:
3199
+ """Creates a scalar map (seconds per tick) from MIDI tempo changes.
3200
+
3201
+ Args:
3202
+ tempo_change_ticks: Series of tick instants where tempo changes occur.
3203
+ midi_tempos: Series of MIDI tempo values (microseconds per quarter note) at those instants.
3204
+ right_boundary: The final tick instant to define the last interval.
3205
+ ticks_per_quarter: The number of ticks per quarter note for the MIDI file.
3206
+
3207
+ Returns:
3208
+ A pandas Series with an IntervalIndex (in ticks) mapping to seconds-per-tick values.
3209
+ """
3210
+ change_instants_shifted = tempo_change_ticks.shift(
3211
+ -1, fill_value=right_boundary
3212
+ ).astype(int)
3213
+ iix = pd.IntervalIndex.from_arrays(
3214
+ tempo_change_ticks, change_instants_shifted, closed="left"
3215
+ )
3216
+ micro_s_per_quarter = DS(midi_tempos, index=iix)
3217
+ micro_s_per_tick = micro_s_per_quarter / ticks_per_quarter
3218
+ scalar_map = (micro_s_per_tick / 1000000).rename("seconds_per_tick")
3219
+ return scalar_map
3220
+
3221
+
3222
+ def seconds_per_tick_scalar_map_from_midi_df(midi_df: pd.DataFrame) -> pd.Series:
3223
+ """Extracts tempo information from a MIDI DataFrame to create a seconds-per-tick scalar map.
3224
+
3225
+ Args:
3226
+ midi_df: DataFrame created from a MIDI file (e.g., by `midi_to_df`).
3227
+
3228
+ Returns:
3229
+ A pandas Series with an IntervalIndex (in ticks) mapping to seconds-per-tick values.
3230
+ """
3231
+ ends = midi_df.absolute_time.where(
3232
+ midi_df.duration.isna(), midi_df.absolute_time + midi_df.duration
3233
+ )
3234
+ right_boundary = ends.max()
3235
+ tempo_changes = midi_df[midi_df.type == "set_tempo"]
3236
+ ticks_per_quarter = midi_df.get_meta("ticks_per_beat")
3237
+ tempo_change_instants = tempo_changes.absolute_time
3238
+ midi_tempos = tempo_changes.tempo.rename("micro_s_per_quarter")
3239
+ scalar_map = seconds_per_tick_scalar_map_from_midi_tempos(
3240
+ tempo_change_instants, midi_tempos, right_boundary, ticks_per_quarter
3241
+ )
3242
+ return scalar_map
3243
+
3244
+
3245
+ def make_ticks2seconds_map(scalar_map: pd.Series) -> TicksToSeconds:
3246
+ """Creates a TicksToSeconds map with appropriate offsets from a seconds-per-tick scalar map.
3247
+
3248
+ This function calculates cumulative target offsets (in seconds) based on the duration
3249
+ of each tick-based interval when converted to seconds. It also sets source offsets
3250
+ to align the start of each tick interval to zero for the scalar multiplication.
3251
+
3252
+ Args:
3253
+ scalar_map: A Series with IntervalIndex (in ticks) mapping to seconds-per-tick values.
3254
+
3255
+ Returns:
3256
+ A TicksToSeconds map instance.
3257
+ """
3258
+ Result = TicksToSeconds(scalar_map)
3259
+ tempo_segments_ticks = scalar_map.index.length
3260
+ tempo_segments_seconds = scalar_map * tempo_segments_ticks
3261
+ segment_durations_cumulative = tempo_segments_seconds.cumsum()
3262
+ Result.offset_target_map = segment_durations_cumulative.shift(fill_value=0)
3263
+ segment_start_ticks = scalar_map.index.left
3264
+ Result.offset_source_map = pd.Series(-segment_start_ticks, index=scalar_map.index)
3265
+ return Result
3266
+
3267
+
3268
+ def ticks2seconds_map_from_midi_df(midi_df: pd.DataFrame) -> TicksToSeconds:
3269
+ """Creates a TicksToSeconds map directly from a MIDI DataFrame.
3270
+
3271
+ Args:
3272
+ midi_df: DataFrame created from a MIDI file.
3273
+
3274
+ Returns:
3275
+ A TicksToSeconds map instance.
3276
+ """
3277
+ scalar_map = seconds_per_tick_scalar_map_from_midi_df(midi_df)
3278
+ return make_ticks2seconds_map(scalar_map)
3279
+
3280
+
3281
+ # endregion ConcatenationMap
3282
+ # region CombinationMap
3283
+
3284
+
3285
+ class CombinationMap(MultiMap):
3286
+ """Stores one or more :class:`SegmentMap` objects allowing to perform
3287
+ multiple conversions at once using a single object/id."""
3288
+
3289
+ @property
3290
+ def source_unit(self) -> Optional[TimeUnit]:
3291
+ if not self.n_maps:
3292
+ return None
3293
+ units = set(
3294
+ su for cmap in self.iter_cmaps() if (su := cmap.source_unit) is not None
3295
+ )
3296
+ if len(units) > 1:
3297
+ raise ValueError(
3298
+ f"The maps include more than one source units which is not possible: {units}"
3299
+ )
3300
+ return units.pop() if units else None
3301
+
3302
+ @property
3303
+ def target_unit(self) -> tuple[TimeUnit, ...]:
3304
+ if self._target_unit is None:
3305
+ return tuple(cmap.target_unit for cmap in self.iter_cmaps())
3306
+ return self._target_unit
3307
+
3308
+ @target_unit.setter
3309
+ def target_unit(self, target_unit: tuple[str, ...]):
3310
+ if target_unit is None:
3311
+ self._target_unit = None
3312
+ return
3313
+ assert (
3314
+ len(target_unit) == self.n_maps
3315
+ ), f"Cannot set {len(target_unit)} units for {self.n_maps} combined maps."
3316
+ # ToDo: more sophisticated mechanism for combining combined maps also
3317
+ self._target_unit = target_unit
3318
+
3319
+ def validate_cmap(self, cmap):
3320
+ su = self.source_unit
3321
+ if (
3322
+ su is not None and cmap.source_unit is not None and cmap.source_unit != su
3323
+ ): # computed based on all
3324
+ # current cmaps
3325
+ raise ValueError(
3326
+ f"Cannot combine {cmap.__class__.__name__} with source unit {cmap.source_unit!r} with "
3327
+ f"a {self.class_name} with source unit {self.source_unit!r}."
3328
+ )
3329
+
3330
+ def convert_number(self, number: Number, *args, **kwargs) -> tuple[Number, ...]:
3331
+ return tuple(
3332
+ cmap.convert(number, *args, **kwargs) for cmap in self.iter_cmaps()
3333
+ )
3334
+
3335
+ def convert_array(self, data: np.ndarray, *args, **kwargs) -> np.ndarray:
3336
+ fields = np.dtype(
3337
+ [field for cmap in self.iter_cmaps() for field in cmap.target_fields]
3338
+ )
3339
+ conversions = [
3340
+ cmap._convert_array(data, *args, **kwargs) for cmap in self.iter_cmaps()
3341
+ ]
3342
+ # return np.column_stack(conversions)
3343
+ structured_array = np.array(list(zip(*conversions)), dtype=fields)
3344
+ return structured_array
3345
+
3346
+ def convert_series(self, data: pd.Series, **kwargs) -> DF:
3347
+ """Converts a pandas Series.
3348
+
3349
+ Args:
3350
+ data: The pandas Series to convert.
3351
+ name: Optional name for the resulting Series.
3352
+ as_dataframe: If True, returns a DataFrame. If a dict, adds columns from dict.
3353
+ **kwargs: Additional arguments for the conversion function.
3354
+
3355
+ Returns:
3356
+ A converted DataFrame.
3357
+ """
3358
+ meta = self.get_meta()
3359
+ if data.size == 0:
3360
+ return DF([], meta=meta)
3361
+ converted = [cmap.convert_series(data, **kwargs) for cmap in self.iter_cmaps()]
3362
+ if len(converted) == 1:
3363
+ return DF(converted[0].to_frame(), meta=meta)
3364
+ return pd_concat(converted, axis=1, meta=meta)
3365
+
3366
+ def convert_index(self, data: pd.Index, **kwargs) -> pd.MultiIndex:
3367
+ """Converts a pandas Index.
3368
+
3369
+ Args:
3370
+ data: The pandas Series to convert.
3371
+ **kwargs: Additional arguments for the conversion function.
3372
+
3373
+ Returns:
3374
+ A converted MultiIndex, i.e., an index with multiple levels.
3375
+ """
3376
+ if data.size == 0:
3377
+ return pd.MultiIndex([])
3378
+ converted = self.convert_series(data)
3379
+ return pd.MultiIndex.from_frame(converted)
3380
+
3381
+ def get_meta(self) -> Meta:
3382
+ """Generates metadata for converted timestamp Series/DataFrames.
3383
+
3384
+ Returns:
3385
+ A Meta object.
3386
+ """
3387
+ return Meta(
3388
+ map_id=self.id,
3389
+ map_type=self.class_name,
3390
+ combined_maps=[cmap_id for cmap_id in self._cmaps.keys()],
3391
+ source_unit=self.source_unit,
3392
+ target_unit=self.target_unit,
3393
+ column_name=self.column_name,
3394
+ )
3395
+
3396
+
3397
+ def _resolve_number_or_tuple(
3398
+ x: tuple[Coordinate | Number] | Coordinate | Number,
3399
+ ) -> tuple[Number] | Number:
3400
+ if isinstance(x, Number):
3401
+ return x
3402
+ if isinstance(x, Coordinate):
3403
+ return x.value
3404
+ if len(x) != 2:
3405
+ raise ValueError(
3406
+ f"Expected either a number or a pair thereof, "
3407
+ f"got a {type(x)} of length ({len(x)})"
3408
+ )
3409
+ x0, x1 = x
3410
+ x0, x1 = get_coordinate_value(x0), get_coordinate_value(x1)
3411
+ if x0 == x1:
3412
+ return x0
3413
+ return x0, x1
3414
+
3415
+
3416
+ class StraightLineMap(CombinationMap):
3417
+ """This :class:`CombinationMap` is used to convert a graphical axis to (x, y) coordinates in
3418
+ a raster graphic.
3419
+ """
3420
+
3421
+ @classmethod
3422
+ def from_coordinates(
3423
+ cls,
3424
+ x: tuple[Coordinate | Number, Coordinate | Number] | Coordinate | Number,
3425
+ y: tuple[Coordinate | Number, Coordinate | Number] | Coordinate | Number,
3426
+ **kwargs,
3427
+ ):
3428
+ """Creates a mapping from a DiscreteGraphicalTimeline to (x,y) coordinates.
3429
+ As of now, this needs to be either horizontal (single y-value with (x0, x1) pair)
3430
+ or vertical (single x-value with (y0, y1) pair). The order in which x0, x1
3431
+ (or y0, y1) are given determines the line's orientation:
3432
+
3433
+ * x0 < x1: horizontal left to right
3434
+ * x0 > x1: horizontal right to left
3435
+ * y0 < y1: vertical top-down
3436
+ * y1 > y0: vertical bottom-up
3437
+
3438
+ This implies that the origin (x, y) = (0, 0) is understood to be the upper left corner.
3439
+ """
3440
+ x = _resolve_number_or_tuple(x)
3441
+ y = _resolve_number_or_tuple(y)
3442
+ is_tuple = [isinstance(x, tuple), isinstance(y, tuple)]
3443
+ if all(is_tuple):
3444
+ raise NotImplementedError("As of now, only one of x, y can be a pair.")
3445
+ if not any(is_tuple):
3446
+ raise ValueError("Exactly one of x, y needs to be a pair.")
3447
+ if is_tuple[0]:
3448
+ orientation = "horizontal"
3449
+ start, end = x
3450
+ const = y
3451
+ else:
3452
+ orientation = "vertical"
3453
+ start, end = y
3454
+ const = x
3455
+ scalar = np.sign(end - start)
3456
+
3457
+ SLM = cls(**kwargs)
3458
+ x_col, y_col = "x", "y"
3459
+ sc_col, cnst_col = (
3460
+ (x_col, y_col) if orientation == "horizontal" else (y_col, x_col)
3461
+ )
3462
+ scalar_map = ScalarMultiplicationMap(
3463
+ scalar=scalar,
3464
+ offset_source=start,
3465
+ source_unit=TimeUnit.px,
3466
+ target_unit=TimeUnit.px,
3467
+ column_name=sc_col,
3468
+ )
3469
+ constant_map = ConstantMap(
3470
+ constant=const,
3471
+ source_unit=TimeUnit.px,
3472
+ target_unit=TimeUnit.px,
3473
+ column_name=cnst_col,
3474
+ )
3475
+ if orientation == "horizontal":
3476
+ SLM.add_conversion_maps(scalar_map, constant_map)
3477
+ else:
3478
+ SLM.add_conversion_maps(constant_map, scalar_map)
3479
+ return SLM
3480
+
3481
+
3482
+ # endregion CombinationMap
3483
+ # region FixedCoordinateTypeObject
3484
+
3485
+
3486
+ class FixedCoordinateTypeObject(_CmapsMixin, RegisteredObject[D]):
3487
+ """This object serves as base class for :class:`Timeline` and `Event`, both of which are bound
3488
+ to single coordinate type. A FixedCoordinateTypeObject is a :class:`RegisteredObject` that
3489
+ is instantiated with a :class:`TimeUnit` and a :class:`NumberType`. Usually, subclasses
3490
+ would lock the properties :attr:`unit` and :attr:`coordinate_type` once data has been added
3491
+ and/or refer to some conversion mechanism.
3492
+ """
3493
+
3494
+ _allowed_units: Optional[tuple[TimeUnit, ...]] = None
3495
+ _allowed_number_types: Optional[tuple[NumberType | Type[Number], ...]] = None
3496
+ _default_unit: Optional[TimeUnit] = None
3497
+ _default_number_type: Optional[NumberType] = None
3498
+
3499
+ def __init__(
3500
+ self,
3501
+ unit: TimeUnit | str,
3502
+ number_type: NumberType | Type[Number],
3503
+ id_prefix: str,
3504
+ uid: Optional[str] = None,
3505
+ ):
3506
+ """Initializes a FixedCoordinateTypeObject.
3507
+
3508
+ Args:
3509
+ unit: The TimeUnit for this object.
3510
+ number_type: The NumberType or Python numeric type for this object.
3511
+ id_prefix: Prefix for the object's ID.
3512
+ uid: Unique identifier.
3513
+ """
3514
+ super().__init__(id_prefix=id_prefix, uid=uid)
3515
+ self._unit = None
3516
+ self._number_type = None
3517
+ self._inverse_maps: Dict[
3518
+ tuple[TimeUnit, Optional[NumberType]] | TimeUnit, CoordinatesMap
3519
+ ] = {}
3520
+ self.unit = unit
3521
+ self.number_type = number_type
3522
+
3523
+ @property
3524
+ def coordinate_type(self) -> str:
3525
+ """String key for this object's coordinate type."""
3526
+ return get_key(self.unit, self.number_type)
3527
+
3528
+ @property
3529
+ def number_type(self) -> NumberType:
3530
+ """The NumberType of this object."""
3531
+ return self._number_type
3532
+
3533
+ @number_type.setter
3534
+ def number_type(self, value: NumberType | Type[Number]): # Allow Type[Number]
3535
+ if value is None:
3536
+ if self._default_number_type is None:
3537
+ raise ValueError(
3538
+ f"number_type cannot be None when {self.class_name}._default_number_type is None."
3539
+ )
3540
+ self._number_type = NumberType(self._default_number_type)
3541
+ return
3542
+ if isinstance(value, NumberType):
3543
+ value = value.value
3544
+ if self._allowed_number_types is None:
3545
+ allowed = Number
3546
+ else:
3547
+ if isinstance(self._allowed_number_types, Iterable):
3548
+ allowed = self._allowed_number_types
3549
+ else:
3550
+ allowed = [self._allowed_number_types]
3551
+ allowed = tuple(
3552
+ t.value if isinstance(t, NumberType) else t for t in allowed
3553
+ )
3554
+ if not issubclass(value, allowed):
3555
+ try:
3556
+ value_name = value.__name__
3557
+ except Exception:
3558
+ value_name = value
3559
+ raise ValueError(
3560
+ f"number_type needs to be a subclass of {allowed}, not {value_name}"
3561
+ )
3562
+ self._number_type = NumberType(value)
3563
+
3564
+ @property
3565
+ def interval(self):
3566
+ raise NotImplementedError(
3567
+ f"Property 'interval' not defined for {self.class_name}"
3568
+ )
3569
+
3570
+ @property
3571
+ def pd_interval(self) -> pd.Interval:
3572
+ left, right = self.interval
3573
+ return pd.Interval(left, right, closed="left")
3574
+
3575
+ @property
3576
+ def unit(self) -> TimeUnit:
3577
+ """The TimeUnit of this object."""
3578
+ return self._unit
3579
+
3580
+ @unit.setter
3581
+ def unit(self, value: TimeUnit | str):
3582
+ if value is None:
3583
+ if self._default_unit is None:
3584
+ raise ValueError(
3585
+ f"unit cannot be None when {self.class_name}._default_unit is None."
3586
+ )
3587
+ self._unit = TimeUnit(self._default_unit)
3588
+ return
3589
+
3590
+ new_value = TimeUnit(value)
3591
+ if self._allowed_units is not None:
3592
+ if new_value not in self._allowed_units:
3593
+ allowed_names = [u.name for u in self._allowed_units]
3594
+ raise TypeError(
3595
+ f"The unit of a {self.class_name} cannot be {new_value.name!r}. Valid units are: "
3596
+ f"{allowed_names}."
3597
+ )
3598
+ self._unit = new_value
3599
+
3600
+ def validate_cmap(self, cmap: ConversionMap):
3601
+ """Checks whether the cmap to be added converts from or to this object's unit.
3602
+
3603
+ Args:
3604
+ cmap: The CoordinatesMap to store.
3605
+
3606
+ Raises:
3607
+ ValueError: If the cmap is incompatible with this object's unit.
3608
+ """
3609
+ if (
3610
+ cmap.source_unit is not None
3611
+ and cmap.source_unit != self.unit
3612
+ and cmap.target_unit != self.unit
3613
+ ):
3614
+ raise ValueError(
3615
+ f"ConversionMap from {cmap.source_unit.name} to {cmap.target_unit.name} "
3616
+ f"is incompatible with this {self.unit.name}-based object {self.id}."
3617
+ )
3618
+
3619
+ def _make_adapted_cmap(self, cmap: CoordinatesMap):
3620
+ """This method may create a cmap based on the one to be added. Typically, this could be
3621
+ an inverted cmap if the one to be added converts into the "wrong direction" ("correct"
3622
+ default: from the object's unit to another one).
3623
+ """
3624
+ if cmap.source_unit is None or cmap.source_unit == self.unit:
3625
+ return cmap
3626
+ inverted = cmap.get_inverse()
3627
+ assert inverted.source_unit == self.unit, (
3628
+ f"Inversion of {cmap.class_name} resulted in a {inverted.class_name} "
3629
+ f"with source_unit {inverted.source_unit}, not {self.unit}."
3630
+ )
3631
+ self.logger.debug(
3632
+ f"{cmap.class_name} had to be inverted before adding it to {self.class_name}"
3633
+ f" {self.id} such that the source unit matches."
3634
+ )
3635
+ return inverted
3636
+
3637
+ def convert_to(
3638
+ self,
3639
+ values: pd.Series | np.ndarray | Sequence[Coord] | Coord,
3640
+ target_unit: TimeUnit,
3641
+ target_type: Optional[NumberType] = None,
3642
+ custom_converter_func: Optional[
3643
+ Callable[[Union[Number, np.ndarray]], Union[Number, np.ndarray]]
3644
+ ] = None,
3645
+ ) -> Any:
3646
+ """Converts values from this object's coordinate type to a target type.
3647
+
3648
+ Args:
3649
+ values: Data to convert.
3650
+ target_unit: Target TimeUnit.
3651
+ target_type: Optional target NumberType.
3652
+ custom_converter_func: Optional custom conversion function.
3653
+
3654
+ Returns:
3655
+ Converted data.
3656
+
3657
+ Raises:
3658
+ ValueError: If conversion is not possible.
3659
+ """
3660
+ if values is None:
3661
+ raise ValueError("Cannot convert None.")
3662
+ cmaps = self.get_conversion_maps(target_unit)
3663
+ if cmaps:
3664
+ if custom_converter_func is not None:
3665
+ warnings.warn(
3666
+ "Existing cmaps were found, custom converter function ignored."
3667
+ )
3668
+ if len(cmaps) == 1:
3669
+ return cmaps[0].convert(values)
3670
+ combined = CombinationMap(cmaps)
3671
+ return combined.convert(values)
3672
+ if custom_converter_func is None:
3673
+ raise ValueError(
3674
+ f"Cannot convert {self.unit} to {target_unit} because no CoordinatesMap is known and no custom "
3675
+ f"converter function has been specified."
3676
+ )
3677
+ new_cmap = CoordinatesMap(
3678
+ source_unit=self.unit,
3679
+ target_unit=target_unit,
3680
+ target_type=target_type,
3681
+ conversion_function=custom_converter_func,
3682
+ )
3683
+ return new_cmap(values)
3684
+
3685
+ def convert_from(
3686
+ self,
3687
+ values: pd.Series | np.ndarray | Sequence[Coord] | Coord,
3688
+ source_unit: TimeUnit,
3689
+ custom_converter_func: Optional[
3690
+ Callable[[Union[Number, np.ndarray]], Union[Number, np.ndarray]]
3691
+ ] = None,
3692
+ ) -> Any:
3693
+ """Converts values from an external source type to this object's coordinate type.
3694
+
3695
+ Args:
3696
+ values: Data to convert.
3697
+ source_unit: TimeUnit of the source data.
3698
+ source_type: Optional NumberType of the source data.
3699
+ custom_converter_func: Optional custom conversion function.
3700
+
3701
+ Returns:
3702
+ Converted data.
3703
+
3704
+ Raises:
3705
+ ValueError: If conversion is not possible.
3706
+ """
3707
+ if values is None:
3708
+ raise ValueError("Cannot convert None.")
3709
+ cmaps = self.get_inverse_maps(source_unit)
3710
+ if cmaps:
3711
+ if len(cmaps) == 1:
3712
+ return cmaps[0].convert(values)
3713
+ combined = CombinationMap(cmaps)
3714
+ return combined.convert(values)
3715
+ if custom_converter_func is None:
3716
+ raise ValueError(
3717
+ f"Cannot convert {self.unit} to {source_unit} because no CoordinatesMap is known and no custom "
3718
+ f"converter function has been specified."
3719
+ )
3720
+ new_cmap = CoordinatesMap(
3721
+ source_unit=source_unit,
3722
+ target_unit=self.unit,
3723
+ target_type=self.number_type,
3724
+ conversion_function=custom_converter_func,
3725
+ )
3726
+ return new_cmap(values)
3727
+
3728
+ def make_coordinate(self, value: Coord) -> Coordinate:
3729
+ """Creates a Coordinate with this object's unit and number type.
3730
+
3731
+ Args:
3732
+ value: The numerical value or an existing Coordinate.
3733
+
3734
+ Returns:
3735
+ A new Coordinate object.
3736
+ """
3737
+ return convert_coordinate(value, self.coordinate_type)
3738
+
3739
+ def make_number(self, value: Number) -> Number:
3740
+ """Converts a number to this object's number type.
3741
+
3742
+ Args:
3743
+ value: The number to convert.
3744
+
3745
+ Returns:
3746
+ The number, cast to this object's NumberType.
3747
+ """
3748
+ return self.number_type.value(value)
3749
+
3750
+
3751
+ # endregion FixedCoordinateTypeObject
3752
+ # region helper functions
3753
+
3754
+
3755
+ def treat_cmaps_argument(
3756
+ cmaps: Iterable[ConversionMap | str] | ConversionMap | str,
3757
+ ) -> dict[ID_str, ConversionMap]:
3758
+ cmaps = make_argument_iterable(cmaps)
3759
+ result = {}
3760
+ for cmap_arg in cmaps:
3761
+ if arg_was_id := isinstance(cmap_arg, str):
3762
+ cmap = get_object_by_id(cmap_arg)
3763
+ else:
3764
+ cmap = cmap_arg
3765
+ if not isinstance(cmap, ConversionMap):
3766
+ if arg_was_id:
3767
+ raise ValueError(
3768
+ f"The ID {cmap_arg} resolved to a {cmap.__class__.__name__}, "
3769
+ f"not a ConversionMap."
3770
+ )
3771
+ else:
3772
+ raise ValueError(f"Expected a ConversionMap, got a {type(cmap_arg)}.")
3773
+ result[cmap.id] = cmap
3774
+ return result
3775
+
3776
+
3777
+ # endregion helper functions