timelined_array 0.0.2__tar.gz → 0.0.3__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: timelined_array
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: Manage easily 1 or multidimensionnal samples numpy arrays that are time related. Extends numpy without removing any of it's abilities on such arrays.
5
5
  Author-Email: Timothe Jost <timothe.jost@wanadoo.fr>
6
6
  License: MIT
@@ -10,7 +10,7 @@ dependencies = [
10
10
  requires-python = ">=3.11"
11
11
  readme = "README.md"
12
12
  dynamic = []
13
- version = "0.0.2"
13
+ version = "0.0.3"
14
14
 
15
15
  [project.license]
16
16
  text = "MIT"
@@ -0,0 +1,3 @@
1
+ __version__ = "0.0.3"
2
+
3
+ from .time import TimelinedArray, MaskedTimelinedArray, Seconds
@@ -0,0 +1,752 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ import numpy as np
4
+ from logging import getLogger
5
+ from typing import Tuple, List, Protocol, Type
6
+
7
+
8
+ logger = getLogger("timelined_array")
9
+
10
+
11
+ class TimeCompatibleProtocol(Protocol):
12
+
13
+ time_dimension: int
14
+ timeline: "Timeline"
15
+
16
+ def __getitem__(self, index) -> np.ndarray: ...
17
+
18
+ def __array__(self) -> np.ndarray: ...
19
+
20
+ @property
21
+ def shape(self) -> Tuple[int]: ...
22
+
23
+ @property
24
+ def ndim(self) -> int: ...
25
+
26
+ @property
27
+ def itime(self) -> "TimeIndexer": ...
28
+
29
+ def _get_array_cls(self) -> "Type": ...
30
+
31
+ def transpose(self): ...
32
+
33
+ def _finish_axis_removing_operation(self, result, axis): ...
34
+
35
+
36
+ class Timeline(np.ndarray):
37
+ def __new__(cls, input_array, uniform_space=False):
38
+
39
+ if uniform_space:
40
+ # if we want a uniformly spaced timeline from start to stop of the current timeline.
41
+ obj = Timeline._uniformize(input_array)
42
+ else:
43
+ if isinstance(input_array, Timeline):
44
+ return input_array
45
+ obj = np.asarray(input_array).view(cls)
46
+
47
+ return obj
48
+
49
+ def __array_finalize__(self, obj):
50
+ pass
51
+
52
+ def __setstate__(self, state):
53
+ # try:
54
+ # super().__setstate__(state[0:-2]) # old deserializer
55
+ # except TypeError:
56
+ super().__setstate__(state) # new one
57
+
58
+ def __contains__(self, time_value):
59
+ return self.min() <= time_value <= self.max()
60
+
61
+ def max(self):
62
+ return super().max().item()
63
+
64
+ def min(self):
65
+ return super().min().item()
66
+
67
+ @classmethod
68
+ def _uniformize(cls, timeline):
69
+ raise NotImplementedError("Upcoming function")
70
+ # obj = np.linspace(input_array[0], input_array[1], len(input_array)).view(cls)
71
+ # TODO : do numpy.interp(np.arange(0, len(a), 1.5), np.arange(0, len(a)), a)
72
+ # interp to get a fixed number of points ?
73
+
74
+ def uniformize(self):
75
+ self[:] = self._uniformize(self)
76
+
77
+
78
+ class TimeIndexer:
79
+ """The time indexer indexes by default from >= to the time start, and strictly < to time stop"""
80
+
81
+ def __init__(self, array: TimeCompatibleProtocol):
82
+ self.array = array
83
+
84
+ def seconds_to_index(self, index):
85
+ # argument index may be a slice or a scalar. Units of index should be in second. Returns a slice as index
86
+ # this is sort of a wrapper for get_iindex that does the heavy lifting.
87
+ # this function just makes sure to pass arguments to it corectly depending
88
+ # on if the time index is a single value or a slice.
89
+ if isinstance(index, slice):
90
+ return self.get_iindex(index.start, index.stop, index.step)
91
+ else:
92
+ return self.get_iindex(sec_start=index).start
93
+
94
+ def _insert_time_index(self, time_index):
95
+ # put the integer value at the position of time index at the right position
96
+ # (time_dimension) in the tuple of all sliced dimensions
97
+ full_index = [slice(None)] * len(self.array.shape)
98
+ full_index[self.array.time_dimension] = time_index
99
+
100
+ return tuple(full_index)
101
+
102
+ def __getitem__(self, index) -> "TimelinedArray | MaskedTimelinedArray | np.ndarray":
103
+ if hasattr(index, "__iter__"):
104
+ # if not isinstance(index,(int,float,slice,np.integer,np.floating)):
105
+ raise ValueError(
106
+ "Isec allow only indexing on time dimension. Index must be either int, float or slice, not iterable"
107
+ )
108
+
109
+ iindex_time = self.seconds_to_index(index)
110
+ full_iindex = self._insert_time_index(iindex_time)
111
+ # print("new full index : ",iindex_time)
112
+ logger.debug(f"About to index over time with iindex_time {iindex_time} and full_iindex {full_iindex}")
113
+ return self.array[full_iindex]
114
+
115
+ def get_iindex(self, sec_start=None, sec_stop=None, sec_step=None): # every value here is in seconds
116
+ # converts a time index (follows a slice syntax, but in time units) to integer units
117
+ timeline_max_step = np.absolute(np.diff(self.array.timeline)).max() * 2
118
+
119
+ if sec_start is None:
120
+ start = 0
121
+ else:
122
+ if sec_start >= self.array.timeline[0]:
123
+ start = np.argmax(self.array.timeline >= sec_start)
124
+ else:
125
+ start = 0
126
+
127
+ if abs(self.array.timeline[start] - sec_start) > timeline_max_step:
128
+ raise IndexError(
129
+ f"The start time value {sec_start} you searched for is not in the timeline of this array "
130
+ f"(timeline starts at {self.array.timeline[0]}, allowed jitter = {timeline_max_step} :"
131
+ " +/- 2 times the max step between two timeline points"
132
+ )
133
+
134
+ if sec_stop is None:
135
+ stop = len(self.array.timeline)
136
+ # elif sec_stop < 0 : Here we allowed for negative indexing but as timeline can have negative values
137
+ # , i removed this posibility
138
+ # stop = np.argmin(self.array.timeline<self.array.timeline[-1]+sec_stop)
139
+ else:
140
+ if sec_stop < self.array.timeline[-1]:
141
+ stop = np.argmin(self.array.timeline < sec_stop)
142
+ else:
143
+ stop = len(self.array.timeline) - 1
144
+
145
+ if abs(self.array.timeline[stop] - sec_stop) > timeline_max_step:
146
+ raise IndexError(
147
+ f"The end time value {sec_stop} you searched for is not in the timeline of this array "
148
+ f"(timeline ends at {self.array.timeline[-1]} , allowed jitter = {timeline_max_step} : "
149
+ "+/- 2 times the max step between two timeline points"
150
+ )
151
+
152
+ if sec_step is None:
153
+ step = 1
154
+ else:
155
+ step = int(np.round(sec_step / self.array.timeline.step))
156
+ if step < 1:
157
+ step = 1
158
+ return slice(start, stop, step)
159
+
160
+
161
+ class TimeMixin:
162
+
163
+ time_dimension: int
164
+ timeline: Timeline
165
+
166
+ def _time_dimension_in_axis(self, axis: int | Tuple[int] | None) -> bool:
167
+ if (
168
+ axis is None
169
+ or axis == self.time_dimension
170
+ or (isinstance(axis, (list, tuple)) and self.time_dimension in axis)
171
+ ):
172
+ return True
173
+ return False
174
+
175
+ def _get_time_dimension_after_axis_removal(self, axis_removed) -> int:
176
+ if not isinstance(axis_removed, tuple):
177
+ axis_removed = (axis_removed,)
178
+ axis_removed = sorted(axis_removed)
179
+
180
+ final_time_dimension = self.time_dimension
181
+ for axis in axis_removed:
182
+ if axis < self.time_dimension:
183
+ final_time_dimension -= 1
184
+ elif axis == self.time_dimension:
185
+ raise ValueError("The time dimension would simply be discarded after axis removal")
186
+
187
+ return final_time_dimension
188
+
189
+ def _get_advanced_indexed_times(self, index):
190
+
191
+ index = np.asarray(index)
192
+
193
+ if index.size == 1:
194
+ # only in case of an array containing a single value, we perform slice indexing from a numpy array
195
+ index = index.item()
196
+ return self._get_slice_indexed_times(index)
197
+
198
+ else:
199
+ # in that case, this is a boolean selection, to filter the array,
200
+ # or an int selection, to filter and/or reorder the array
201
+ if index.dtype == bool or index.dtype == int:
202
+
203
+ # if it's boolean selecting on dimensions including the time_dim, we drop the timeline
204
+ if len(index.shape) > self.time_dimension:
205
+ # if time dimension is the first one, we filter the time dim in the same way we do for the array
206
+ # and we keep the time_dimension
207
+ if self.time_dimension == 0:
208
+ return index, self.timeline[index], self.time_dimension
209
+
210
+ # TimelinedArray(
211
+ # super().__getitem__(index),
212
+ # timeline=self.timeline[index],
213
+ # time_dimension=self.time_dimension,
214
+ # )
215
+ # if it has dimensions before the time dimension, then we don't know what "sub" filter to use
216
+ # to filter the time dimension, so we skip and return a standard array
217
+ else:
218
+ return index, None, None
219
+
220
+ # else we return the filtered array, keeping time_dimension and timeline as is,
221
+ # as they should be untouched
222
+ else:
223
+ return index, self.timeline, self.time_dimension
224
+
225
+ # TimelinedArray(super().__getitem__(index), timeline=self.timeline, time_dimension=self.time_dimension)
226
+ else:
227
+ raise ValueError(
228
+ "Cannot use advanced indexing with arrays " "that are not composed of either booleans or integers"
229
+ )
230
+
231
+ def _get_slice_indexed_times(self, index):
232
+
233
+ # this will store the new time_dimension axis in the newly formed array
234
+ final_time_dimension = self.time_dimension
235
+ # this is to store the time_dimension axis to use in the index, after we searched if we have np.newaxes in it
236
+ time_dimension_in_index = self.time_dimension
237
+
238
+ # if we reach here, we know we are indexing either with :
239
+ # an int for the index, a tuple of ints, a slice or a tuple of slices
240
+ # to ease our way, we make the single int a tuple first :
241
+ if not isinstance(index, tuple):
242
+ index = (index,)
243
+
244
+ # as we can add np.newaxes dynamically, we need to parse the index in a loop to defined the behaviour to adopt
245
+ for dimension in range(len(index)):
246
+ # as long as we are looking at indexing after the time_dimension, we don't care,
247
+ # because standard numpy indexing will occur without changing anything about the timeline nor time_dimension
248
+ if dimension > time_dimension_in_index:
249
+ continue
250
+
251
+ # np.newaxis is a placeholder for None
252
+ if index[dimension] is None:
253
+ # in that case, it means a dimension was added before time_dimension, so we will shift it by one.
254
+ final_time_dimension += 1
255
+ # we also will look at values for time dimension here to apply to timeline later
256
+ time_dimension_in_index += 1
257
+
258
+ # a index at time_dimension or after, is a single integer
259
+ elif isinstance(index[dimension], (int, np.integer)):
260
+
261
+ # if the time dimension index itself is an integer,
262
+ # we loose time related information and return a standard numpy array
263
+ if dimension == time_dimension_in_index:
264
+ return index, None, None
265
+ # np.array(self).__getitem__(index)
266
+
267
+ # otherwise the dimension removed is below time_dimension,
268
+ # and in that case, we decrease it's position in the final array
269
+ # (not in the index, e.g. time_dimension_in_index, as this will still be used to get how to crop it)
270
+ final_time_dimension -= 1
271
+
272
+ # note that if index is a slice, we siply let the normal indexing occur,
273
+ # as it doesn't add or remove dimensions
274
+
275
+ # if a part of the index was destined to reshape the time dimension,
276
+ # we apply this reshaping to timeline too.
277
+
278
+ final_timeline = (
279
+ self.timeline[index[time_dimension_in_index]] if len(index) > time_dimension_in_index else self.timeline
280
+ )
281
+
282
+ return index, final_timeline, final_time_dimension
283
+
284
+ def _get_indexed_times(self, index: int | Tuple[int] | slice | Tuple[slice] | List | np.ndarray):
285
+
286
+ # if index is an array or a list, we do advanced indexing.
287
+ if isinstance(index, (np.ndarray, list)):
288
+ return self._get_advanced_indexed_times(index)
289
+ # otherwise, if single element, (int, slice) or tuple or these, we do regular indexing.
290
+ return self._get_slice_indexed_times(index)
291
+
292
+ @staticmethod
293
+ def _is_single_element(obj):
294
+ return obj.shape == ()
295
+
296
+ def _finish_axis_removing_operation(self, result: TimeCompatibleProtocol, axis: int | Tuple[int] | None):
297
+ if not isinstance(result, np.ndarray):
298
+ return result
299
+ if not self._is_single_element(result):
300
+ return result.item()
301
+ if self._time_dimension_in_axis(axis):
302
+ return np.asarray(result)
303
+ result.time_dimension = self._get_time_dimension_after_axis_removal(axis)
304
+ return result
305
+
306
+ # # REDUCE and SETSTATE are used to instanciate the array from and to a pickled serialized object.
307
+ # # We only need to store and retrieve time_dimension and timeline on top of the array's data
308
+ def __reduce__(self):
309
+ # Get the parent's __reduce__ tuple
310
+ pickled_state = super().__reduce__()
311
+ # Create our own tuple to pass to __setstate__
312
+ new_state = pickled_state[2] + (self.timeline, self.time_dimension) # type: ignore
313
+
314
+ # self.logger.debug(f"Reduced to : time_dimension={self.time_dimension}. Array shape is : {new_state}")
315
+ # Return a tuple that replaces the parent's __setstate__ tuple with our own
316
+ return (pickled_state[0], pickled_state[1], new_state)
317
+
318
+ def __setstate__(self: TimeCompatibleProtocol, state):
319
+ self.timeline = state[-2] # Set the info attribute
320
+ self.time_dimension = state[-1]
321
+
322
+ # Call the parent's __setstate__ with the other tuple elements.
323
+ super().__setstate__(state[0:-2])
324
+
325
+ def __hash__(self):
326
+ return hash((self.__array__(), self.timeline)) # type: ignore
327
+
328
+ def _get_array_cls(self) -> TimeCompatibleProtocol:
329
+ valid_types = [TimelinedArray, TimelinedArray]
330
+ for vtype in valid_types:
331
+ if isinstance(self, vtype):
332
+ return vtype # type: ignore
333
+ return np.ndarray # type: ignore
334
+
335
+ @property
336
+ def array_info(self: TimeCompatibleProtocol):
337
+ return (
338
+ f"{type(self).__name__} of shape {self.shape}, time_dimension {self.time_dimension} "
339
+ f"and timeline shape {self.timeline.shape}"
340
+ )
341
+
342
+ @property
343
+ def itime(self: TimeCompatibleProtocol):
344
+ return TimeIndexer(self)
345
+
346
+ @property
347
+ def isec(self: TimeCompatibleProtocol):
348
+ return self.itime
349
+
350
+ def align_trace(self, start: float, element_nb: int):
351
+ """Aligns the timelined array by making it start from a timepoint in time-units (synchronizing)
352
+ and cutting the array N elements after the start point.
353
+
354
+ Args:
355
+ start (float): Start point, in time-units. (usually seconds) Time index based, so can be float or integer.
356
+ element_nb (int): Cuts the returned array at 'element_nb' amount of elements, after the starting point.
357
+ It is item index based, not time index based, so it must necessarily be an integer.
358
+
359
+ Returns:
360
+ TimelinedArray: The synchronized and cut arrray.
361
+ """
362
+
363
+ return self.itime[start:][:element_nb] # type: ignore
364
+
365
+ def swapaxes(self: TimeCompatibleProtocol, axis1: int, axis2: int):
366
+ # we re-instanciate a TimelinedArray with view instead of the full constructor : faster
367
+ cls = self._get_array_cls()
368
+
369
+ swapped_array: TimeCompatibleProtocol = np.swapaxes(np.asarray(self), axis1, axis2).view(cls) # type: ignore
370
+ swapped_array.timeline = self.timeline
371
+
372
+ if axis1 == self.time_dimension:
373
+ swapped_array.time_dimension = axis2
374
+ elif axis2 == self.time_dimension:
375
+ swapped_array.time_dimension = axis1
376
+ else:
377
+ swapped_array.time_dimension = self.time_dimension
378
+
379
+ # TimelinedArray.time_dimension and TimelinedArray.timeline are set. good to go
380
+ return swapped_array
381
+
382
+ def transpose(self: TimeCompatibleProtocol, *axes):
383
+ if not axes:
384
+ axes = tuple(range(self.ndim))[::-1]
385
+
386
+ cls = self._get_array_cls()
387
+
388
+ # we re-instanciate a TimelinedArray with view instead of the full constructor : faster
389
+ transposed_array: TimeCompatibleProtocol = np.transpose(np.asarray(self), axes).view(cls) # type: ignore
390
+ transposed_array.timeline = self.timeline
391
+
392
+ if self.time_dimension in axes:
393
+ transposed_array.time_dimension = axes.index(self.time_dimension)
394
+ else:
395
+ transposed_array.time_dimension = self.time_dimension
396
+
397
+ # TimelinedArray.time_dimension and TimelinedArray.timeline are set. good to go
398
+ return transposed_array
399
+
400
+ @property
401
+ def T(self: TimeCompatibleProtocol):
402
+ return self.transpose()
403
+
404
+ def moveaxis(self: TimeCompatibleProtocol, source: int | Tuple[int], destination: int | Tuple[int]):
405
+ if isinstance(source, int):
406
+ source = (source,)
407
+ if isinstance(destination, int):
408
+ destination = (destination,)
409
+
410
+ cls = self._get_array_cls()
411
+
412
+ # we re-instanciate a TimelinedArray with view instead of the full constructor : faster
413
+ moved_array: TimeCompatibleProtocol = np.moveaxis(np.asarray(self), source, destination).view(
414
+ cls
415
+ ) # type: ignore
416
+ moved_array.timeline = self.timeline
417
+ moved_array.time_dimension = self.time_dimension
418
+
419
+ if self.time_dimension in source:
420
+ index_in_source = source.index(self.time_dimension)
421
+ moved_array.time_dimension = destination[index_in_source]
422
+ else:
423
+ for src, dest in zip(source, destination):
424
+ if src < self.time_dimension and dest >= self.time_dimension:
425
+ moved_array.time_dimension -= 1
426
+ elif src > self.time_dimension and dest <= self.time_dimension:
427
+ moved_array.time_dimension += 1
428
+
429
+ # TimelinedArray.time_dimension and TimelinedArray.timeline are set. good to go
430
+ return moved_array
431
+
432
+ def rollaxis(self: TimeCompatibleProtocol, axis: int, start: int = 0):
433
+ # we re-instanciate a TimelinedArray with view instead of the full constructor : faster
434
+ cls = self._get_array_cls()
435
+
436
+ rolled_array: TimeCompatibleProtocol = np.rollaxis(np.asarray(self), axis, start).view(cls) # type: ignore
437
+ rolled_array.timeline = self.timeline
438
+ rolled_array.time_dimension = self.time_dimension
439
+
440
+ if axis < self.time_dimension:
441
+ if start <= axis:
442
+ rolled_array.time_dimension += 1
443
+ elif start <= self.time_dimension:
444
+ rolled_array.time_dimension -= 1
445
+ elif axis == self.time_dimension:
446
+ rolled_array.time_dimension = start
447
+ else:
448
+ if start <= self.time_dimension:
449
+ rolled_array.time_dimension -= 1
450
+ elif start > self.time_dimension:
451
+ rolled_array.time_dimension += 1
452
+
453
+ # TimelinedArray.time_dimension and TimelinedArray.timeline are set. good to go
454
+ return rolled_array
455
+
456
+ def mean(self: TimeCompatibleProtocol, axis: int | Tuple[int] | None = None, dtype=None, out=None, keepdims=False):
457
+ result = super().mean(axis=axis, dtype=dtype, out=out, keepdims=keepdims)
458
+ return self._finish_axis_removing_operation(result, axis)
459
+
460
+ # Override other reduction methods similarly if needed
461
+ def sum(self: TimeCompatibleProtocol, axis: int | Tuple[int] | None = None, dtype=None, out=None, keepdims=False):
462
+ result = super().sum(axis=axis, dtype=dtype, out=out, keepdims=keepdims)
463
+ return self._finish_axis_removing_operation(result, axis)
464
+
465
+ def std(
466
+ self: TimeCompatibleProtocol, axis: int | Tuple[int] | None = None, dtype=None, out=None, ddof=0, keepdims=False
467
+ ):
468
+ result = super().std(axis=axis, dtype=dtype, out=out, ddof=ddof, keepdims=keepdims)
469
+ return self._finish_axis_removing_operation(result, axis)
470
+
471
+ def var(
472
+ self: TimeCompatibleProtocol, axis: int | Tuple[int] | None = None, dtype=None, out=None, ddof=0, keepdims=False
473
+ ):
474
+ result = super().var(axis=axis, dtype=dtype, out=out, ddof=ddof, keepdims=keepdims)
475
+ return self._finish_axis_removing_operation(result, axis)
476
+
477
+ def rebase_timeline(self, at=0):
478
+ # returns a modified version of the array, with the first element of the array to time zero,
479
+ # and shift the rest accordingly
480
+ cls = self._get_array_cls()
481
+ return cls(self, timeline=self.timeline - self.timeline[at]) # type: ignore
482
+
483
+ def offset_timeline(self, offset):
484
+ # returns a modified version of the array, where we set time of all elements
485
+ # in array at a fix offset relative to their current value.
486
+ cls = self._get_array_cls()
487
+ return cls(self, timeline=self.timeline + offset) # type: ignore
488
+
489
+ @property
490
+ def pack(self):
491
+ return TimePacker(self)
492
+
493
+ def sec_max(self):
494
+ # get maximum time
495
+ return self.timeline.max()
496
+
497
+ def sec_min(self):
498
+ # get minimum time
499
+ return self.timeline.min()
500
+
501
+ @staticmethod
502
+ def extract_time_from_data(data, timeline=None, time_dimension=None, uniform_space=False):
503
+ _unpacking = False
504
+ # if timeline not explicitely passed as arg, we try to pick up the timeline of the input_array.
505
+ # will rise after if input_array is not a timelined_array
506
+ if timeline is None:
507
+ timeline = getattr(data, "timeline", None)
508
+
509
+ if timeline is None:
510
+ # if arguments are an uniform list of timelined array
511
+ # (often use to make mean and std of synchonized timelines), we pick up the first one.
512
+ for element in data:
513
+ timeline = getattr(element, "timeline", None)
514
+ _unpacking = True
515
+ break
516
+
517
+ if timeline is None:
518
+ raise ValueError("timeline must be supplied if the input_array is not a TimelinedArray")
519
+
520
+ if time_dimension is None: # same thing for the time dimension.
521
+ time_dimension = getattr(data, "time_dimension", None)
522
+
523
+ if time_dimension is None:
524
+ # if arguments are an uniform list of timelined array
525
+ # (often use to make mean and std of synchonized timelines), we pick up the first one.
526
+ # but it also means default numpy packing will set the new dimension as dimension 0.
527
+ # As such, the current time dimension will have to be the time dimension of the listed elements,
528
+ # +1 (a.k.a. shifted one dimension deeper)
529
+
530
+ for element in data:
531
+ time_dimension = getattr(element, "time_dimension", None) + 1
532
+ _unpacking = True
533
+ break
534
+ else:
535
+ time_dimension = 0
536
+
537
+ if time_dimension is None:
538
+ time_dimension = 0
539
+
540
+ if not isinstance(time_dimension, int):
541
+ raise ValueError("time_dimension must be an integer")
542
+
543
+ timeline = Timeline(timeline, uniform_space=uniform_space)
544
+
545
+ if _unpacking:
546
+ logger.debug(f"We are unpacking {type(data)} data")
547
+ if not isinstance(data, np.ndarray) or len(data.shape) <= time_dimension: # type: ignore
548
+ data = np.stack(data) # type: ignore
549
+
550
+ return data, timeline, time_dimension
551
+
552
+
553
+ class TimePacker:
554
+ def __init__(self, array):
555
+ self.array = array
556
+
557
+ def __iter__(self):
558
+ return iter((self.array.timeline, self.array.__array__()))
559
+
560
+
561
+ class TimelinedArray(TimeMixin, np.ndarray, TimeCompatibleProtocol):
562
+ """
563
+ The TimelinedArray class is a subclass of the numpy.ndarray class, which represents a multi-dimensional
564
+ array of homogeneous data. This class adds additional functionality
565
+ for working with arrays that have a time dimension, specifically:
566
+
567
+ It defines a Timeline class, which is also a subclass of numpy.ndarray, and represents a timeline associated
568
+ with the array. The Timeline class has several methods, including:
569
+ arange_timeline: This method takes a timeline array and creates an evenly spaced timeline based
570
+ on the start and stop time of the original timeline.
571
+ timeline_step: This method returns the average time difference between each consecutive value in the timeline.
572
+
573
+ TimelinedArrayIndexer class, which has several methods, including:
574
+ seconds_to_index: This method converts time in seconds to index value.
575
+ get_iindex: This method converts time in seconds to a slice object representing time.
576
+
577
+ __new__ : This method is used to creates a new instance of the TimelinedArray class. It takes several optional
578
+ arguments: timeline, time_dimension, arange_timeline, and timeline_is_arranged.
579
+ It creates a TimelinedArrayIndexer object with the input array,
580
+ and assigns the supplied timeline and dimension properties.
581
+
582
+ It defines an indexer to access the TimelinedArray as if it was indexed by time instead of index
583
+ It also adds an attribute time_dimension , and timeline_is_arranged to the class, which are used to keep track of
584
+ the time dimension of the array and whether the timeline is arranged or not.
585
+ It enables accessing the array with time instead of index, and it also tries to keep track of the time dimension
586
+ and the timeline, so it can be used to correct indexed time.
587
+
588
+ Example :
589
+ ...
590
+
591
+ """
592
+
593
+ def __new__(cls, data, timeline=None, time_dimension: int | None = None, uniform_space=False) -> "TimelinedArray":
594
+
595
+ data, timeline, time_dimension = TimeMixin.extract_time_from_data(
596
+ data, timeline=timeline, time_dimension=time_dimension, uniform_space=uniform_space
597
+ )
598
+
599
+ # if np.isscalar(timeline):
600
+ # logger.debug(f"Scalar timeline found. Timeline is {timeline}")
601
+ # return np.asarray(input_array) # type: ignore
602
+
603
+ # instanciate the np array as a view, as per numpy documentation on how to make ndarray child classes
604
+ obj = np.asarray(data).view(cls)
605
+
606
+ if obj.shape[time_dimension] != len(timeline):
607
+ raise ValueError(
608
+ "timeline object and the shape of time_dimension of the input_array must be equal. "
609
+ f"They are : {len(timeline)} and {obj.shape[time_dimension]}"
610
+ )
611
+
612
+ obj.timeline = timeline
613
+ obj.time_dimension = time_dimension
614
+ return obj
615
+
616
+ def __array_finalize__(self, obj):
617
+ super().__array_finalize__(obj)
618
+ if obj is None:
619
+ return
620
+ self.timeline = getattr(obj, "timeline", Timeline([]))
621
+ self.time_dimension = getattr(obj, "time_dimension", 0)
622
+
623
+ def __array_wrap__(self, out_arr, context=None):
624
+ if context is not None:
625
+ logger.debug(f"wrapping array after ufunc {context[0].__name__}")
626
+ output = super().__array_wrap__(out_arr, context)
627
+ if len(output.shape) < len(self.shape):
628
+ logger.debug(f"shape reduced from : {self.shape} to : {output.shape}. outarray was : {out_arr.shape}")
629
+ return output
630
+
631
+ def __array_function__(self, func, types, args, kwargs):
632
+ logger.debug(f"intercepting array before function {func.__name__}")
633
+ return super().__array_function__(func, types, args, kwargs)
634
+
635
+ def __getitem__(
636
+ self, index: int | Tuple[int] | slice | Tuple[slice] | List | np.ndarray
637
+ ) -> "TimelinedArray | np.ndarray":
638
+
639
+ index, final_timeline, final_time_dimension = self._get_indexed_times(index)
640
+
641
+ if final_timeline is None or final_time_dimension is None:
642
+ return np.array(self).__getitem__(index)
643
+
644
+ indexed_result = super().__getitem__(index)
645
+
646
+ logger.debug(
647
+ f"Current object : {self.array_info}.\n"
648
+ f"Newly indexed object : {type(indexed_result).__name__} of shape {indexed_result.shape}, "
649
+ f"time_dimension {final_time_dimension} and timeline shape {final_timeline.shape}"
650
+ )
651
+
652
+ return TimelinedArray(indexed_result, timeline=final_timeline, time_dimension=final_time_dimension)
653
+
654
+ # __repr__ and __str__ ARE OVERRIDEN TO AVOID HORRIBLE PERFORMANCE WHEN PRINTING
655
+ # DUE TO CUSTOM __GETITEM__ PRE-CHECKS WITH RECURSIVE NATIVE NUMPY REPR
656
+ def __repr__(self):
657
+ return type(self).__name__ + np.array(self).__repr__()[5:]
658
+
659
+ def __str__(self):
660
+ return type(self).__name__ + np.array(self).__str__()
661
+
662
+ @staticmethod
663
+ def align_from_iterable(iterable) -> "TimelinedArray":
664
+ start = min([item.timeline.min() for item in iterable])
665
+ maxlen = min([len(item.isec[start:]) for item in iterable])
666
+
667
+ aligned_arrays = []
668
+ for index, item in enumerate(iterable):
669
+ aligned_arrays.append(item.align_trace(start, maxlen))
670
+
671
+ return TimelinedArray(aligned_arrays)
672
+
673
+
674
+ class MaskedTimelinedArray(TimeMixin, np.ma.MaskedArray, TimeCompatibleProtocol):
675
+ def __new__(
676
+ cls,
677
+ data,
678
+ mask=np.ma.nomask,
679
+ dtype=None,
680
+ copy=False,
681
+ fill_value=None,
682
+ keep_mask=True,
683
+ hard_mask=False,
684
+ shrink=True,
685
+ timeline=None,
686
+ time_dimension=None,
687
+ uniform_space=False,
688
+ **kwargs,
689
+ ):
690
+
691
+ _, timeline, time_dimension = TimeMixin.extract_time_from_data(
692
+ data, timeline=timeline, time_dimension=time_dimension, uniform_space=uniform_space
693
+ )
694
+
695
+ obj = super().__new__(
696
+ cls,
697
+ data,
698
+ mask=mask,
699
+ dtype=dtype,
700
+ copy=copy,
701
+ fill_value=fill_value,
702
+ keep_mask=keep_mask,
703
+ hard_mask=hard_mask,
704
+ shrink=shrink,
705
+ **kwargs,
706
+ )
707
+
708
+ obj.timeline = timeline
709
+ obj.time_dimension = time_dimension
710
+ return obj
711
+
712
+ def __array_finalize__(self, obj):
713
+ super().__array_finalize__(obj)
714
+ if obj is None:
715
+ return
716
+ self.timeline = getattr(obj, "timeline", Timeline([]))
717
+ self.time_dimension = getattr(obj, "time_dimension", 0)
718
+
719
+ def __getitem__(
720
+ self, index: int | Tuple[int] | slice | Tuple[slice] | List | np.ndarray
721
+ ) -> "MaskedTimelinedArray | np.ma.MaskedArray":
722
+
723
+ index, final_timeline, final_time_dimension = self._get_indexed_times(index)
724
+
725
+ if final_timeline is None or final_time_dimension is None:
726
+ return np.ma.MaskedArray(data=np.asarray(self), mask=self.mask, fill_value=self.fill_value).__getitem__(
727
+ index
728
+ )
729
+
730
+ indexed_result = super().__getitem__(index)
731
+
732
+ logger.debug(
733
+ f"Current object : {self.array_info}.\n"
734
+ f"Newly indexed object : {type(indexed_result).__name__} of shape {indexed_result.shape}, "
735
+ f"time_dimension {final_time_dimension} and timeline shape {final_timeline.shape}"
736
+ )
737
+
738
+ return MaskedTimelinedArray(indexed_result, timeline=final_timeline, time_dimension=final_time_dimension)
739
+
740
+
741
+ class Seconds(float):
742
+ def to_index(self, fs):
743
+ """_summary_
744
+
745
+ Args:
746
+ fs (float or int): Sampling frequency in Hertz (samples per second)
747
+
748
+ Returns:
749
+ int: The samples index that this second corresponds to,
750
+ (if sample 0 is at 0 second) in an uniformly spaced time array.
751
+ """
752
+ return int(self * fs)
@@ -1,3 +0,0 @@
1
- __version__ = "0.0.2"
2
-
3
- from .time import TimelinedArray
@@ -1,356 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
-
3
- import numpy as np
4
-
5
- # TODO : do numpy.interp(np.arange(0, len(a), 1.5), np.arange(0, len(a)), a) interp to get a fixed number of points ?
6
-
7
-
8
- class TimelinedArray(np.ndarray):
9
- """
10
- The TimelinedArray class is a subclass of the numpy.ndarray class, which represents a multi-dimensional
11
- array of homogeneous data. This class adds additional functionality
12
- for working with arrays that have a time dimension, specifically:
13
-
14
- It defines a Timeline class, which is also a subclass of numpy.ndarray, and represents a timeline associated
15
- with the array. The Timeline class has several methods, including:
16
- arange_timeline: This method takes a timeline array and creates an evenly spaced timeline based
17
- on the start and stop time of the original timeline.
18
- timeline_step: This method returns the average time difference between each consecutive value in the timeline.
19
-
20
- TimelinedArrayIndexer class, which has several methods, including:
21
- seconds_to_index: This method converts time in seconds to index value.
22
- get_iindex: This method converts time in seconds to a slice object representing time.
23
-
24
- __new__ : This method is used to creates a new instance of the TimelinedArray class. It takes several optional
25
- arguments: timeline, time_dimension, arange_timeline, and timeline_is_arranged.
26
- It creates a TimelinedArrayIndexer object with the input array,
27
- and assigns the supplied timeline and dimension properties.
28
-
29
- It defines an indexer to access the TimelinedArray as if it was indexed by time instead of index
30
- It also adds an attribute time_dimension , and timeline_is_arranged to the class, which are used to keep track of
31
- the time dimension of the array and whether the timeline is arranged or not.
32
- It enables accessing the array with time instead of index, and it also tries to keep track of the time dimension
33
- and the timeline, so it can be used to correct indexed time.
34
-
35
- Example :
36
- ...
37
-
38
- """
39
-
40
- time_dimension: int
41
- timeline: np.ndarray
42
-
43
- class TA_Timeline(np.ndarray):
44
- def __new__(cls, input_array, uniform_space=False):
45
-
46
- if uniform_space:
47
- # if we want a uniformly spaced timeline from start to stop of the current timeline.
48
- obj = np.linspace(input_array[0], input_array[1], len(input_array)).view(cls)
49
-
50
- else:
51
- if isinstance(input_array, TimelinedArray.TA_Timeline):
52
- return input_array
53
- obj = np.asarray(input_array).view(cls)
54
-
55
- return obj
56
-
57
- def __array_finalize__(self, obj):
58
- pass
59
-
60
- def __setstate__(self, state):
61
- try:
62
- super().__setstate__(state[0:-2]) # old deserializer
63
- except TypeError:
64
- super().__setstate__(state) # new one
65
-
66
- def __contains__(self, time_value):
67
- if time_value >= self.min() and time_value <= self.max():
68
- return True
69
- return False
70
-
71
- def max(self):
72
- return super().max().item()
73
-
74
- def min(self):
75
- return super().min().item()
76
-
77
- class TA_Isec_Indexer:
78
-
79
- def __init__(self, array):
80
- self.array = array
81
-
82
- def seconds_to_index(self, index):
83
- # argument index may be a slice or a scalar. Units of index should be in second. Returns a slice as index
84
- # this is sort of a wrapper for get_iindex that does the heavy lifting.
85
- # this function just makes sure to pass arguments to it corectly depending
86
- # on if the time index is a single value or a slice.
87
- if isinstance(index, slice):
88
- return self.get_iindex(index.start, index.stop, index.step)
89
- else:
90
- return self.get_iindex(sec_start=index).start
91
-
92
- def _insert_time_index(self, time_index):
93
- # put the integer value at the position of time index at the right position
94
- # (time_dimension) in the tuple of all sliced dimensions
95
- full_index = [slice(None)] * len(self.array.shape)
96
- full_index[self.array.time_dimension] = time_index
97
-
98
- return tuple(full_index)
99
-
100
- def __getitem__(self, index):
101
- if hasattr(index, "__iter__"):
102
- # if not isinstance(index,(int,float,slice,np.integer,np.floating)):
103
- raise ValueError(
104
- "Isec allow only indexing on time dimension. Index must be either int, float or slice, not iterable"
105
- )
106
-
107
- iindex_time = self.seconds_to_index(index)
108
- full_iindex = self._insert_time_index(iindex_time)
109
- # print("new full index : ",iindex_time)
110
- return self.array[full_iindex]
111
-
112
- def get_iindex(self, sec_start=None, sec_stop=None, sec_step=None): # every value here is in seconds
113
- # converts a time index (follows a slice syntax, but in time units) to integer units
114
- timeline_max_step = np.absolute(np.diff(self.array.timeline)).max() * 2
115
-
116
- if sec_start is None:
117
- start = 0
118
- else:
119
- if sec_start >= self.array.timeline[0]:
120
- start = np.argmax(self.array.timeline >= sec_start)
121
- else:
122
- start = 0
123
-
124
- if abs(self.array.timeline[start] - sec_start) > timeline_max_step:
125
- raise IndexError(
126
- f"The start time value {sec_start} you searched for is not in the timeline of this array "
127
- f"(timeline starts at {self.array.timeline[0]}, allowed jitter = {timeline_max_step} :"
128
- " +/- 2 times the max step between two timeline points"
129
- )
130
-
131
- if sec_stop is None:
132
- stop = len(self.array.timeline)
133
- # elif sec_stop < 0 : Here we allowed for negative indexing but as timeline can have negative values
134
- # , i removed this posibility
135
- # stop = np.argmin(self.array.timeline<self.array.timeline[-1]+sec_stop)
136
- else:
137
- if sec_stop < self.array.timeline[-1]:
138
- stop = np.argmin(self.array.timeline < sec_stop)
139
- else:
140
- stop = len(self.array.timeline) - 1
141
-
142
- if abs(self.array.timeline[stop] - sec_stop) > timeline_max_step:
143
- raise IndexError(
144
- f"The end time value {sec_stop} you searched for is not in the timeline of this array "
145
- f"(timeline ends at {self.array.timeline[-1]} , allowed jitter = {timeline_max_step} : "
146
- "+/- 2 times the max step between two timeline points"
147
- )
148
-
149
- if sec_step is None:
150
- step = 1
151
- else:
152
- step = int(np.round(sec_step / self.array.timeline.step))
153
- if step < 1:
154
- step = 1
155
- return slice(start, stop, step)
156
-
157
- class TA_Packer:
158
- def __init__(self, array):
159
- self.array = array
160
-
161
- def __iter__(self):
162
- return iter((self.array.timeline, self.array.__array__()))
163
-
164
- def __new__(cls, input_array, timeline=None, time_dimension: int | None = None, uniform_space=False):
165
-
166
- _unpacking = False
167
- if (
168
- timeline is None
169
- ): # if timeline not explicitely passed as arg, we try to pick up the timeline of the input_array.
170
- # will rise after if input_array is not a timelined_array
171
- try:
172
- timeline = input_array.timeline
173
- except AttributeError:
174
- try: # if arguments are an uniform list of timelined array
175
- # (often use to make mean and std of synchonized timelines), we pick up the first one.
176
- for element in input_array:
177
- timeline = element.timeline
178
- _unpacking = True
179
- break
180
- except AttributeError:
181
- raise ValueError("timeline must be supplied if the input_array is not a TimelinedArray")
182
-
183
- if time_dimension is None: # same thing for the time dimension.
184
- try:
185
- time_dimension = int(input_array.time_dimension)
186
- except AttributeError:
187
- try: # if arguments are an uniform list of timelined array
188
- # (often use to make mean and std of synchonized timelines), we pick up the first one.
189
- # but it also means default numpy packing will set the new dimension as dimension 0.
190
- # As such, the current time dimension will have to be the time dimension of the listed elements,
191
- # +1 (a.k.a. shifted one dimension deeper)
192
- for element in input_array:
193
- time_dimension = int(element.time_dimension) + 1
194
- _unpacking = True
195
- break
196
- else:
197
- time_dimension = 0
198
- except AttributeError:
199
- time_dimension = 0
200
-
201
- timeline = TimelinedArray.TA_Timeline(timeline, uniform_space=uniform_space)
202
-
203
- if _unpacking and len(input_array.shape) <= time_dimension: # type: ignore
204
- input_array = np.stack(input_array)
205
-
206
- obj = np.asarray(input_array).view(
207
- cls
208
- ) # instanciate the np array as a view, as per numpy documentation on how to make ndarray child classes
209
-
210
- if obj.shape[time_dimension] != len(timeline):
211
- raise ValueError(
212
- "timeline object and the shape of time_dimension of the input_array must be equal. "
213
- f"They are : {len(timeline)} and {obj.shape[time_dimension]}"
214
- )
215
-
216
- obj.timeline = timeline
217
- obj.time_dimension = time_dimension
218
- return obj
219
-
220
- def __array_finalize__(self, obj):
221
- if obj is None:
222
- return
223
- # else, we can reassign attributes of the old array, after a transformation for example .mean, etc
224
- # TODO : change here to include safechecks to change the timeline if the array changed.
225
- # maybe using original_shape as a memo of the shape before transormation,
226
- # to find out wich dimension was reduced ?
227
- self.timeline = getattr(obj, "timeline", np.array([]))
228
- self.time_dimension = getattr(obj, "time_dimension", 0)
229
-
230
- def rebase_timeline(self):
231
- # returns a modified version of the array, with the first element of the array to time zero,
232
- # and shift the rest accordingly
233
- return TimelinedArray(self, timeline=self.timeline - self.timeline[0])
234
-
235
- def offset_timeline(self, offset):
236
- # returns a modified version of the array, where we set time of all elements
237
- # in array at a fix offset relative to their current value.
238
- return TimelinedArray(self, timeline=self.timeline + offset)
239
-
240
- @property
241
- def pack(self):
242
- return TimelinedArray.TA_Packer(self)
243
-
244
- def __getitem__(self, index):
245
- # we re-implement the index getting method of numpy, to crop the timeline with the array,
246
- # when the array is asked to be cropped.
247
-
248
- # first we must check wether the time dimension has evolved (less/more dimensions)
249
- # if index is not iterable, then we affect only dimension 0
250
- # we define a placeholder as a tuple just for code flexibility to all cases
251
-
252
- # just in case index is a numpy array with one element, we get it as a single element and not an array
253
- # as an array has __iter__ but a single element array has no __len__, so the next code would break
254
- if isinstance(index, np.ndarray):
255
- if index.size == 1:
256
- index = index.item()
257
-
258
- _index = index if hasattr(index, "__iter__") else (index,)
259
- _time_dimension = self.time_dimension
260
- _time_dimension_in_index = self.time_dimension
261
-
262
- for dimension in range(len(_index)):
263
- if dimension > _time_dimension_in_index:
264
- continue # if the _index[dimension] changes above time dimension,
265
- # we don't care as it will not affect it's position.
266
- if _index[dimension] is None: # np.newaxis is a placeholder for None. a None in an index == a new axis.
267
- # in that case, it means a dimension was added before time_dimension, so we will shift it by one.
268
- _time_dimension += 1
269
- _time_dimension_in_index += (
270
- 1 # we also will look at values for time dimension here to apply to timeline later
271
- )
272
- elif isinstance(_index[dimension], (int, np.integer)):
273
- if dimension == _time_dimension:
274
- # if the time dimension IS an integer, the use will loose time anyway
275
- # (getting a single point in time)
276
- # so in that case, we stop further thinking in term of TimelinedArray and return a standard array,
277
- # indexed as wanted.
278
- return np.array(self).__getitem__(index)
279
- # otherwise it is below, so in that case, if one dimension is removed,
280
- # bu selecting an single value or that dimension, we substract one to time dimension.
281
- _time_dimension -= 1
282
- # here we don't remove one to _time_dimension_in_index.
283
- # #This -1 removal is for the new array, but the current array still has some somension here that will
284
- # be indexed,and we need to know that value for timeline.
285
-
286
- # (index, _time_dimension, _time_dimension_in_index)
287
-
288
- if len(_index) >= _time_dimension_in_index + 1:
289
- # if a part of the index was destined to reshape the time dimension,
290
- # we apply this reshaping to timeline too.
291
- _timeline = self.timeline[_index[_time_dimension_in_index]]
292
- else:
293
- _timeline = self.timeline # if time_dimension is not part of index, then timeline doesn't change.
294
-
295
- return TimelinedArray(super().__getitem__(index), timeline=_timeline, time_dimension=_time_dimension)
296
-
297
- @property
298
- def isec(self):
299
- """Allows to use my_array.isec[12.5] to get element closes to time 12.5 in the array's timeline.
300
- Works with spans of time, and uses item based index for dimensions that are not a time_dimension
301
-
302
- Returns:
303
- TA_Isec_Indexer: the objects that can index on the TimelinedArray,
304
- and returns a TimelinedArray or standard nparray upon __getitem__ call.
305
- (Standard ndarray array if the time dimension collapses to size 1)
306
- """
307
- return TimelinedArray.TA_Isec_Indexer(self)
308
-
309
- def sec_max(self):
310
- # get maximum time
311
- return self.timeline.max()
312
-
313
- def sec_min(self):
314
- # get minimum time
315
- return self.timeline.min()
316
-
317
- def __reduce__(self):
318
- # Get the parent's __reduce__ tuple
319
- pickled_state = super().__reduce__()
320
- # Create our own tuple to pass to __setstate__
321
- new_state = pickled_state[2] + (self.timeline, self.time_dimension) # type: ignore
322
-
323
- # Return a tuple that replaces the parent's __setstate__ tuple with our own
324
- return (pickled_state[0], pickled_state[1], new_state)
325
-
326
- def __setstate__(self, state):
327
- self.timeline = state[-2] # Set the info attribute
328
- self.time_dimension = state[-1]
329
- # Call the parent's __setstate__ with the other tuple elements.
330
- super().__setstate__(state[0:-2])
331
-
332
- def __hash__(self):
333
- return hash((self.__array__(), self.timeline))
334
-
335
- # THESE ARE OVERRIDEN TO AVOID HORRIBLE PERFORMANCE WHEN PRINTING
336
- # DUE TO CUSTOM __GETITEM__ PRE-CHECKS WITH RECURSIVE NATIVE NUMPY REPR
337
- def __repr__(self):
338
- return type(self).__name__ + np.array(self).__repr__()[5:]
339
-
340
- def __str__(self):
341
- return type(self).__name__ + np.array(self).__str__()
342
-
343
- def align_trace(self, start: float, element_nb: int):
344
- """Aligns the timelined array by making it start from a timepoint in time-units (synchronizing)
345
- and cutting the array N elements after the start point.
346
-
347
- Args:
348
- start (float): Start point, in time-units. (usually seconds) Time index based, so can be float or integer.
349
- element_nb (int): Cuts the returned array at 'element_nb' amount of elements, after the starting point.
350
- It is item index based, not time index based, so it must necessarily be an integer.
351
-
352
- Returns:
353
- TimelinedArray: The synchronized and cut arrray.
354
- """
355
-
356
- return self.isec[start:][:element_nb]
File without changes