auto-trainer-core 2.0.1.dev353__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.
Files changed (73) hide show
  1. auto_trainer_core-2.0.1.dev353.dist-info/METADATA +18 -0
  2. auto_trainer_core-2.0.1.dev353.dist-info/RECORD +73 -0
  3. auto_trainer_core-2.0.1.dev353.dist-info/WHEEL +5 -0
  4. auto_trainer_core-2.0.1.dev353.dist-info/top_level.txt +1 -0
  5. autotrainer/core/__init__.py +319 -0
  6. autotrainer/core/analysis/__init__.py +6 -0
  7. autotrainer/core/analysis/alarm_monitor.py +484 -0
  8. autotrainer/core/analysis/audio_spectrum_data.py +27 -0
  9. autotrainer/core/analysis/audio_spectrum_monitor.py +134 -0
  10. autotrainer/core/analysis/auto_tunnel_fan_monitor.py +92 -0
  11. autotrainer/core/analysis/detector.py +179 -0
  12. autotrainer/core/analysis/external_doors_monitor.py +124 -0
  13. autotrainer/core/analysis/global_animal_presence_monitor.py +69 -0
  14. autotrainer/core/analysis/head_fix_measurement.py +15 -0
  15. autotrainer/core/analysis/headbar_pressure_monitor.py +158 -0
  16. autotrainer/core/analysis/load_cell_monitor.py +377 -0
  17. autotrainer/core/analysis/load_cell_tare_monitor.py +217 -0
  18. autotrainer/core/analysis/measurement_data.py +80 -0
  19. autotrainer/core/analysis/pellet_position_monitor.py +108 -0
  20. autotrainer/core/analysis/sensor_analysis.py +390 -0
  21. autotrainer/core/analysis/system_fault_monitor.py +81 -0
  22. autotrainer/core/analysis/system_maintenance_monitor.py +88 -0
  23. autotrainer/core/animal/__init__.py +1 -0
  24. autotrainer/core/animal/animal_subject.py +208 -0
  25. autotrainer/core/capture.py +25 -0
  26. autotrainer/core/configuration/__init__.py +88 -0
  27. autotrainer/core/configuration/alarm_configuration.py +55 -0
  28. autotrainer/core/configuration/animal_presence_configuration.py +17 -0
  29. autotrainer/core/configuration/behavior_configuration.py +323 -0
  30. autotrainer/core/configuration/camera_configuration.py +108 -0
  31. autotrainer/core/configuration/external_doors_monitor_configuration.py +9 -0
  32. autotrainer/core/configuration/hardware_configuration.py +30 -0
  33. autotrainer/core/configuration/inference_configuration.py +26 -0
  34. autotrainer/core/configuration/load_cell_config.py +61 -0
  35. autotrainer/core/configuration/persistence_configuration.py +29 -0
  36. autotrainer/core/configuration/presence_detection_configuration.py +9 -0
  37. autotrainer/core/configuration/system_configuration.py +211 -0
  38. autotrainer/core/configuration/system_fault_config.py +9 -0
  39. autotrainer/core/configuration/system_maintenance_config.py +17 -0
  40. autotrainer/core/diamond_triangle_config.py +150 -0
  41. autotrainer/core/event/__init__.py +54 -0
  42. autotrainer/core/event/api_event_kind.py +5 -0
  43. autotrainer/core/event/api_event_plugin.py +57 -0
  44. autotrainer/core/event/event_info.py +53 -0
  45. autotrainer/core/event/event_manager.py +296 -0
  46. autotrainer/core/event/event_manager_plugin.py +45 -0
  47. autotrainer/core/event/file_event_plugin.py +91 -0
  48. autotrainer/core/event/logger_event_plugin.py +43 -0
  49. autotrainer/core/fixed_array_multiqueue.py +263 -0
  50. autotrainer/core/fixed_array_queue.py +124 -0
  51. autotrainer/core/frame_index.py +34 -0
  52. autotrainer/core/interfaces.py +347 -0
  53. autotrainer/core/logging.py +714 -0
  54. autotrainer/core/message/__init__.py +26 -0
  55. autotrainer/core/message/audio_spectrum_message.py +21 -0
  56. autotrainer/core/message/measurement_message.py +61 -0
  57. autotrainer/core/message/message_handler.py +158 -0
  58. autotrainer/core/message/motor_configuration_message.py +128 -0
  59. autotrainer/core/message/system_command_kind.py +74 -0
  60. autotrainer/core/message/system_message_handler.py +122 -0
  61. autotrainer/core/message/system_status_message.py +112 -0
  62. autotrainer/core/multiproc.py +50 -0
  63. autotrainer/core/notification/__init__.py +2 -0
  64. autotrainer/core/notification/notification_center.py +80 -0
  65. autotrainer/core/notification/trigger_notification.py +27 -0
  66. autotrainer/core/observable_object.py +105 -0
  67. autotrainer/core/perf_monitor.py +77 -0
  68. autotrainer/core/pose_elements.py +196 -0
  69. autotrainer/core/project/__init__.py +19 -0
  70. autotrainer/core/project/project_info.py +486 -0
  71. autotrainer/core/queue_util.py +60 -0
  72. autotrainer/core/reach_event.py +38 -0
  73. autotrainer/core/video_detection.py +326 -0
@@ -0,0 +1,377 @@
1
+ import itertools
2
+ import math
3
+
4
+ import dataclasses
5
+ from collections import deque
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, timedelta
8
+ from typing import Tuple, Deque, Optional, Union
9
+
10
+ import numpy
11
+
12
+ from autotrainer.api import ApiDetectorKind, ApiEventKind
13
+
14
+ from autotrainer.core.logging import get_verbose_logger
15
+
16
+ from .detector import BaseDetector
17
+
18
+ from autotrainer.core import get_perf_now
19
+ from autotrainer.core.multiproc import make_daemon_timer, no_op_timer
20
+ from autotrainer.core.configuration.load_cell_config import LoadCellConfiguration
21
+ from autotrainer.core.event.event_manager import EventManager
22
+
23
+
24
+ logger = get_verbose_logger(__name__)
25
+
26
+ # to allow to be patched from tests:
27
+ _timer_load_cell_engaged = make_daemon_timer
28
+
29
+
30
+ @dataclass
31
+ class LoadCellMonitorContext:
32
+ # using a dedicated "context" object, allows to have all attributes consistent, given we replace the active one
33
+ # in the load cell monitor with new one having all needed attributes already replaced/set.
34
+ # See LoadCellMonitor.is_engaged and LoadCellMonitor.thrashing_detected setters below.
35
+ # This allows to get/read a consistent view/data of current/live context, *without* needing to take the thread lock.
36
+
37
+ is_engaged: bool = False
38
+ last_engaged_perf_c: float = -math.inf
39
+ last_disengaged_perf_c: float = -math.inf
40
+
41
+ thrashing_detected: bool = False
42
+ thrashing_last_engaged_perf_c: float = -math.inf
43
+ thrashing_last_disengaged_perf_c: float = -math.inf
44
+
45
+ @property
46
+ def engaged_age(self):
47
+ """Current, if is currently engaged, or previous otherwise, engaged age"""
48
+ return (get_perf_now() if self.is_engaged else self.last_disengaged_perf_c) - self.last_engaged_perf_c
49
+
50
+ @property
51
+ def disengaged_age(self):
52
+ """Current or previous disengaged age"""
53
+ return (get_perf_now() if not self.is_engaged else self.last_engaged_perf_c) - self.last_disengaged_perf_c
54
+
55
+ @property
56
+ def thrashing_engaged_age(self):
57
+ """current if it is currently engaged, or previous otherwise thrashing_engaged_age"""
58
+ age = get_perf_now() if self.thrashing_detected else self.thrashing_last_disengaged_perf_c
59
+ return age - self.thrashing_last_engaged_perf_c
60
+
61
+ @property
62
+ def thrashing_disengaged_age(self):
63
+ """current if it is currently disengaged, or previous otherwise thrashing_disengaged_age"""
64
+ age = get_perf_now() if not self.thrashing_detected else self.thrashing_last_engaged_perf_c
65
+ return age - self.thrashing_last_disengaged_perf_c
66
+
67
+
68
+ class LoadCellMonitor(BaseDetector):
69
+ """
70
+ Monitor the load cell data stream and perform any required analysis. The current implementation is used to
71
+ determine when the animal is in the tunnel. At this time, this is specifically used downstream as a factor in
72
+ whether to start and stop "sessions" of an experiment.
73
+ """
74
+
75
+ use_timer: bool = False
76
+ # to use timer to allow triggering active/inactive, or not.
77
+
78
+ LOAD_CELL_ENGAGED_THRESHOLD_PROPERTY = "load_cell_engaged_threshold"
79
+ IS_ENGAGED_PROPERTY = "is_engaged"
80
+ IS_THRASHING_DETECTED_PROPERTY = "is_thrashing_detected"
81
+
82
+ def __init__(
83
+ self,
84
+ *,
85
+ config: Optional[LoadCellConfiguration] = None
86
+ ):
87
+ super().__init__()
88
+ self._config = LoadCellConfiguration() if config is None else config
89
+ self._cur_idx = 0
90
+ self._p_start_active = None # get_perf_now()
91
+ self._p_start_inactive = None # get_perf_now()
92
+ self._cur_ptp_count = 0
93
+ self._p_last_ptp_check = 0
94
+ self._active_debounce = no_op_timer
95
+ self._inactive_debounce = no_op_timer
96
+ self._index = 0 # used to pass with event when engaged is changed
97
+ self._force_engaged: bool = False # debug
98
+ self._engaged_batch_count: int = 10 # how many last values to use as mean for check is_engaged
99
+ # same than in HardwareModel.connect (currently hardcoded too)
100
+ self._p_next_hist_log = get_perf_now()
101
+ self._values_history: Deque[
102
+ Tuple[float, float, float]
103
+ # data, unix timestamp, perf_c
104
+ ] = deque()
105
+ self._context = LoadCellMonitorContext(
106
+ last_engaged_perf_c=-math.inf,
107
+ last_disengaged_perf_c=-math.inf,
108
+ thrashing_last_engaged_perf_c=-math.inf,
109
+ thrashing_last_disengaged_perf_c=-math.inf,
110
+ )
111
+
112
+ def _check_state(self) -> Optional[float]:
113
+ return None
114
+
115
+ @property
116
+ def context(self) -> LoadCellMonitorContext:
117
+ return self._context
118
+
119
+ @property
120
+ def config(self) -> LoadCellConfiguration:
121
+ return self._config
122
+
123
+ @property
124
+ def load_cell_engaged_threshold(self) -> float:
125
+ return self._config.weight_active_threshold
126
+
127
+ @load_cell_engaged_threshold.setter
128
+ def load_cell_engaged_threshold(self, value: float):
129
+ cfg = self._config
130
+ prev, cfg.weight_active_threshold = cfg.weight_active_threshold, value
131
+ self._on_property_changed(LoadCellMonitor.LOAD_CELL_ENGAGED_THRESHOLD_PROPERTY, value, prev)
132
+
133
+ @property
134
+ def is_engaged(self) -> bool:
135
+ return self._context.is_engaged
136
+
137
+ @is_engaged.setter
138
+ def is_engaged(self, is_engaged: bool):
139
+ with self._lock:
140
+ if is_engaged == self._context.is_engaged:
141
+ return
142
+ new_context = dataclasses.replace(self._context, is_engaged=is_engaged)
143
+ if is_engaged:
144
+ related_perf_c = self._p_start_active
145
+ else:
146
+ related_perf_c = self._p_start_inactive
147
+ if related_perf_c is None:
148
+ related_perf_c = get_perf_now()
149
+ if is_engaged:
150
+ new_context.last_engaged_perf_c = related_perf_c
151
+ else:
152
+ new_context.last_disengaged_perf_c = related_perf_c
153
+ self._context = new_context
154
+ logger.verbose("new context: %s", new_context)
155
+ EventManager.default().post_event_content(
156
+ ApiEventKind.loadCellEngagedChanged, data=dict(is_engaged=is_engaged),
157
+ when=datetime.now() - timedelta(seconds=get_perf_now() - related_perf_c), index=self._index)
158
+ # executing the event without the lock acquired:
159
+ self._on_property_changed(LoadCellMonitor.IS_ENGAGED_PROPERTY, is_engaged, not is_engaged)
160
+
161
+ @property
162
+ def thrashing_detected(self) -> bool:
163
+ return self._context.thrashing_detected
164
+
165
+ @thrashing_detected.setter
166
+ def thrashing_detected(self, value):
167
+ with self._lock:
168
+ self._set_thrashing_detected(value)
169
+
170
+ def _set_thrashing_detected(self, value):
171
+ ctx = self._context
172
+ prev = ctx.thrashing_detected
173
+ if value == prev:
174
+ return
175
+ new_ctx = dataclasses.replace(ctx, thrashing_detected=value)
176
+ logger.debug("load_cell_monitor.thrashing_detected=%s", value)
177
+ perf_now = get_perf_now()
178
+ if value:
179
+ new_ctx.thrashing_last_engaged_perf_c = perf_now
180
+ else:
181
+ new_ctx.thrashing_last_disengaged_perf_c = perf_now
182
+ self._context = new_ctx
183
+ self.property_changed(self.IS_THRASHING_DETECTED_PROPERTY, value, prev)
184
+ self.post_detector_event(ApiDetectorKind.loadCellThrash, value)
185
+
186
+ def load_configuration(self, configuration: LoadCellConfiguration):
187
+ # force the on_property_changed event too (if new value differs) :
188
+ self.load_cell_engaged_threshold = configuration.weight_active_threshold
189
+ # before resetting the new config:
190
+ self._config = configuration
191
+
192
+ def save_configuration(self) -> LoadCellConfiguration:
193
+ return self._config
194
+
195
+ def _update_history(self, value, when, perf_c):
196
+ hist = self._values_history
197
+ cfg = self._config
198
+ keep = max(
199
+ cfg.threshold_duration,
200
+ cfg.min_event_duration,
201
+ cfg.min_post_event_hold_duration,
202
+ cfg.thrashing_var_min_delay,
203
+ cfg.thrashing_var_max_delay,
204
+ )
205
+ while len(hist) > 0 and len(hist) >= self._engaged_batch_count:
206
+ if perf_c - hist[0][2] <= keep:
207
+ break
208
+ hist.popleft()
209
+ hist.append((value, when, perf_c))
210
+
211
+ def _check_ptp_threshold(self, cur_perf_c: float, min_delay: float, max_delay: float, ptp_threshold: float):
212
+ values = [
213
+ h_val
214
+ for h_val, _, h_perf_c in self._values_history
215
+ if min_delay <= cur_perf_c - h_perf_c < max_delay
216
+ ]
217
+ if len(values) == 0:
218
+ return False
219
+ ptp_value = numpy.ptp(values)
220
+ if __debug__:
221
+ if ptp_value >= ptp_threshold:
222
+ logger.debug("ptp_value=%s threshold=%s", ptp_value, ptp_threshold)
223
+ return bool(
224
+ # NB: using bool() given value is numpy.bool_ otherwise,
225
+ # we want to use native python bool instead, so the bool().
226
+ ptp_value >= ptp_threshold
227
+ )
228
+
229
+ def _make_thrashing_check(self, perf_c, cfg):
230
+ if not (
231
+ self.is_engaged
232
+ and perf_c - self._p_last_ptp_check >= cfg.thrashing_var_min_delay
233
+ ):
234
+ return
235
+ self._p_last_ptp_check = perf_c
236
+ # consider ptp between when and cfg.thrashing_var_min_delay,
237
+ # and ptp between when and cfg.thrashing_var_max_delay
238
+ detected1 = self._check_ptp_threshold(
239
+ perf_c, 0, cfg.thrashing_var_min_delay, cfg.thrashing_var_weight_threshold_min)
240
+ detected2 = self._check_ptp_threshold(
241
+ perf_c, 0, cfg.thrashing_var_max_delay, cfg.thrashing_var_weight_threshold_max)
242
+ if (detected1 and detected2) or ((detected1 or detected2) and self._cur_ptp_count > 0):
243
+ self._cur_ptp_count += 1 if detected1 != detected2 else 1.5
244
+ if self._cur_ptp_count >= cfg.thrashing_min_ptp_change_count:
245
+ self.thrashing_detected = True
246
+ self._cur_ptp_count = 0
247
+ # to makes longer thrashing period we could only keep it on min_ptp_change_count value,
248
+ # and rely on below logic
249
+ self._p_last_ptp_check += cfg.thrashing_var_max_delay
250
+ # otherwise thrashing ON period does not last very long.
251
+ else:
252
+ # put back next check
253
+ self._p_last_ptp_check += cfg.thrashing_var_max_delay if detected1 and detected2 else (
254
+ cfg.thrashing_var_min_delay)
255
+ else:
256
+ # following might be adapted/changed
257
+ if not detected1 and not detected2:
258
+ self._cur_ptp_count = 0
259
+ # to makes possible longer thrashing periods, we could subtract 1.5 like here instead.
260
+ elif self._cur_ptp_count > 0:
261
+ self._cur_ptp_count -= 1
262
+ if self._cur_ptp_count <= 0:
263
+ self.thrashing_detected = False
264
+ else:
265
+ self._p_last_ptp_check += cfg.thrashing_var_max_delay if not (
266
+ detected1 or detected2) else cfg.thrashing_var_min_delay
267
+
268
+ def update(self, value: Union[float, numpy.floating], when: float, index: int):
269
+ """
270
+ value: weight in gram
271
+ when: realtime UNIX timestamp
272
+ index: nanosecond perf counter
273
+ """
274
+ cfg = self._config
275
+ if self._force_engaged:
276
+ # debug code
277
+ value = self._config.weight_active_threshold + 0.1
278
+ perf_c = index / 1e9
279
+ self._update_history(value, when, perf_c)
280
+ p_start = self._p_start_active
281
+ ctx = self._context
282
+ cur_engaged = ctx.is_engaged
283
+ cur_thrashing = ctx.thrashing_detected
284
+ if __debug__:
285
+ p_now = get_perf_now()
286
+ if p_now > self._p_next_hist_log:
287
+ logger.verbose("hist size=%s value=%.1f index=%s start_active=%s engaged=%s trashing=%s",
288
+ len(self._values_history), value, index, p_start, cur_engaged, cur_thrashing)
289
+ self._p_next_hist_log += 60
290
+ # always:
291
+ self._make_thrashing_check(perf_c, cfg)
292
+ #
293
+ self._cur_idx += 1
294
+ if self._cur_idx >= self._engaged_batch_count:
295
+ self._cur_idx = 0
296
+ n_values = len(self._values_history)
297
+ prev_n_values = [(v, p) for v, _, p in itertools.islice(self._values_history,
298
+ n_values - self._engaged_batch_count, n_values)]
299
+ value = numpy.mean([prev[0] for prev in prev_n_values])
300
+ if value >= cfg.weight_active_threshold:
301
+ self._inactive_debounce.cancel()
302
+ self._p_start_inactive = None
303
+ if p_start is None:
304
+ self._index = index
305
+ self._p_start_active = perf_c
306
+ self._cur_ptp_count = 0
307
+ logger.verbose("considering to engage within %.3f seconds", cfg.threshold_duration)
308
+ if self.use_timer:
309
+ self._active_debounce = _timer_load_cell_engaged(cfg.threshold_duration, self._ensure_active)
310
+ self._active_debounce.start()
311
+ elif perf_c - p_start > cfg.threshold_duration:
312
+ self._active_debounce.cancel()
313
+ self._ensure_active()
314
+
315
+ elif value < cfg.weight_inactive_threshold:
316
+ # inactive case
317
+ self._active_debounce.cancel()
318
+ if cur_engaged:
319
+ hold_time = perf_c - self._p_start_active
320
+ if hold_time >= cfg.min_event_duration:
321
+ duration = cfg.min_post_event_hold_duration
322
+ else:
323
+ duration = max(cfg.min_post_event_hold_duration, cfg.min_event_duration - hold_time)
324
+ else:
325
+ duration = 0
326
+ if self._p_start_inactive is None:
327
+ logger.verbose("considering to disengage within %.3f seconds", duration)
328
+ self._p_start_inactive = perf_c
329
+ self._index = index
330
+ if self.use_timer:
331
+ self._inactive_debounce = _timer_load_cell_engaged(duration, self._ensure_inactive)
332
+ self._inactive_debounce.start()
333
+ elif perf_c - self._p_start_inactive > duration:
334
+ self._inactive_debounce.cancel()
335
+ self._ensure_inactive()
336
+ else:
337
+ # inactive_threshold <= weight < active_threshold
338
+ # in between, only cancel the inactive debounce
339
+ self._inactive_debounce.cancel()
340
+ self._p_start_inactive = None
341
+
342
+ def _ensure_active(self):
343
+ # can be called directly from caller of update() thread or indirectly with a timer thread
344
+ # not sure if we should not maybe better only handle the direct one...
345
+ with self._lock:
346
+ self.__ensure_active()
347
+
348
+ def __ensure_active(self):
349
+ if self.is_engaged:
350
+ return
351
+ logger.notice("Setting active / engaged ; prev weight: %s", self._values_history[-1])
352
+ self._p_last_ptp_check = self._p_start_active + self._config.thrashing_var_max_delay
353
+ self.is_engaged = True # last on purpose
354
+
355
+ def _ensure_inactive(self):
356
+ # can be called directly from caller of update() thread or indirectly with a timer thread
357
+ with self._lock:
358
+ self.__ensure_inactive()
359
+
360
+ def __ensure_inactive(self):
361
+ if not self.is_engaged:
362
+ return
363
+ logger.notice("Setting inactive / disengaged ; prev weight: %s", self._values_history[-1])
364
+ self._p_start_active = None
365
+ self._cur_ptp_count = 0
366
+ self.thrashing_detected = False
367
+ self.is_engaged = False # last
368
+
369
+ def force_engaged(self, engaged: bool) -> None:
370
+ """
371
+ Primarily used for testing. This will force the load cell monitor to be engaged or not engaged.
372
+ """
373
+ logger.verbose("Force engaged: %s", engaged)
374
+ self._force_engaged = engaged
375
+ # self.is_engaged = engaged # assuming the load-cell sensor is working and calling our .update() function
376
+ # which will generate the engaged after the configured delay.
377
+ # And same for disengage.
@@ -0,0 +1,217 @@
1
+ import copy
2
+ import dataclasses
3
+ import math
4
+ import os
5
+ import time
6
+ from typing import Callable, Optional, List, Protocol
7
+
8
+ import numpy
9
+
10
+ from autotrainer.core import get_perf_now
11
+ from autotrainer.core.analysis.detector import BaseDetector
12
+ from autotrainer.core.configuration.load_cell_config import LoadCellAutoTareConfiguration
13
+ from autotrainer.core.logging import get_verbose_logger
14
+
15
+ logger = get_verbose_logger(__name__)
16
+
17
+
18
+ class TareCallbackT(Protocol):
19
+
20
+ def __call__(self, *, force: bool = False) -> bool:
21
+ """Request a tare if possible, or force it
22
+ If tare callback returns True: reset_baseline, otherwise: update_baseline
23
+ """
24
+
25
+
26
+ @dataclasses.dataclass
27
+ class LoadCellAutoTareContext:
28
+ low_variance_engaged: bool = True
29
+ low_variance_engaged_perf_c: float = -math.inf
30
+ low_variance_disengaged_perf_c: float = -math.inf
31
+
32
+
33
+ class LoadCellTareMonitor(BaseDetector):
34
+ """
35
+ Monitor the load cell data stream and whether zeroing is required. The decision to actually zero or not is not
36
+ performed here. It simply reports whether the conditions meet the requirements where zeroing is applicable.
37
+ """
38
+
39
+ def __init__(self):
40
+ super().__init__()
41
+
42
+ self._context = LoadCellAutoTareContext()
43
+ self._config = LoadCellAutoTareConfiguration()
44
+ self._tare_callback: Optional[TareCallbackT] = None
45
+ self._baseline: float = 0
46
+ self._values: numpy.ndarray
47
+ self._index = 0
48
+ self._reset()
49
+ # debug:
50
+ self._next_dbg_log = -math.inf
51
+ self._last_dbg_log = -math.inf
52
+ self._cnt_received = 0
53
+
54
+ def _check_state(self) -> Optional[float]:
55
+ # all handled by .update()
56
+ return None
57
+
58
+ @property
59
+ def config(self) -> LoadCellAutoTareConfiguration:
60
+ return self._config
61
+
62
+ @property
63
+ def context(self) -> LoadCellAutoTareContext:
64
+ return self._context
65
+
66
+ def get_context(self) -> LoadCellAutoTareContext:
67
+ with self._lock:
68
+ return copy.deepcopy(self._context)
69
+
70
+ # eventual todo begin: could use instance.config.xxx instead of these individual properties
71
+ @property
72
+ def threshold(self) -> float:
73
+ return self._config.threshold
74
+
75
+ @threshold.setter
76
+ def threshold(self, value: float) -> None:
77
+ self._config.threshold = value
78
+
79
+ @property
80
+ def range_threshold(self) -> float:
81
+ return self._config.range_threshold
82
+
83
+ @range_threshold.setter
84
+ def range_threshold(self, value: float) -> None:
85
+ self._config.range_threshold = value
86
+
87
+ @property
88
+ def duration(self) -> float:
89
+ return self._config.duration
90
+
91
+ @duration.setter
92
+ def duration(self, value: float) -> None:
93
+ self._config.duration = value
94
+ self._reset()
95
+
96
+ @property
97
+ def sample_rate(self) -> int:
98
+ return self._config.sample_rate
99
+
100
+ @sample_rate.setter
101
+ def sample_rate(self, value: int) -> None:
102
+ self._config.sample_rate = value
103
+ self._reset()
104
+
105
+ @property
106
+ def baseline(self) -> float:
107
+ return self._baseline
108
+ # eventual todo end.
109
+
110
+ @property
111
+ def tare_callback(self):
112
+ return self._tare_callback
113
+
114
+ @tare_callback.setter
115
+ def tare_callback(self, tare_callback: TareCallbackT) -> None:
116
+ logger.info("Setting new tare_callback: %s", tare_callback)
117
+ self._tare_callback = tare_callback
118
+
119
+ def load_configuration(self, configuration: LoadCellAutoTareConfiguration):
120
+ self._config = configuration
121
+ logger.info("got new config: %s", configuration)
122
+ self._reset()
123
+
124
+ def save_configuration(self) -> LoadCellAutoTareConfiguration:
125
+ return self._config
126
+
127
+ def update(self, values: List[float]) -> bool:
128
+ log = self._logger
129
+ new_values = numpy.array(values)
130
+ t_now = time.perf_counter()
131
+ cur_buff = self._values
132
+ buf_len = len(cur_buff)
133
+ increase = len(new_values)
134
+ if increase > buf_len:
135
+ # only keep most recent in case we get too much:
136
+ new_values = new_values[increase - buf_len:]
137
+ increase = buf_len
138
+ self._cnt_received += increase
139
+
140
+ cfg = self._config
141
+ # replaces NaN by base + 2 * threshold, so that if only NaN's get in,
142
+ # NB: using 2 * threshold to be sure to be above it for below comparison against it.
143
+ # so that if all of them are NaN we'll execute a tare.
144
+ mask_nan = numpy.ma.array(new_values, mask=numpy.isnan(new_values))
145
+ is_all_nans = bool(numpy.isnan(new_values).all())
146
+ has_nans = bool(numpy.isnan(new_values).any())
147
+ new_values[new_values != mask_nan] = self._baseline + 2 * cfg.threshold
148
+
149
+ idx = self._index
150
+ off = idx + increase
151
+
152
+ w_off = min(buf_len, off)
153
+ cur_buff[idx:w_off] = new_values[:w_off - idx]
154
+ idx += increase
155
+ if idx >= buf_len:
156
+ idx %= buf_len
157
+ cur_buff[:idx] = new_values[increase - idx:]
158
+ self._index = idx
159
+ #
160
+ ctx = self._context
161
+ p_now = get_perf_now()
162
+ ptp = float(numpy.ptp(cur_buff))
163
+ low_ptp = ptp <= cfg.range_threshold
164
+ #
165
+ if __debug__:
166
+ if t_now > self._next_dbg_log or has_nans:
167
+ rcv_per_s = self._cnt_received / (t_now - self._last_dbg_log)
168
+ log.verbose(
169
+ "base=%.1f low_ptp=%s new values: received=%.1f/s cur_values=%s ; buffer=%s",
170
+ self._baseline, low_ptp, rcv_per_s, new_values, cur_buff.tolist(),
171
+ )
172
+ self._last_dbg_log = t_now
173
+ self._next_dbg_log = t_now + float(os.getenv("LOADCELL_TARE_DBG_DELAY", 30))
174
+ self._cnt_received = 0
175
+
176
+ #
177
+ if (not ctx.low_variance_engaged and low_ptp) or (ctx.low_variance_engaged and not low_ptp):
178
+ log.notice("low_variance %sengaged ; ptp=%.1f",
179
+ "" if low_ptp else "dis", ptp)
180
+ with self._lock:
181
+ ctx.low_variance_engaged = low_ptp
182
+ if low_ptp:
183
+ ctx.low_variance_engaged_perf_c = p_now
184
+ else:
185
+ ctx.low_variance_disengaged_perf_c = p_now
186
+
187
+ if is_all_nans or (low_ptp and numpy.all(numpy.abs(cur_buff - self._baseline) >= cfg.threshold)):
188
+ tare_cb = self._tare_callback
189
+ if tare_cb is None:
190
+ log.debug("no tare callback configured")
191
+ return False
192
+ tare_cb: TareCallbackT
193
+ if tare_cb(force=is_all_nans):
194
+ log.verbose("tare_cb=True -> reset_baseline")
195
+ self.reset_baseline()
196
+ else:
197
+ if is_all_nans:
198
+ self.reset_baseline()
199
+ log.verbose("all nans: reset_baseline")
200
+ else:
201
+ self.update_baseline()
202
+ log.verbose("tare_cb=False -> update_baseline")
203
+ # replace all values in current buffer with baseline:
204
+ self._values[:] = self._baseline
205
+ return True
206
+ return False
207
+
208
+ def update_baseline(self):
209
+ self._baseline = float(numpy.average(self._values))
210
+
211
+ def reset_baseline(self):
212
+ self._baseline = 0
213
+
214
+ def _reset(self) -> None:
215
+ cfg = self._config
216
+ self._values = numpy.zeros(int(cfg.sample_rate * cfg.duration))
217
+ self._index = 0
@@ -0,0 +1,80 @@
1
+ import csv
2
+ from dataclasses import dataclass
3
+ from typing import List
4
+
5
+ from typing_extensions import Self
6
+
7
+ from ..message.measurement_message import MeasurementMessageProtocol
8
+
9
+
10
+ @dataclass
11
+ class MeasurementData(MeasurementMessageProtocol):
12
+ """
13
+ Default class that implements the MeasurementMessage protocol. Actual hardware devices will likely deliver their
14
+ own object that implements the protocol. However, this class can be used to read back data from the "monitor" CSV
15
+ output files for playback, reprocessing, or other tasks.
16
+ """
17
+ when_val: float
18
+ index_val: int
19
+ weight_val: float
20
+ pressure_val: int
21
+ temperature_val: float
22
+ humidity_val: float
23
+ head_contact_val: bool
24
+
25
+ @property
26
+ def when(self) -> float:
27
+ return self.when_val
28
+
29
+ @property
30
+ def timestamp(self) -> int:
31
+ return self.index_val
32
+
33
+ @property
34
+ def index(self) -> int:
35
+ return self.index_val
36
+
37
+ @property
38
+ def weight(self) -> float:
39
+ return self.weight_val
40
+
41
+ @property
42
+ def pressure(self) -> int:
43
+ return self.pressure_val
44
+
45
+ @property
46
+ def switch(self) -> float:
47
+ return 1.0 if self.head_contact_val is True else 0.0
48
+
49
+ @property
50
+ def temperature(self) -> float:
51
+ return self.temperature_val
52
+
53
+ @property
54
+ def humidity(self) -> float:
55
+ return self.humidity_val
56
+
57
+ @property
58
+ def head_contact(self) -> bool:
59
+ return self.head_contact_val
60
+
61
+ @classmethod
62
+ def from_file(cls, path: str) -> List[Self]:
63
+ measurements = []
64
+
65
+ with open(str(path), mode="r") as file:
66
+ csv_data = csv.reader(file)
67
+
68
+ for line in csv_data:
69
+ if line[0] == "Time":
70
+ continue
71
+
72
+ measurement = cls(when_val=float(line[0]), index_val=int(float(line[1])),
73
+ weight_val=float(line[2]),
74
+ head_contact_val=int(line[3]) == 1, pressure_val=int(line[4]),
75
+ temperature_val=float(line[5]),
76
+ humidity_val=float(line[6]))
77
+
78
+ measurements.append(measurement)
79
+
80
+ return measurements