litoid 0.1.0__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.
Files changed (49) hide show
  1. litoid-0.1.0/LICENSE +21 -0
  2. litoid-0.1.0/PKG-INFO +35 -0
  3. litoid-0.1.0/README.md +2 -0
  4. litoid-0.1.0/litoid/__init__.py +0 -0
  5. litoid-0.1.0/litoid/__main__.py +19 -0
  6. litoid-0.1.0/litoid/app.py +9 -0
  7. litoid-0.1.0/litoid/io/__init__.py +0 -0
  8. litoid-0.1.0/litoid/io/dmx.py +19 -0
  9. litoid-0.1.0/litoid/io/hotkey.py +29 -0
  10. litoid-0.1.0/litoid/io/key_mouse.py +26 -0
  11. litoid-0.1.0/litoid/io/midi/__init__.py +3 -0
  12. litoid-0.1.0/litoid/io/midi/message.py +219 -0
  13. litoid-0.1.0/litoid/io/midi/midi.py +61 -0
  14. litoid-0.1.0/litoid/io/osc.py +41 -0
  15. litoid-0.1.0/litoid/io/player.py +64 -0
  16. litoid-0.1.0/litoid/io/recorder.py +93 -0
  17. litoid-0.1.0/litoid/io/track.py +66 -0
  18. litoid-0.1.0/litoid/litoid.py +33 -0
  19. litoid-0.1.0/litoid/log.py +53 -0
  20. litoid-0.1.0/litoid/scenes/__init__.py +0 -0
  21. litoid-0.1.0/litoid/scenes/compose.py +18 -0
  22. litoid-0.1.0/litoid/scenes/rgb_keyboard.py +24 -0
  23. litoid-0.1.0/litoid/state/__init__.py +0 -0
  24. litoid-0.1.0/litoid/state/instrument.py +86 -0
  25. litoid-0.1.0/litoid/state/instruments.py +40 -0
  26. litoid-0.1.0/litoid/state/lamp.py +67 -0
  27. litoid-0.1.0/litoid/state/level.py +16 -0
  28. litoid-0.1.0/litoid/state/scene.py +34 -0
  29. litoid-0.1.0/litoid/state/state.py +102 -0
  30. litoid-0.1.0/litoid/ui/__init__.py +0 -0
  31. litoid-0.1.0/litoid/ui/action.py +103 -0
  32. litoid-0.1.0/litoid/ui/canvas_window.py +22 -0
  33. litoid-0.1.0/litoid/ui/controller.py +110 -0
  34. litoid-0.1.0/litoid/ui/defaults.py +11 -0
  35. litoid-0.1.0/litoid/ui/drawing_canvas.py +44 -0
  36. litoid-0.1.0/litoid/ui/event.py +34 -0
  37. litoid-0.1.0/litoid/ui/layout.py +72 -0
  38. litoid-0.1.0/litoid/ui/model.py +96 -0
  39. litoid-0.1.0/litoid/ui/ui.py +71 -0
  40. litoid-0.1.0/litoid/ui/view.py +66 -0
  41. litoid-0.1.0/litoid/util/__init__.py +0 -0
  42. litoid-0.1.0/litoid/util/file.py +22 -0
  43. litoid-0.1.0/litoid/util/has_thread.py +38 -0
  44. litoid-0.1.0/litoid/util/is_running.py +25 -0
  45. litoid-0.1.0/litoid/util/play.py +25 -0
  46. litoid-0.1.0/litoid/util/smooth.py +19 -0
  47. litoid-0.1.0/litoid/util/thread_queue.py +28 -0
  48. litoid-0.1.0/litoid/util/timed_heap.py +45 -0
  49. litoid-0.1.0/pyproject.toml +38 -0
litoid-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Tom Ritchford
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
litoid-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.1
2
+ Name: litoid
3
+ Version: 0.1.0
4
+ Summary: 💡Sequence DMX lighting 💡
5
+ License: MIT
6
+ Author: Tom Ritchford
7
+ Author-email: tom@swirly.com
8
+ Requires-Python: >=3.11
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Requires-Dist: clsprop (>=1.1.0)
13
+ Requires-Dist: datacls (>=4.6.0)
14
+ Requires-Dist: distinctipy (>=1.2.2)
15
+ Requires-Dist: dtyper (>=2.1.0)
16
+ Requires-Dist: ipython (>=8.12.0)
17
+ Requires-Dist: matplotlib (>=3.7.1)
18
+ Requires-Dist: mido (>=1.2.10)
19
+ Requires-Dist: numpy (>=1.24.2)
20
+ Requires-Dist: psgdemos (>=1.12.1)
21
+ Requires-Dist: pyenttec (>=1.4)
22
+ Requires-Dist: pynput (>=1.7.6)
23
+ Requires-Dist: pyperclip (>=1.8.2)
24
+ Requires-Dist: pysimplegui (>=4.60.4)
25
+ Requires-Dist: python-osc (>=1.8.1)
26
+ Requires-Dist: python-rtmidi (>=1.5.4)
27
+ Requires-Dist: simpleaudio (>=1.0.4)
28
+ Requires-Dist: tomlkit (>=0.11.6)
29
+ Requires-Dist: typer (>=0.7.0)
30
+ Requires-Dist: xmod (>=1.4.0)
31
+ Description-Content-Type: text/markdown
32
+
33
+ # litoid
34
+ Sequence DMX lighting
35
+
litoid-0.1.0/README.md ADDED
@@ -0,0 +1,2 @@
1
+ # litoid
2
+ Sequence DMX lighting
File without changes
@@ -0,0 +1,19 @@
1
+ def cli():
2
+ from . import app, litoid
3
+
4
+ assert litoid
5
+ app.app(standalone_mode=False)
6
+
7
+
8
+ def gui():
9
+ from .ui.controller import Controller
10
+ import time
11
+
12
+ try:
13
+ Controller().start()
14
+ finally:
15
+ time.sleep(0.1) # Allow daemon threads to print tracebacks
16
+
17
+
18
+ if __name__ == '__main__':
19
+ gui()
@@ -0,0 +1,9 @@
1
+ from dtyper import Argument, Option, Typer
2
+
3
+ __all__ = 'Argument', 'Option', 'Typer', 'app', 'command'
4
+
5
+ app = Typer(
6
+ add_completion=False,
7
+ context_settings={'help_option_names': ['-h', '--help']},
8
+ )
9
+ command = app.command
File without changes
@@ -0,0 +1,19 @@
1
+ from functools import cached_property
2
+ import datacls
3
+ import pyenttec
4
+
5
+
6
+ @datacls.mutable
7
+ class DMX:
8
+ port: str
9
+
10
+ @cached_property
11
+ def connection(self):
12
+ return pyenttec.DMXConnection(self.port)
13
+
14
+ @cached_property
15
+ def frame(self):
16
+ return memoryview(self.connection.dmx_frame)
17
+
18
+ def send_packet(self):
19
+ self.connection.render()
@@ -0,0 +1,29 @@
1
+ from ..util.has_thread import HasThread
2
+ from functools import cached_property, partial
3
+ from pynput import keyboard
4
+ from typing import Callable
5
+ import datacls
6
+
7
+
8
+ @datacls.mutable
9
+ class HotKeys(HasThread):
10
+ keys: dict[str, str] = datacls.field(dict)
11
+ callback: Callable = print
12
+
13
+ @cached_property
14
+ def hotkeys(self):
15
+ def cmd(k):
16
+ return k if '<' in k else f'<cmd>+{k}'
17
+
18
+ k = {cmd(k): partial(self.callback, v) for k, v in self.keys.items()}
19
+ return keyboard.GlobalHotKeys(k)
20
+
21
+ def _target(self):
22
+ with self.hotkeys as h:
23
+ h.join()
24
+
25
+
26
+ if __name__ == '__main__':
27
+ s = 'abcdefghijklmnopqrstuvwxyz'
28
+ keys = [f'<ctrl>+{i}' for i in s]
29
+ HotKeys(keys)._target()
@@ -0,0 +1,26 @@
1
+ from ..util.has_thread import HasThread
2
+ from typing import Callable
3
+ import datacls
4
+ import pynput
5
+
6
+
7
+ @datacls
8
+ class _Base(HasThread):
9
+ callback: Callable = print
10
+
11
+ def _target(self):
12
+ with self.event_module.Events() as events:
13
+ for msg in events:
14
+ self.callback(msg)
15
+
16
+ @classmethod
17
+ def Message(cls) -> type:
18
+ return cls.event_module.Events.Event
19
+
20
+
21
+ class Keyboard(_Base):
22
+ event_module = pynput.keyboard
23
+
24
+
25
+ class Mouse(_Base):
26
+ event_module = pynput.mouse
@@ -0,0 +1,3 @@
1
+ __all__ = 'MidiInput',
2
+
3
+ from . midi import MidiInput
@@ -0,0 +1,219 @@
1
+ import clsprop
2
+ import xmod
3
+
4
+ _PITCH = 0xe0
5
+ _SYSEX = 0xF0
6
+
7
+
8
+ @xmod
9
+ class MidiMessage:
10
+ fields = ()
11
+ status_start = 0
12
+
13
+ __slots__ = 'data', 'time'
14
+
15
+ def __init__(self, data=None, time=0, **kwargs):
16
+ self.time = time
17
+
18
+ if data is not None:
19
+ self.data = data
20
+ else:
21
+ self.data = (
22
+ [self.status_start]
23
+ + [kwargs.pop(f, 0) or 0 for f in self.fields]
24
+ )
25
+
26
+ if bad := [k for k, v in kwargs.items() if v is not None]:
27
+ assert (data is None) or not kwargs, f'{data=} {kwargs=}'
28
+ s = 's' * (len(bad) != 1)
29
+ raise ValueError(f'Extra parameter{s}: {list(kwargs)}')
30
+
31
+ def __len__(self):
32
+ return len(self.data)
33
+
34
+ def __getitem__(self, i):
35
+ return self.data[i]
36
+
37
+ @clsprop
38
+ def has_channel(cls) -> bool:
39
+ return _has_channel(cls.status_start)
40
+
41
+ @clsprop
42
+ def size(cls) -> int:
43
+ return (
44
+ len(cls.fields)
45
+ + (not cls.has_channel)
46
+ + (cls.status_start == _PITCH)
47
+ )
48
+
49
+ @property
50
+ def status(self):
51
+ return self.data[0]
52
+
53
+ def asdict(self):
54
+ return {k: getattr(self, k) for k in self.fields}
55
+
56
+ def __new__(cls, data=None, time=0, **kwargs):
57
+ if data is not None and kwargs:
58
+ raise ValueError('Cannot specify data and named args together')
59
+
60
+ try:
61
+ cls = MESSAGE_CLASSES[data[0] - 128]
62
+ except IndexError:
63
+ cls = None
64
+
65
+ if cls is None:
66
+ raise ValueError(f'{data=} is not a MIDI packet')
67
+
68
+ if cls.status_start != _SYSEX and len(data) != cls.size:
69
+ msg = f'Wrong MIDI packet length: {len(data)} != {cls.size}'
70
+ raise ValueError(msg)
71
+
72
+ self = super().__new__(cls)
73
+ cls.__init__(self, data, time, **kwargs)
74
+ return self
75
+
76
+
77
+ class MidiChannelMessage(MidiMessage):
78
+ def __init__(self, data=None, time=0, **kwargs):
79
+ channel = kwargs.pop('channel', None)
80
+
81
+ if is_pitch := self.status_start == _PITCH:
82
+ pitch = kwargs.pop('pitch', 0)
83
+ super().__init__(data, time, **kwargs)
84
+ if is_pitch:
85
+ self.pitch = pitch
86
+ if channel is not None:
87
+ self.channel = channel
88
+
89
+ @property
90
+ def channel(self):
91
+ return self.status & 0x0F
92
+
93
+ @channel.setter
94
+ def channel(self, channel):
95
+ self.status = (self.status & 0xF0) | channel
96
+
97
+ def asdict(self):
98
+ return super().asdict() | {'channel': self.channel}
99
+
100
+
101
+ def _has_channel(status: int) -> bool:
102
+ return status < _SYSEX
103
+
104
+
105
+ def _class(status_start, name, *props):
106
+ if _has_channel(status_start):
107
+ parent = MidiChannelMessage
108
+ if status_start == _PITCH:
109
+ fields = 'pitch',
110
+
111
+ fields = 'channel', *props
112
+ else:
113
+ parent = MidiMessage
114
+ fields = props
115
+
116
+ members = {
117
+ '__init__': _init(parent, fields),
118
+ '__repr__': _repr(fields),
119
+ 'fields': fields,
120
+ 'status_start': status_start,
121
+ }
122
+
123
+ props = {prop: _prop(i + 1, prop) for i, prop in enumerate(props)}
124
+ cls = type(name, (parent,), members | _pitch(status_start) | props)
125
+ globals()[name] = cls
126
+ return cls
127
+
128
+
129
+ def _prop(i, field):
130
+ def getter(self):
131
+ return self.data[i]
132
+
133
+ def setter(self, d):
134
+ self.data[i] = d
135
+
136
+ getter.__name__ = setter.__name__ = field
137
+ return property(getter, setter)
138
+
139
+
140
+ def _init(parent, fields):
141
+ # Construct the constructor!
142
+ if params_in := ', '.join(f'{f}=None' for f in fields):
143
+ params_in = f', *, {params_in}'
144
+
145
+ if params_out := ', '.join(f'{f}={f}' for f in fields):
146
+ params_out = f', {params_out}'
147
+
148
+ definition = f'lambda self, data=None, time=0{params_in}:'
149
+ result = f'{parent.__name__}.__init__(self, data, time{params_out})'
150
+ init = eval(f'{definition} {result}')
151
+ init.__name__ = '__init__'
152
+ return init
153
+
154
+
155
+ def _pitch(status_start):
156
+ if status_start != _PITCH:
157
+ return {}
158
+
159
+ pitch_offset = 0x2000
160
+
161
+ def getter(self):
162
+ return 0x80 * self.pitch_hsb + self.pitch_lsb - self.pitch_offset
163
+
164
+ def setter(self, p):
165
+ self.pitch_hsb, self.pitch_lsb = divmod(p + self.pitch_offset, 0x80)
166
+
167
+ return {
168
+ 'pitch': property(getter, setter),
169
+ 'pitch_offset': pitch_offset,
170
+ }
171
+
172
+
173
+ def _repr(fields):
174
+ # See dataclasses.py around line 595
175
+ fields = *fields, 'time'
176
+ params = ', '.join(f'{f}={{self.{f}!r}}' for f in fields)
177
+ classname = '{self.__class__.__qualname__}'
178
+ rep = eval(f'lambda self: f"{classname}({params})"')
179
+ rep.__name__ = '__repr__'
180
+ return rep
181
+
182
+
183
+ _CHANNEL = (
184
+ # Channel messages
185
+ _class(0x80, 'NoteOff', 'note', 'velocity'),
186
+ _class(0x90, 'NoteOn', 'note', 'velocity'),
187
+ _class(0xa0, 'Polytouch', 'note', 'value'),
188
+ _class(0xb0, 'ControlChange', 'control', 'value'),
189
+ _class(0xc0, 'ProgramChange', 'program'),
190
+ _class(0xd0, 'Aftertouch', 'value'),
191
+ _class(_PITCH, 'Pitch', 'pitch_lsb', 'pitch_hsb'),
192
+ )
193
+
194
+ _SYSTEM = (
195
+ # System common messages.
196
+ _class(_SYSEX, 'Sysex'),
197
+ _class(0xf1, 'QuarterFrame', 'frame_type', 'frame_value'),
198
+ _class(0xf2, 'Songpos', 'pos'),
199
+ _class(0xf3, 'SongSelect', 'song'),
200
+
201
+ None, # 0xf4
202
+ None, # 0xf5
203
+ _class(0xf6, 'TuneRequest'),
204
+ None, # 0xf7 is sysex end
205
+
206
+ # System real time messages.
207
+ _class(0xf8, 'Clock'),
208
+ None, # 0xf9
209
+ _class(0xfa, 'Start'),
210
+ _class(0xfb, 'Continue'),
211
+
212
+ _class(0xfc, 'Stop'),
213
+ None, # 0xfd
214
+ _class(0xfe, 'ActiveSensing'),
215
+ _class(0xff, 'Reset'),
216
+ )
217
+
218
+ MESSAGE_CLASSES = tuple(c for c in _CHANNEL for i in range(16)) + _SYSTEM
219
+ __all__ = ('MidiMessage',) + tuple(c.__name__ for c in _CHANNEL + _SYSTEM if c)
@@ -0,0 +1,61 @@
1
+ from .message import MidiMessage
2
+ from functools import cached_property
3
+ from litoid.util.has_thread import HasThread
4
+ from rtmidi import midiutil, MidiIn
5
+ from typing import Callable
6
+ import datacls
7
+ import mido
8
+ import time
9
+ SPIN_TIME = 0.003
10
+
11
+
12
+ @datacls
13
+ class MidiOutput:
14
+ name: str | None = None
15
+
16
+ @cached_property
17
+ def output_name(self):
18
+ return self.name or sorted(mido.get_output_names()[0])
19
+
20
+ @cached_property
21
+ def output(self):
22
+ return mido.open_output(self.name)
23
+
24
+
25
+ @datacls.mutable
26
+ class MidiInput(HasThread):
27
+ callback: Callable = print
28
+ make_message: bool = True
29
+ name: str = ''
30
+ last_event_time: float = 0
31
+
32
+ def _target(self):
33
+ midiin, port_name = midiutil.open_midiinput(self.input_name)
34
+ try:
35
+ while True:
36
+ while not (msg := midiin.get_message()):
37
+ time.sleep(SPIN_TIME)
38
+
39
+ mbytes, deltatime = msg
40
+ if self.last_event_time:
41
+ self.last_event_time += deltatime
42
+ else:
43
+ # Ignore the first deltatime, which seems to be 0 anyway
44
+ self.last_event_time = time.time()
45
+
46
+ if self.make_message:
47
+ msg = MidiMessage(mbytes, time=self.last_event_time)
48
+ self.callback(msg)
49
+ finally:
50
+ midiin.close_port()
51
+
52
+ @cached_property
53
+ def midiin(self):
54
+ return MidiIn(midiutil.get_api_from_environment())
55
+
56
+ def get_input_names(self):
57
+ return self.midiin.get_ports()
58
+
59
+ @cached_property
60
+ def input_name(self):
61
+ return self.name or sorted(self.get_input_names())[0]
@@ -0,0 +1,41 @@
1
+ from ..util.thread_queue import ThreadQueue
2
+ from functools import cached_property
3
+ from pythonosc.dispatcher import Dispatcher
4
+ from pythonosc.osc_server import BlockingOSCUDPServer
5
+ from typing import Callable
6
+ import datacls
7
+
8
+
9
+ @datacls.mutable
10
+ class Desc:
11
+ endpoints: tuple[str] = ()
12
+ ip: str = '127.0.0.1'
13
+ port: int = 5005
14
+ maxsize: int = 0
15
+ thread_count: int = 1
16
+
17
+ def server(self):
18
+ return BlockingOSCUDPServer(self.ip, self.port, self.dispatcher)
19
+
20
+
21
+ class Message(tuple):
22
+ pass
23
+
24
+
25
+ @datacls.mutable
26
+ class Server(Desc, ThreadQueue):
27
+ callback: Callable = print
28
+
29
+ def serve(self):
30
+ self.start()
31
+ self.desc.server().serve_forever()
32
+
33
+ @cached_property
34
+ def dispatcher(self):
35
+ d = Dispatcher()
36
+ for e in self.endpoints:
37
+ d.map(f'/{e}', self.osc_callback)
38
+ return d
39
+
40
+ def osc_callback(self, address, *osc_args):
41
+ self.put(Message(address, *osc_args))
@@ -0,0 +1,64 @@
1
+ from .recorder import Recorder
2
+ from .track import Track
3
+ from functools import cached_property, total_ordering
4
+ from litoid.util.timed_heap import TimedHeap
5
+ from typing import Callable
6
+ import datacls
7
+ import time
8
+
9
+ INFINITE = float('inf')
10
+
11
+
12
+ @datacls.mutable
13
+ class Player:
14
+ recorder: Recorder
15
+ callback: Callable = print
16
+ offset_time: float = 0
17
+ start_time: float = 0
18
+
19
+ @cached_property
20
+ def timed_heap(self) -> TimedHeap:
21
+ kt = ((k, t) for k, t in self.record.tracks.items() if t)
22
+ players = (TrackPlayer(self, k, t) for k, t in kt)
23
+ return TimedHeap([tp for tp in players if tp])
24
+
25
+ def start(self):
26
+ self.start_time = time.time()
27
+ self.timed_heap.start()
28
+
29
+ @cached_property
30
+ def net_offset(self) -> float:
31
+ return self.offset_time + self.start_time - self.recorder.start_time
32
+
33
+
34
+ @total_ordering
35
+ @datacls.mutable(order=False)
36
+ class TrackPlayer:
37
+ player: Player
38
+ key: tuple[int, ...]
39
+ track: Track
40
+ position: int = 0
41
+
42
+ def __len__(self):
43
+ return len(self.track) - self.position
44
+
45
+ @property
46
+ def timestamp(self) -> float:
47
+ if not self:
48
+ return INFINITE
49
+ return self._timestamp() + self.player.net_offset
50
+
51
+ def __lt__(self, x) -> bool:
52
+ return self._timestamp() < x._timestamp()
53
+
54
+ def _timestamp(self):
55
+ return self.track.times[self.position]
56
+
57
+ def __call__(self):
58
+ msg_data = self.track.get_message(self.position)
59
+ self.position += 1
60
+
61
+ self.player.callback(self.key, msg_data)
62
+
63
+ if self:
64
+ self.player.timed_heap.push(self)
@@ -0,0 +1,93 @@
1
+ from .track import Track
2
+ from litoid import log
3
+ from pathlib import Path
4
+ import datacls
5
+ import numpy as np
6
+ import time as _time
7
+
8
+ SEP = '-'
9
+
10
+
11
+ @datacls.mutable
12
+ class Recorder:
13
+ """
14
+ A Recorder record and timestamps messages which are sequences of bytes,
15
+ like MIDI or DMX.
16
+
17
+ The first one or more bytes are used as a key into a dict of Tracks,
18
+ and the remaining bytes are stored in a `uint8` array, and the timestamps
19
+ in a parallel `float64` array
20
+
21
+ This only works for protocols where you can deduce the length of the packet
22
+ from the initial bytes only.
23
+ """
24
+ name: str
25
+ path: Path | None = None
26
+ tracks: dict = datacls.field(dict[str, Track])
27
+ start_time: float = datacls.field(_time.time)
28
+ update_time: float = datacls.field(_time.time)
29
+
30
+ def __post_init__(self):
31
+ if self.path and self.path.exists():
32
+ self._fill_from_dict(np.load(self.path))
33
+ log.debug(f'Loaded {self.name}', self.report())
34
+
35
+ def record(self, data: list, key_size: int, time: float = 0):
36
+ time = time or _time.time()
37
+ key = SEP.join(str(i) for i in data[:key_size])
38
+
39
+ byte_width = len(data) - key_size
40
+ if (track := self.tracks.get(key)) is None:
41
+ track = Track(byte_width)
42
+ self.tracks[key] = track
43
+ else:
44
+ assert track.byte_width == byte_width
45
+
46
+ track.append(data[key_size:], time)
47
+ self.update_time = time
48
+
49
+ if empty := sorted(k for k, v in self.tracks.items() if not v.empty):
50
+ log.error('Empty tracks', *empty)
51
+
52
+ def save(self):
53
+ if self.path:
54
+ np.savez(self.path, **self.asdict())
55
+ log.debug(f'Saved {self.name}', self.report())
56
+
57
+ def report(self):
58
+ return {
59
+ 'event_count': {k: t.count for k, t in self.tracks.items()},
60
+ 'total_event_count': sum(t.count for t in self.tracks.values()),
61
+ 'track_count': len(self.tracks),
62
+ }
63
+
64
+ def plottable(self):
65
+ return [i for t in self.tracks.values() for i in t.astuple()]
66
+
67
+ def asdict(self):
68
+ data = {'times': np.array((self.start_time, self.update_time))}
69
+
70
+ for key, track in sorted(self.tracks.items()):
71
+ for name, array in track.asdict().items():
72
+ if len(array):
73
+ joined_key = SEP.join((key, name))
74
+ data[joined_key] = array
75
+
76
+ return data
77
+
78
+ @classmethod
79
+ def fromdict(cls, d):
80
+ c = cls()
81
+ c._fill_from_dict(d)
82
+ return c
83
+
84
+ def _fill_from_dict(self, d):
85
+ parts = {}
86
+ for joined_key, array in d.items():
87
+ if joined_key == 'times':
88
+ self.start_time, self.update_time = array
89
+ else:
90
+ key, _, name = joined_key.rpartition(SEP)
91
+ parts.setdefault(key, {})[name] = array
92
+
93
+ self.tracks = {k: Track.fromdict(**v) for k, v in parts.items()}