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,92 @@
|
|
|
1
|
+
|
|
2
|
+
import dataclasses
|
|
3
|
+
import math
|
|
4
|
+
from typing import Optional, Tuple, List
|
|
5
|
+
|
|
6
|
+
from autotrainer.core import get_perf_now, Offset3DTuple
|
|
7
|
+
from autotrainer.core.logging import get_verbose_logger
|
|
8
|
+
from autotrainer.core.multiproc import no_op_timer, make_daemon_timer
|
|
9
|
+
from autotrainer.core.analysis.detector import BaseDetector
|
|
10
|
+
from autotrainer.core.analysis.pellet_position_monitor import PelletMisplacedDetector
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
logger = get_verbose_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_offset_nans = Offset3DTuple(math.nan, math.nan, math.nan)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclasses.dataclass
|
|
20
|
+
class AutoTunnelSweepConfiguration:
|
|
21
|
+
enabled: bool = False
|
|
22
|
+
misplaced_trigger_delay: float = 5
|
|
23
|
+
tunnel_fan_on_duration: float = 5
|
|
24
|
+
rate_limit_delay: float = 60
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AutoTunnelSweepMonitor(BaseDetector):
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self, config: AutoTunnelSweepConfiguration,
|
|
31
|
+
*,
|
|
32
|
+
pellet_misplaced_detector: PelletMisplacedDetector,
|
|
33
|
+
):
|
|
34
|
+
super().__init__()
|
|
35
|
+
self._config = config
|
|
36
|
+
self._misplaced_detector = pellet_misplaced_detector
|
|
37
|
+
self._timer_end_engaged = no_op_timer
|
|
38
|
+
pellet_misplaced_detector.property_changed += self._on_pellet_misplaced_property_changed
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def config(self) -> AutoTunnelSweepConfiguration:
|
|
42
|
+
return self._config
|
|
43
|
+
|
|
44
|
+
@config.setter
|
|
45
|
+
def config(self, value: AutoTunnelSweepConfiguration):
|
|
46
|
+
self._config = value
|
|
47
|
+
|
|
48
|
+
def _stop(self):
|
|
49
|
+
self._timer_end_engaged.cancel()
|
|
50
|
+
self.is_engaged = False # force disengaged on stop
|
|
51
|
+
|
|
52
|
+
def _end_engaged(self):
|
|
53
|
+
self.is_engaged = False
|
|
54
|
+
self._misplaced_detector.restart()
|
|
55
|
+
|
|
56
|
+
def _check_state(self) -> Optional[float]:
|
|
57
|
+
cfg = self._config
|
|
58
|
+
if not self._running or not cfg.enabled:
|
|
59
|
+
return None
|
|
60
|
+
if not self._misplaced_detector.is_engaged:
|
|
61
|
+
logger.debug("misplaced not engaged")
|
|
62
|
+
self.is_engaged = False
|
|
63
|
+
return None
|
|
64
|
+
p_now = get_perf_now()
|
|
65
|
+
rate_remain = cfg.rate_limit_delay + cfg.misplaced_trigger_delay - (p_now - self._disengaged_perf_c)
|
|
66
|
+
if rate_remain > 0:
|
|
67
|
+
logger.verbose("delaying tunnel sweep for %.1fs due to rate limit", rate_remain)
|
|
68
|
+
return rate_remain
|
|
69
|
+
self.is_engaged = True
|
|
70
|
+
prev_timer_disengage = self._timer_end_engaged
|
|
71
|
+
if prev_timer_disengage is no_op_timer or prev_timer_disengage.finished.is_set():
|
|
72
|
+
timer = self._timer_end_engaged = make_daemon_timer(cfg.tunnel_fan_on_duration, self._end_engaged)
|
|
73
|
+
timer.start()
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
def _on_pellet_misplaced_property_changed(self, name, value, _):
|
|
77
|
+
if not self._running or not self._config.enabled:
|
|
78
|
+
logger.verbose("auto tunnel sweep not enabled")
|
|
79
|
+
return
|
|
80
|
+
if name == BaseDetector.IS_ENGAGED:
|
|
81
|
+
if value:
|
|
82
|
+
delay = self._config.misplaced_trigger_delay
|
|
83
|
+
if delay > 0:
|
|
84
|
+
with self._lock:
|
|
85
|
+
timer = self._cur_timer
|
|
86
|
+
if not timer.finished.is_set():
|
|
87
|
+
self._logger.debug("timer already in progress")
|
|
88
|
+
else:
|
|
89
|
+
self._make_new_timer(delay)
|
|
90
|
+
return
|
|
91
|
+
# all other cases (pellet misplaced not engaged or trigger delay <= 0):
|
|
92
|
+
self.check_state()
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import queue
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from typing import Dict, Tuple, Optional, Union
|
|
6
|
+
|
|
7
|
+
from autotrainer.api import ApiEventKind
|
|
8
|
+
from autotrainer.core import ObservableObject, get_perf_now
|
|
9
|
+
from autotrainer.core.event import post_api_detector_event_content
|
|
10
|
+
from autotrainer.core.event.event_manager import EventManager
|
|
11
|
+
from autotrainer.core.logging import get_verbose_logger
|
|
12
|
+
from autotrainer.core.multiproc import no_op_timer, make_daemon_timer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
logger = get_verbose_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BaseDetector(ObservableObject):
|
|
19
|
+
|
|
20
|
+
IS_ENGAGED = "is_engaged"
|
|
21
|
+
|
|
22
|
+
use_daemon: bool = False
|
|
23
|
+
default_timer_delay: Optional[float] = None
|
|
24
|
+
|
|
25
|
+
def __init__(self):
|
|
26
|
+
super().__init__()
|
|
27
|
+
self._running = False
|
|
28
|
+
self._p_started = -math.inf
|
|
29
|
+
self._is_engaged = False
|
|
30
|
+
self._engaged_perf_c = -math.inf
|
|
31
|
+
self._disengaged_perf_c = -math.inf
|
|
32
|
+
self._cur_timer = no_op_timer
|
|
33
|
+
self._thread_queue: Optional[Tuple[threading.Thread, queue.Queue]] = None
|
|
34
|
+
self._lock = threading.RLock()
|
|
35
|
+
self._logger = get_verbose_logger(self.__class__.__module__)
|
|
36
|
+
self._event_manager = EventManager.default()
|
|
37
|
+
|
|
38
|
+
def post_detector_event(self, detector_id: int, active: bool, enabled: Optional[bool] = None):
|
|
39
|
+
if enabled is None:
|
|
40
|
+
enabled = self._running
|
|
41
|
+
post_api_detector_event_content(self._event_manager, detector_id, active, enabled)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def running(self):
|
|
45
|
+
return self._running
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def is_engaged(self):
|
|
49
|
+
return self._is_engaged
|
|
50
|
+
|
|
51
|
+
@is_engaged.setter
|
|
52
|
+
def is_engaged(self, value):
|
|
53
|
+
prev, self._is_engaged = self._is_engaged, value
|
|
54
|
+
if prev == value:
|
|
55
|
+
return
|
|
56
|
+
perf_now = get_perf_now()
|
|
57
|
+
if value:
|
|
58
|
+
self._engaged_perf_c = perf_now
|
|
59
|
+
else:
|
|
60
|
+
self._disengaged_perf_c = perf_now
|
|
61
|
+
self._logger.notice("is_engaged -> %s (age previous = %.1f)",
|
|
62
|
+
value, perf_now - (self._disengaged_perf_c if value else self._engaged_perf_c))
|
|
63
|
+
self._on_property_changed(self.IS_ENGAGED, value, prev)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def engaged_age(self):
|
|
67
|
+
return (get_perf_now() if self._is_engaged else self._disengaged_perf_c) - self._engaged_perf_c
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def disengaged_age(self):
|
|
71
|
+
return (get_perf_now() if not self._is_engaged else self._engaged_perf_c) - self._disengaged_perf_c
|
|
72
|
+
|
|
73
|
+
def _make_new_timer(self, delay: float):
|
|
74
|
+
self._cur_timer.cancel() # safer
|
|
75
|
+
timer = self._cur_timer = make_daemon_timer(delay, self.check_state)
|
|
76
|
+
timer.start()
|
|
77
|
+
self._logger.verbose("created timer to check_state within %.1fs", delay)
|
|
78
|
+
|
|
79
|
+
def _check_state(self) -> Optional[float]:
|
|
80
|
+
raise NotImplementedError
|
|
81
|
+
|
|
82
|
+
def check_state_if_not_detector_thread(self):
|
|
83
|
+
th_q = self._thread_queue
|
|
84
|
+
if th_q is None or threading.current_thread() != th_q[0]:
|
|
85
|
+
self.check_state()
|
|
86
|
+
|
|
87
|
+
def check_state(self):
|
|
88
|
+
with self._lock:
|
|
89
|
+
if not self._running:
|
|
90
|
+
return None
|
|
91
|
+
next_delay = self._check_state()
|
|
92
|
+
if next_delay is None:
|
|
93
|
+
next_delay = self.default_timer_delay
|
|
94
|
+
if next_delay is not None and not self.use_daemon:
|
|
95
|
+
# "recurrent/timed" detector
|
|
96
|
+
self._make_new_timer(next_delay)
|
|
97
|
+
else:
|
|
98
|
+
# daemon detector or detector that will resume by explicit refresh
|
|
99
|
+
self._cur_timer.cancel()
|
|
100
|
+
self._cur_timer = no_op_timer
|
|
101
|
+
return next_delay
|
|
102
|
+
|
|
103
|
+
def _start(self):
|
|
104
|
+
"""Allow any subclass to customize its start procedure. super() should be called."""
|
|
105
|
+
|
|
106
|
+
def start(self):
|
|
107
|
+
with self._lock:
|
|
108
|
+
if self._running:
|
|
109
|
+
self._logger.debug("requested start but already running")
|
|
110
|
+
return
|
|
111
|
+
self._logger.verbose("%s: starting monitor", self.__class__.__name__)
|
|
112
|
+
self._running = True
|
|
113
|
+
self.is_engaged = False # force reset "engaged" to False
|
|
114
|
+
self._p_started = get_perf_now()
|
|
115
|
+
self._start()
|
|
116
|
+
if self.use_daemon:
|
|
117
|
+
cmd_queue = queue.Queue()
|
|
118
|
+
thread = threading.Thread(name=self.__class__.__name__, target=self._daemon_run, daemon=True,
|
|
119
|
+
args=(cmd_queue,))
|
|
120
|
+
thread.start()
|
|
121
|
+
self._thread_queue = thread, cmd_queue
|
|
122
|
+
else:
|
|
123
|
+
self.check_state()
|
|
124
|
+
|
|
125
|
+
def _daemon_run(self, cmd_queue):
|
|
126
|
+
self._logger.info("%s running", self.__class__.__name__)
|
|
127
|
+
while True:
|
|
128
|
+
delay = self.check_state() # always check immediately
|
|
129
|
+
if delay is None:
|
|
130
|
+
delay = self.default_timer_delay
|
|
131
|
+
if delay is None:
|
|
132
|
+
delay = 1
|
|
133
|
+
# Now limit max delay to 60 seconds,
|
|
134
|
+
# so that any change in config will be handled at most every 60 seconds.
|
|
135
|
+
# Some detector, like global animal presence, could use very long delay between check,
|
|
136
|
+
# so this ensures that if its setting relating to the delay is changed, then the new one will be handled,
|
|
137
|
+
# ~relatively quickly.
|
|
138
|
+
# Otherwise, we would need to restart any of them that has its config updated.
|
|
139
|
+
delay = min(60., delay)
|
|
140
|
+
try:
|
|
141
|
+
r = cmd_queue.get(timeout=delay)
|
|
142
|
+
except queue.Empty:
|
|
143
|
+
pass
|
|
144
|
+
else:
|
|
145
|
+
cmd_queue.task_done()
|
|
146
|
+
# we only support None exit sentinel
|
|
147
|
+
assert r is None
|
|
148
|
+
break
|
|
149
|
+
self._logger.verbose("%s: exiting main loop", self.__class__.__name__)
|
|
150
|
+
|
|
151
|
+
def _stop(self):
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
def stop(self):
|
|
155
|
+
with self._lock:
|
|
156
|
+
self._cur_timer.cancel()
|
|
157
|
+
if not self._running:
|
|
158
|
+
return
|
|
159
|
+
self._logger.verbose("%s: stopping monitor", self.__class__.__name__)
|
|
160
|
+
self._running = False
|
|
161
|
+
self._stop()
|
|
162
|
+
# don't try join check thread with the lock acquired, given that can deadlock otherwise.
|
|
163
|
+
thread_queue = self._thread_queue
|
|
164
|
+
if thread_queue is not None:
|
|
165
|
+
thread, q = self._thread_queue
|
|
166
|
+
# assert isinstance(q, queue.Queue)
|
|
167
|
+
if thread.is_alive():
|
|
168
|
+
q.put(None)
|
|
169
|
+
self._logger.debug("Joining check thread %s", thread)
|
|
170
|
+
thread.join(3)
|
|
171
|
+
self._logger.verbose("joined check thread %s", thread)
|
|
172
|
+
if thread.is_alive():
|
|
173
|
+
self._logger.warning("check thread still alive, but continuing anyway")
|
|
174
|
+
self._thread_queue = None
|
|
175
|
+
|
|
176
|
+
def restart(self):
|
|
177
|
+
self._logger.notice("Restarting %s", self.__class__.__name__)
|
|
178
|
+
self.stop()
|
|
179
|
+
self.start()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import math
|
|
3
|
+
from typing import Dict, Tuple, Optional, NamedTuple
|
|
4
|
+
|
|
5
|
+
from autotrainer.api import ApiDetectorKind
|
|
6
|
+
|
|
7
|
+
from autotrainer.core import get_perf_now
|
|
8
|
+
from autotrainer.core.analysis.detector import BaseDetector
|
|
9
|
+
from autotrainer.core.logging import get_verbose_logger
|
|
10
|
+
from autotrainer.core.configuration.external_doors_monitor_configuration import ExternalDoorsMonitorConfig
|
|
11
|
+
from autotrainer.core.message.system_status_message import SystemStatusMessageKind
|
|
12
|
+
|
|
13
|
+
logger = get_verbose_logger(__name__)
|
|
14
|
+
|
|
15
|
+
class DoorState(NamedTuple):
|
|
16
|
+
open: Optional[bool]
|
|
17
|
+
perf_c: float
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
FrontDoor = SystemStatusMessageKind.FRONT_DOOR
|
|
21
|
+
SlidingDoor = SystemStatusMessageKind.DRAWER_DOOR
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
ActiveDoors = {
|
|
25
|
+
FrontDoor,
|
|
26
|
+
SlidingDoor,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_door_2_api_detector_kind = {
|
|
31
|
+
FrontDoor: ApiDetectorKind.frontDoor,
|
|
32
|
+
SlidingDoor: ApiDetectorKind.slidingDoor,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclasses.dataclass
|
|
37
|
+
class DoorsState:
|
|
38
|
+
front: DoorState
|
|
39
|
+
sliding: DoorState
|
|
40
|
+
|
|
41
|
+
def get_door(self, kind: SystemStatusMessageKind) -> DoorState:
|
|
42
|
+
if kind == FrontDoor:
|
|
43
|
+
return self.front
|
|
44
|
+
elif kind == SlidingDoor:
|
|
45
|
+
return self.sliding
|
|
46
|
+
raise ValueError(f"Invalid door kind: {kind}")
|
|
47
|
+
|
|
48
|
+
def set_door(self, kind: SystemStatusMessageKind, state: DoorState):
|
|
49
|
+
if kind == FrontDoor:
|
|
50
|
+
self.front = state
|
|
51
|
+
elif kind == SlidingDoor:
|
|
52
|
+
self.sliding = state
|
|
53
|
+
else:
|
|
54
|
+
raise ValueError(f"Invalid door kind: {kind}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _make_doors_state():
|
|
58
|
+
return DoorsState(
|
|
59
|
+
front=DoorState(None, -math.inf),
|
|
60
|
+
sliding=DoorState(None, -math.inf),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ExternalDoorsMonitor(BaseDetector):
|
|
65
|
+
|
|
66
|
+
CONFIG = "config"
|
|
67
|
+
|
|
68
|
+
def __init__(self, config: ExternalDoorsMonitorConfig):
|
|
69
|
+
super().__init__()
|
|
70
|
+
self._config = config
|
|
71
|
+
self._doors_state = _make_doors_state()
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def config(self) -> ExternalDoorsMonitorConfig:
|
|
75
|
+
return self._config
|
|
76
|
+
|
|
77
|
+
@config.setter
|
|
78
|
+
def config(self, value: ExternalDoorsMonitorConfig):
|
|
79
|
+
prev, self._config = self._config, value
|
|
80
|
+
logger.debug("external_doors=%s", value)
|
|
81
|
+
# self._on_property_changed(self.CONFIG, value, prev)
|
|
82
|
+
# not needed atm.
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def doors_state(self) -> DoorsState:
|
|
86
|
+
return self._doors_state
|
|
87
|
+
|
|
88
|
+
def _start(self):
|
|
89
|
+
super()._start()
|
|
90
|
+
self._doors_state = _make_doors_state()
|
|
91
|
+
|
|
92
|
+
def _check_state(self):
|
|
93
|
+
doors_state = self._doors_state
|
|
94
|
+
perf_now = get_perf_now()
|
|
95
|
+
cfg = self._config
|
|
96
|
+
min_delay = math.inf
|
|
97
|
+
new_engaged = False
|
|
98
|
+
for door_kind in ActiveDoors:
|
|
99
|
+
door_open, door_last_perf_c = doors_state.get_door(door_kind)
|
|
100
|
+
if door_open:
|
|
101
|
+
r = cfg.trigger_open_delay - (perf_now - door_last_perf_c)
|
|
102
|
+
if r < 0:
|
|
103
|
+
new_engaged = True
|
|
104
|
+
elif r < min_delay:
|
|
105
|
+
min_delay = r
|
|
106
|
+
self.is_engaged = new_engaged
|
|
107
|
+
if not new_engaged and not math.isinf(min_delay):
|
|
108
|
+
logger.debug("timer for next check state delay=%.1f", min_delay)
|
|
109
|
+
return min_delay
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
def update_door_state(self, door_kind, is_open):
|
|
113
|
+
if __debug__:
|
|
114
|
+
if door_kind not in ActiveDoors:
|
|
115
|
+
logger.warning("Got unexpected door message: %s", door_kind)
|
|
116
|
+
return
|
|
117
|
+
state = self._doors_state.get_door(door_kind)
|
|
118
|
+
if is_open == state.open:
|
|
119
|
+
return
|
|
120
|
+
logger.notice("%s: is_open: %s -> %s", door_kind, state.open, is_open)
|
|
121
|
+
new_perf_c = get_perf_now() if is_open else state.perf_c
|
|
122
|
+
self._doors_state.set_door(door_kind, DoorState(is_open, new_perf_c))
|
|
123
|
+
self.check_state()
|
|
124
|
+
self.post_detector_event(_door_2_api_detector_kind[door_kind], is_open)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import math
|
|
4
|
+
import threading
|
|
5
|
+
|
|
6
|
+
from autotrainer.core import ObservableObject, get_perf_now
|
|
7
|
+
from autotrainer.core.analysis.detector import BaseDetector
|
|
8
|
+
from autotrainer.core.configuration.animal_presence_configuration import GlobalAnimalPresenceConfig
|
|
9
|
+
from autotrainer.core.logging import get_verbose_logger
|
|
10
|
+
from autotrainer.core.video_detection import PresenceDetectionAttrs
|
|
11
|
+
from autotrainer.core.analysis.load_cell_monitor import LoadCellMonitor
|
|
12
|
+
|
|
13
|
+
logger = get_verbose_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GlobalAnimalPresenceMonitor(BaseDetector):
|
|
17
|
+
|
|
18
|
+
use_daemon = True
|
|
19
|
+
feature_enabled = True
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self, *,
|
|
23
|
+
config: GlobalAnimalPresenceConfig,
|
|
24
|
+
load_cell_monitor: LoadCellMonitor,
|
|
25
|
+
topcam_presence: PresenceDetectionAttrs,
|
|
26
|
+
):
|
|
27
|
+
super().__init__()
|
|
28
|
+
self._config = config
|
|
29
|
+
self._load_cell_monitor = load_cell_monitor
|
|
30
|
+
self._topcam_presence = topcam_presence
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def config(self):
|
|
34
|
+
return self._config
|
|
35
|
+
|
|
36
|
+
@config.setter
|
|
37
|
+
def config(self, value):
|
|
38
|
+
self._config = value
|
|
39
|
+
|
|
40
|
+
def _check_state(self):
|
|
41
|
+
perf_now = get_perf_now()
|
|
42
|
+
cfg = self._config
|
|
43
|
+
load_cell_mon = self._load_cell_monitor.context
|
|
44
|
+
topcam_presence = self._topcam_presence
|
|
45
|
+
if topcam_presence is None:
|
|
46
|
+
logger.verbose("topcam presence is not configured")
|
|
47
|
+
return None
|
|
48
|
+
top_cam_pres_age = perf_now - topcam_presence.last_presence_start_perf_c
|
|
49
|
+
delay_seconds = cfg.presence_missing_delay_hours * 3600
|
|
50
|
+
timer_delay = 1
|
|
51
|
+
diff_started = perf_now - self._p_started
|
|
52
|
+
if diff_started < delay_seconds or load_cell_mon.is_engaged:
|
|
53
|
+
new_engaged = False
|
|
54
|
+
if diff_started < delay_seconds:
|
|
55
|
+
timer_delay = delay_seconds - diff_started
|
|
56
|
+
top_cam_miss = math.nan
|
|
57
|
+
load_cell_miss = math.nan
|
|
58
|
+
else:
|
|
59
|
+
top_cam_miss = delay_seconds - top_cam_pres_age
|
|
60
|
+
load_cell_miss = delay_seconds - load_cell_mon.disengaged_age
|
|
61
|
+
if top_cam_miss <= 0 and load_cell_miss <= 0:
|
|
62
|
+
new_engaged = True
|
|
63
|
+
else:
|
|
64
|
+
new_engaged = False
|
|
65
|
+
timer_delay = max(top_cam_miss, load_cell_miss)
|
|
66
|
+
logger.debug("engaged=%s top_cam_miss=%.1f load_cell_miss=%.1f ; new_delay=%.1f",
|
|
67
|
+
new_engaged, top_cam_miss, load_cell_miss, timer_delay)
|
|
68
|
+
self.is_engaged = new_engaged
|
|
69
|
+
return timer_delay
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclasses.dataclass
|
|
6
|
+
class HeadFixMeasurement:
|
|
7
|
+
when: float = 0 # realtime timestamp, i.e: "UNIX" timestamp, in float seconds as normally.
|
|
8
|
+
timestamp: int = 0 # perf counter timestamp, in integer nanoseconds
|
|
9
|
+
weight: float = 0
|
|
10
|
+
switch: bool = False
|
|
11
|
+
pressure: float = 0
|
|
12
|
+
temperature: float = 0
|
|
13
|
+
humidity: float = 0
|
|
14
|
+
spectrum: List[float] = dataclasses.field(default_factory=list)
|
|
15
|
+
head_contact: bool = False
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from math import floor
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import List
|
|
5
|
+
from typing_extensions import Self
|
|
6
|
+
|
|
7
|
+
import numpy
|
|
8
|
+
|
|
9
|
+
from ..observable_object import ObservableObject
|
|
10
|
+
from ..event import EventManager, ApiEventKind, post_api_event_content
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class HeadbarPressureConfiguration:
|
|
15
|
+
threshold: float = 20
|
|
16
|
+
duration: float = 0.5
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def from_version_zero(cls, content: dict) -> Self:
|
|
20
|
+
return cls(
|
|
21
|
+
threshold=content.get("threshold", 30),
|
|
22
|
+
duration=content.get("duration", 0.25)
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HeadbarPressureMonitor(ObservableObject):
|
|
27
|
+
"""
|
|
28
|
+
Monitor the headbar pressure data stream and perform any required analysis. The current implementation is used to
|
|
29
|
+
determine if the headbar is considered sufficiently "engaged" or not. At this time, this is specifically used
|
|
30
|
+
downstream to allow for the tunnel auto-clamp behavior, when enabled.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
IS_ENGAGED_PROPERTY = "is_engaged"
|
|
34
|
+
|
|
35
|
+
def __init__(self):
|
|
36
|
+
super().__init__()
|
|
37
|
+
|
|
38
|
+
self._is_engaged = False
|
|
39
|
+
self._force_engaged = False
|
|
40
|
+
|
|
41
|
+
self._sample_rate = 100
|
|
42
|
+
|
|
43
|
+
self._load_cell_engaged_threshold: float = 30
|
|
44
|
+
self._duration: float = 0.25
|
|
45
|
+
|
|
46
|
+
self._values = numpy.empty((1, 0))
|
|
47
|
+
|
|
48
|
+
self._buffer_length: float = 1.0
|
|
49
|
+
self._retain_count: int = 1
|
|
50
|
+
|
|
51
|
+
self._window_count: int = 3
|
|
52
|
+
|
|
53
|
+
self._first_third = 1
|
|
54
|
+
self._last_third = 1
|
|
55
|
+
|
|
56
|
+
self._rebuild_buffers()
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def sample_rate(self) -> int:
|
|
60
|
+
return self._sample_rate
|
|
61
|
+
|
|
62
|
+
@sample_rate.setter
|
|
63
|
+
def sample_rate(self, value: int) -> None:
|
|
64
|
+
# Odd number sample rates lead to mismatched indexing. Can just handle here once by effectively doing the
|
|
65
|
+
# round.
|
|
66
|
+
self._sample_rate = value + (value % 2)
|
|
67
|
+
self._rebuild_buffers()
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def load_cell_engaged_threshold(self) -> float:
|
|
71
|
+
return self._load_cell_engaged_threshold
|
|
72
|
+
|
|
73
|
+
@load_cell_engaged_threshold.setter
|
|
74
|
+
def load_cell_engaged_threshold(self, value: float) -> None:
|
|
75
|
+
prev, self._load_cell_engaged_threshold = self._load_cell_engaged_threshold, value
|
|
76
|
+
self._on_property_changed("threshold", value, prev)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def duration(self) -> float:
|
|
80
|
+
return self._duration
|
|
81
|
+
|
|
82
|
+
@duration.setter
|
|
83
|
+
def duration(self, value: float) -> None:
|
|
84
|
+
prev, self._duration = self._duration, value
|
|
85
|
+
self._on_property_changed("duration", value, prev)
|
|
86
|
+
self._rebuild_buffers()
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def is_engaged(self) -> bool:
|
|
90
|
+
return self._is_engaged or self._force_engaged
|
|
91
|
+
|
|
92
|
+
@is_engaged.setter
|
|
93
|
+
def is_engaged(self, value):
|
|
94
|
+
value = value or self._force_engaged
|
|
95
|
+
old_value, self._is_engaged = self._is_engaged, value
|
|
96
|
+
if value != old_value:
|
|
97
|
+
self.property_changed(self.IS_ENGAGED_PROPERTY, value, old_value)
|
|
98
|
+
|
|
99
|
+
def load_configuration(self, configuration: HeadbarPressureConfiguration):
|
|
100
|
+
self.load_cell_engaged_threshold = configuration.threshold
|
|
101
|
+
self.duration = configuration.duration
|
|
102
|
+
|
|
103
|
+
def save_configuration(self) -> HeadbarPressureConfiguration:
|
|
104
|
+
return HeadbarPressureConfiguration(threshold=self._load_cell_engaged_threshold, duration=self._duration)
|
|
105
|
+
|
|
106
|
+
def update(self, values: List[float], when: float = 0.0, index: int = 0) -> bool:
|
|
107
|
+
self._values = numpy.append(self._values, values)
|
|
108
|
+
self._values = self._values[-self._retain_count:]
|
|
109
|
+
|
|
110
|
+
if len(self._values) < self._retain_count:
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
is_engaged = False
|
|
114
|
+
|
|
115
|
+
# Values are appended and dropped in batches. Need to evaluate over the full window size as more than just one
|
|
116
|
+
# sample will be gone the next evaluation. We could just evaluate oldest last window_count samples, but there
|
|
117
|
+
# would be greater latency in the response. We are also evaluating the same window of samples multiple times
|
|
118
|
+
# depending on the measurement batch size, but this is simple calculation and not worth optimizing out at the
|
|
119
|
+
# moment.
|
|
120
|
+
for idx in range(self._window_count * 3):
|
|
121
|
+
new_start = idx + self._last_third
|
|
122
|
+
new_end = idx + self._window_count
|
|
123
|
+
old_start = idx
|
|
124
|
+
old_end = idx + self._first_third
|
|
125
|
+
|
|
126
|
+
if numpy.all(self._values[old_start:old_end] <= (
|
|
127
|
+
self._values[new_start:new_end] - self._load_cell_engaged_threshold)):
|
|
128
|
+
is_engaged = True
|
|
129
|
+
break
|
|
130
|
+
|
|
131
|
+
is_engaged |= self._force_engaged
|
|
132
|
+
prev_engaged = self._is_engaged
|
|
133
|
+
self.is_engaged = is_engaged
|
|
134
|
+
if is_engaged != prev_engaged:
|
|
135
|
+
EventManager.default().post_event_content(
|
|
136
|
+
ApiEventKind.headbarPressureEngagedChanged,
|
|
137
|
+
data=dict(is_engaged=is_engaged),
|
|
138
|
+
when=datetime.fromtimestamp(when), index=index)
|
|
139
|
+
return is_engaged
|
|
140
|
+
|
|
141
|
+
def _rebuild_buffers(self):
|
|
142
|
+
# Measurements are received in batches. Depending on that batch size, it may not result in a continuous, moving
|
|
143
|
+
# evaluation with data processed in batches. Store a larger buffer than the window so we can apply the window
|
|
144
|
+
# over each starting and ending element.
|
|
145
|
+
self._buffer_length = self._duration * 4
|
|
146
|
+
self._retain_count = round(self._sample_rate * self._buffer_length)
|
|
147
|
+
|
|
148
|
+
self._window_count = round(self._sample_rate * self._duration)
|
|
149
|
+
|
|
150
|
+
self._first_third = floor(self._window_count / 3)
|
|
151
|
+
self._last_third = self._window_count - self._first_third
|
|
152
|
+
|
|
153
|
+
def force_engaged(self, engaged: bool) -> None:
|
|
154
|
+
"""
|
|
155
|
+
Primarily used for testing. This will force the headbar pressure monitor to be engaged or not engaged.
|
|
156
|
+
"""
|
|
157
|
+
self._force_engaged = engaged
|
|
158
|
+
self.is_engaged = engaged
|