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,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