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.
- auto_trainer_core-2.0.1.dev353.dist-info/METADATA +18 -0
- auto_trainer_core-2.0.1.dev353.dist-info/RECORD +73 -0
- auto_trainer_core-2.0.1.dev353.dist-info/WHEEL +5 -0
- auto_trainer_core-2.0.1.dev353.dist-info/top_level.txt +1 -0
- autotrainer/core/__init__.py +319 -0
- autotrainer/core/analysis/__init__.py +6 -0
- autotrainer/core/analysis/alarm_monitor.py +484 -0
- autotrainer/core/analysis/audio_spectrum_data.py +27 -0
- autotrainer/core/analysis/audio_spectrum_monitor.py +134 -0
- autotrainer/core/analysis/auto_tunnel_fan_monitor.py +92 -0
- autotrainer/core/analysis/detector.py +179 -0
- autotrainer/core/analysis/external_doors_monitor.py +124 -0
- autotrainer/core/analysis/global_animal_presence_monitor.py +69 -0
- autotrainer/core/analysis/head_fix_measurement.py +15 -0
- autotrainer/core/analysis/headbar_pressure_monitor.py +158 -0
- autotrainer/core/analysis/load_cell_monitor.py +377 -0
- autotrainer/core/analysis/load_cell_tare_monitor.py +217 -0
- autotrainer/core/analysis/measurement_data.py +80 -0
- autotrainer/core/analysis/pellet_position_monitor.py +108 -0
- autotrainer/core/analysis/sensor_analysis.py +390 -0
- autotrainer/core/analysis/system_fault_monitor.py +81 -0
- autotrainer/core/analysis/system_maintenance_monitor.py +88 -0
- autotrainer/core/animal/__init__.py +1 -0
- autotrainer/core/animal/animal_subject.py +208 -0
- autotrainer/core/capture.py +25 -0
- autotrainer/core/configuration/__init__.py +88 -0
- autotrainer/core/configuration/alarm_configuration.py +55 -0
- autotrainer/core/configuration/animal_presence_configuration.py +17 -0
- autotrainer/core/configuration/behavior_configuration.py +323 -0
- autotrainer/core/configuration/camera_configuration.py +108 -0
- autotrainer/core/configuration/external_doors_monitor_configuration.py +9 -0
- autotrainer/core/configuration/hardware_configuration.py +30 -0
- autotrainer/core/configuration/inference_configuration.py +26 -0
- autotrainer/core/configuration/load_cell_config.py +61 -0
- autotrainer/core/configuration/persistence_configuration.py +29 -0
- autotrainer/core/configuration/presence_detection_configuration.py +9 -0
- autotrainer/core/configuration/system_configuration.py +211 -0
- autotrainer/core/configuration/system_fault_config.py +9 -0
- autotrainer/core/configuration/system_maintenance_config.py +17 -0
- autotrainer/core/diamond_triangle_config.py +150 -0
- autotrainer/core/event/__init__.py +54 -0
- autotrainer/core/event/api_event_kind.py +5 -0
- autotrainer/core/event/api_event_plugin.py +57 -0
- autotrainer/core/event/event_info.py +53 -0
- autotrainer/core/event/event_manager.py +296 -0
- autotrainer/core/event/event_manager_plugin.py +45 -0
- autotrainer/core/event/file_event_plugin.py +91 -0
- autotrainer/core/event/logger_event_plugin.py +43 -0
- autotrainer/core/fixed_array_multiqueue.py +263 -0
- autotrainer/core/fixed_array_queue.py +124 -0
- autotrainer/core/frame_index.py +34 -0
- autotrainer/core/interfaces.py +347 -0
- autotrainer/core/logging.py +714 -0
- autotrainer/core/message/__init__.py +26 -0
- autotrainer/core/message/audio_spectrum_message.py +21 -0
- autotrainer/core/message/measurement_message.py +61 -0
- autotrainer/core/message/message_handler.py +158 -0
- autotrainer/core/message/motor_configuration_message.py +128 -0
- autotrainer/core/message/system_command_kind.py +74 -0
- autotrainer/core/message/system_message_handler.py +122 -0
- autotrainer/core/message/system_status_message.py +112 -0
- autotrainer/core/multiproc.py +50 -0
- autotrainer/core/notification/__init__.py +2 -0
- autotrainer/core/notification/notification_center.py +80 -0
- autotrainer/core/notification/trigger_notification.py +27 -0
- autotrainer/core/observable_object.py +105 -0
- autotrainer/core/perf_monitor.py +77 -0
- autotrainer/core/pose_elements.py +196 -0
- autotrainer/core/project/__init__.py +19 -0
- autotrainer/core/project/project_info.py +486 -0
- autotrainer/core/queue_util.py +60 -0
- autotrainer/core/reach_event.py +38 -0
- 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
|