timetoalign 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- timetoalign-0.0.1.dist-info/METADATA +11 -0
- timetoalign-0.0.1.dist-info/RECORD +13 -0
- timetoalign-0.0.1.dist-info/WHEEL +5 -0
- timetoalign-0.0.1.dist-info/top_level.txt +1 -0
- tta/__init__.py +1 -0
- tta/common.py +58 -0
- tta/conversions.py +3777 -0
- tta/events.py +1527 -0
- tta/parsing.py +2236 -0
- tta/registry.py +546 -0
- tta/representations.py +826 -0
- tta/timelines.py +2192 -0
- tta/utils.py +963 -0
tta/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
|