auto-trainer-core 2.0.1.dev349__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.dev349.dist-info/METADATA +19 -0
  2. auto_trainer_core-2.0.1.dev349.dist-info/RECORD +73 -0
  3. auto_trainer_core-2.0.1.dev349.dist-info/WHEEL +5 -0
  4. auto_trainer_core-2.0.1.dev349.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,108 @@
1
+ import dataclasses
2
+ import math
3
+ from typing import Optional, Tuple, List
4
+
5
+ from autotrainer.api import ApiDetectorKind
6
+
7
+ from autotrainer.core.diamond_triangle_config import DiamondTriangleOffsetConfig
8
+ from autotrainer.core import get_perf_now, Offset3DTuple, calculate_std_dev_manual
9
+ from autotrainer.core.logging import get_verbose_logger
10
+ from autotrainer.core.analysis.detector import BaseDetector
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 PelletMisplacedDetectorConfiguration:
21
+ enabled: bool = True
22
+ aggregate_duration: float = 1 # how long ago to check/look at results
23
+ # previous results older than that are discarded before each check/update.
24
+
25
+ use_dcs_y_low_limit: bool = True
26
+ dcs_y_low_limit: float = 0 # lower than this -> error condition
27
+
28
+
29
+ class PelletMisplacedDetector(BaseDetector):
30
+
31
+ use_daemon = True
32
+ default_timer_delay = 0.25 # default delay between each check of the possible condition(s)
33
+ # NB: should be at least 2 times smaller than aggregate_duration, otherwise given we expire too old data
34
+ # before checking the condition, we could always have the data set empty.
35
+
36
+ def __init__(self, config: PelletMisplacedDetectorConfiguration):
37
+ super().__init__()
38
+ self._config = config
39
+ self._prev_data: List[Tuple[float, Offset3DTuple]] = []
40
+ self._dcs_config: Optional[DiamondTriangleOffsetConfig] = None
41
+
42
+ @BaseDetector.is_engaged.setter
43
+ def is_engaged(self, value):
44
+ prev = self._is_engaged
45
+ BaseDetector.is_engaged.fset(self, value)
46
+ if prev != value:
47
+ self.post_detector_event(ApiDetectorKind.pelletMisplaced, value)
48
+
49
+ @property
50
+ def dcs_config(self) -> Optional[DiamondTriangleOffsetConfig]:
51
+ return self._dcs_config
52
+
53
+ @dcs_config.setter
54
+ def dcs_config(self, value: Optional[DiamondTriangleOffsetConfig]):
55
+ logger.verbose("got new DCS config: %s", value)
56
+ self._dcs_config = value
57
+
58
+ def _check_state(self) -> Optional[float]:
59
+ cfg = self._config
60
+ prev_data = self._prev_data
61
+ engaged = False
62
+ p_now = get_perf_now()
63
+ if p_now - self._p_started < cfg.aggregate_duration:
64
+ # wait at least 1 window before consider
65
+ return None
66
+ idx = 0
67
+ while idx < len(prev_data):
68
+ perf_c = prev_data[idx][0]
69
+ if p_now - perf_c <= cfg.aggregate_duration:
70
+ break
71
+ idx += 1
72
+ del prev_data[:idx]
73
+ if cfg.use_dcs_y_low_limit:
74
+ # logger.debug("Checking %s", prev_positions)
75
+ if len(prev_data) > 0 and all(pos.y < cfg.dcs_y_low_limit for _, pos in prev_data):
76
+ engaged = True
77
+ if not self._is_engaged and engaged:
78
+ if len(prev_data) < 2:
79
+ if len(prev_data) == 0:
80
+ avg_pos, stdev_pos = _offset_nans, _offset_nans
81
+ else:
82
+ avg_pos, stdev_pos = prev_data[-1][1], Offset3DTuple(0, 0, 0)
83
+ else:
84
+ avg_pos, stdev_pos = calculate_std_dev_manual([d[1] for d in prev_data])
85
+ logger.notice("New engaged, dcs_pos=%s stdev=%s ; all=%s",
86
+ avg_pos.humanize(), stdev_pos.humanize(), prev_data)
87
+ self.is_engaged = engaged
88
+ return None
89
+
90
+ def update(self, pellet_inference_3d: Optional[Offset3DTuple]):
91
+ dcs_cfg = self._dcs_config
92
+ if dcs_cfg is None or not dcs_cfg.fully_valid: # refuse to guess with not fully valid
93
+ return
94
+ if pellet_inference_3d is None:
95
+ # no need put None 3d loc, the check is done by timer
96
+ return
97
+ rel_to_diamond = dcs_cfg.diamond_coord - pellet_inference_3d
98
+ dcs_pellet_3d = dcs_cfg.inference_to_diamond(rel_to_diamond)
99
+ if __debug__:
100
+ # should not be needed with fully_valid check above.
101
+ if any(map(math.isnan, dcs_pellet_3d)):
102
+ logger.debug("filtering bad pellet3d : %s", pellet_inference_3d)
103
+ return
104
+ p_now = get_perf_now()
105
+ with self._lock:
106
+ self._prev_data.append((p_now, dcs_pellet_3d))
107
+ # self.check_state()
108
+ # no: we don't need resolution/check frequency as high as the input source
@@ -0,0 +1,390 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import os
5
+ import math
6
+ import threading
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Optional, List, Tuple, IO
10
+
11
+ import numpy
12
+
13
+ from autotrainer.core.logging import get_verbose_logger
14
+ from autotrainer.core.project import ProjectInfo, ProjectInterval
15
+ from autotrainer.core.perf_monitor import PerfMonitor
16
+ from autotrainer.core.observable_object import ObservableObject
17
+ from autotrainer.core.video_detection import PresenceDetectionAttrs
18
+
19
+ from ..configuration.alarm_configuration import EmergencyAlarmConfiguration
20
+ from ..configuration.animal_presence_configuration import GlobalAnimalPresenceConfig
21
+ from ..configuration.external_doors_monitor_configuration import ExternalDoorsMonitorConfig
22
+ from ..configuration.system_fault_config import SystemFaultConfig
23
+ from ..configuration.system_maintenance_config import SystemMaintenanceConfig
24
+ from ..message.audio_spectrum_message import AudioSpectrumMessage
25
+
26
+ from .head_fix_measurement import HeadFixMeasurement
27
+ from .audio_spectrum_monitor import AudioSpectrumThrashMonitor
28
+ from .headbar_pressure_monitor import HeadbarPressureMonitor
29
+ from .load_cell_monitor import LoadCellMonitor
30
+ from .load_cell_tare_monitor import LoadCellTareMonitor
31
+ from .alarm_monitor import EmergencyAlarmMonitor
32
+ from .global_animal_presence_monitor import GlobalAnimalPresenceMonitor
33
+ from .external_doors_monitor import ExternalDoorsMonitor
34
+ from .pellet_position_monitor import PelletMisplacedDetector, PelletMisplacedDetectorConfiguration
35
+ from .auto_tunnel_fan_monitor import AutoTunnelSweepMonitor, AutoTunnelSweepConfiguration
36
+ from .system_maintenance_monitor import SystemMaintenanceMonitor
37
+ from .system_fault_monitor import SystemFaultMonitor
38
+
39
+ logger = get_verbose_logger(__name__)
40
+
41
+
42
+ # TODO: Separate true analysis from data recording to file(s) for post-analysis.
43
+ class SensorAnalysis(ObservableObject):
44
+
45
+ def __init__(self, *, topcam_presence: Optional[PresenceDetectionAttrs] = None):
46
+ super().__init__()
47
+
48
+ self._project_info: Optional[ProjectInfo] = None
49
+ self._interval = ProjectInterval.HOUR
50
+
51
+ self._filter_invalid_weight_started = False
52
+
53
+ # The "monitor" CSV file with the bulk of the sensor data.
54
+ self._record_file = None
55
+ self._current_record_interval = -1
56
+ self._had_write_error = False
57
+
58
+ # The audio spectrum file.
59
+ self._audio_record_file_writer: Optional[Tuple[IO[str], csv.DictWriter]] = None
60
+ self._current_audio_record_interval = -1
61
+ self._audio_had_write_error = False
62
+
63
+ self._is_load_cell_engaged = False
64
+ self._load_cell_monitor = LoadCellMonitor()
65
+
66
+ # The analog pressure sensor on the headbar.
67
+ self._is_headbar_pressure_engaged = False
68
+ self._headbar_pressure_monitor = HeadbarPressureMonitor()
69
+
70
+ # The digital i/o switch on the headbar.
71
+ self._is_headbar_switch_engaged = False
72
+
73
+ self._tare_detector = LoadCellTareMonitor()
74
+ self._tare_callback = None
75
+
76
+ self._audio_thrashing_monitor = AudioSpectrumThrashMonitor()
77
+
78
+ self._global_animal_presence_monitor = GlobalAnimalPresenceMonitor(
79
+ config=GlobalAnimalPresenceConfig(),
80
+ load_cell_monitor=self._load_cell_monitor,
81
+ topcam_presence=topcam_presence,
82
+ )
83
+
84
+ self._external_doors_monitor = ExternalDoorsMonitor(ExternalDoorsMonitorConfig())
85
+
86
+ self._pellet_misplaced_monitor = PelletMisplacedDetector(PelletMisplacedDetectorConfiguration())
87
+ self._auto_tunnel_sweep_monitor = AutoTunnelSweepMonitor(
88
+ AutoTunnelSweepConfiguration(),
89
+ pellet_misplaced_detector=self._pellet_misplaced_monitor,
90
+ )
91
+
92
+ self._system_maintenance_monitor = SystemMaintenanceMonitor(config=SystemMaintenanceConfig())
93
+ self._system_fault_monitor = SystemFaultMonitor(config=SystemFaultConfig())
94
+
95
+ self._alarm_monitor = EmergencyAlarmMonitor(
96
+ config=EmergencyAlarmConfiguration(),
97
+ load_cell_monitor=self._load_cell_monitor,
98
+ load_cell_tare_monitor=self._tare_detector,
99
+ audio_monitor=self._audio_thrashing_monitor,
100
+ external_doors_monitor=self._external_doors_monitor,
101
+ global_animal_presence_monitor=self._global_animal_presence_monitor,
102
+ system_maintenance_monitor=self._system_maintenance_monitor,
103
+ system_fault_monitor=self._system_fault_monitor,
104
+ topcam_presence_attrs=topcam_presence,
105
+ )
106
+
107
+ self._perf_monitor = PerfMonitor(name="<sensor-analysis>", units="mps", report_window=30)
108
+
109
+ self._detectors = [
110
+ self._alarm_monitor, # put first
111
+ self._load_cell_monitor,
112
+ self._tare_detector,
113
+ self._audio_thrashing_monitor,
114
+ self._external_doors_monitor,
115
+ self._global_animal_presence_monitor,
116
+ self._pellet_misplaced_monitor,
117
+ self._auto_tunnel_sweep_monitor,
118
+ self._system_maintenance_monitor,
119
+ self._system_fault_monitor,
120
+ ]
121
+
122
+ @property
123
+ def detectors(self):
124
+ return self._detectors
125
+
126
+ def start(self):
127
+ for detector in self._detectors:
128
+ detector.start()
129
+
130
+ def stop(self):
131
+ for detector in self._detectors:
132
+ detector.stop()
133
+
134
+ def restart(self):
135
+ for detector in self._detectors:
136
+ detector.restart()
137
+
138
+ @property
139
+ def project_info(self) -> Optional[ProjectInfo]:
140
+ return self._project_info
141
+
142
+ @project_info.setter
143
+ def project_info(self, value: ProjectInfo) -> None:
144
+ self._project_info = value
145
+ self._update_record_file()
146
+ self._update_audio_file()
147
+ self._perf_monitor.reset()
148
+
149
+ @property
150
+ def interval(self) -> ProjectInterval:
151
+ return self._interval
152
+
153
+ @interval.setter
154
+ def interval(self, value: ProjectInterval) -> None:
155
+ self._interval = value
156
+
157
+ @property
158
+ def load_cell_monitor(self) -> LoadCellMonitor:
159
+ return self._load_cell_monitor
160
+
161
+ @property
162
+ def headbar_pressure_monitor(self) -> HeadbarPressureMonitor:
163
+ return self._headbar_pressure_monitor
164
+
165
+ @property
166
+ def load_cell_tare_monitor(self) -> LoadCellTareMonitor:
167
+ return self._tare_detector
168
+
169
+ @property
170
+ def audio_thrashing_monitor(self) -> AudioSpectrumThrashMonitor:
171
+ return self._audio_thrashing_monitor
172
+
173
+ @property
174
+ def emergency_alarm_monitor(self) -> EmergencyAlarmMonitor:
175
+ return self._alarm_monitor
176
+
177
+ @property
178
+ def global_animal_presence_monitor(self) -> GlobalAnimalPresenceMonitor:
179
+ return self._global_animal_presence_monitor
180
+
181
+ @property
182
+ def external_doors_monitor(self) -> ExternalDoorsMonitor:
183
+ return self._external_doors_monitor
184
+
185
+ @property
186
+ def pellet_misplaced_monitor(self) -> PelletMisplacedDetector:
187
+ return self._pellet_misplaced_monitor
188
+
189
+ @property
190
+ def auto_tunnel_sweep_monitor(self) -> AutoTunnelSweepMonitor:
191
+ return self._auto_tunnel_sweep_monitor
192
+
193
+ @property
194
+ def system_maintenance_monitor(self) -> SystemMaintenanceMonitor:
195
+ return self._system_maintenance_monitor
196
+
197
+ @property
198
+ def system_fault_monitor(self) -> SystemFaultMonitor:
199
+ return self._system_fault_monitor
200
+
201
+ @property
202
+ def is_headbar_switch_engaged(self):
203
+ # TODO - this signal is not currently encapsulated in an object for whatever analysis is needed like the others.
204
+ # If it is used in the future, this should probably be refactored similar to the other sub-components.
205
+ return self._is_headbar_switch_engaged
206
+
207
+ def stream_start(self):
208
+ logger.verbose("SensorAnalysis: stream_start")
209
+ self._perf_monitor.reset()
210
+
211
+ def measurements_received(
212
+ self,
213
+ measurements: List[HeadFixMeasurement]
214
+ ) -> Tuple[List[float], List[bool], List[float], List[float], List[float]] :
215
+ """Return weight_vals, switch_vals, pressure_vals, temperature_vals, humidity_vals"""
216
+ # logger.spam("Received %s measures", len(measurements))
217
+ assert len(measurements) > 0
218
+
219
+ switch_vals: List[bool] = []
220
+ pressure_vals: List[float] = []
221
+ temperature_vals: List[float] = []
222
+ humidity_vals: List[float] = []
223
+
224
+ fh = self._record_file
225
+ if fh is not None:
226
+ now = datetime.now()
227
+ needs_update = now.hour != self._current_record_interval \
228
+ if self._interval == ProjectInterval.HOUR \
229
+ else now.minute != self._current_record_interval
230
+ if needs_update:
231
+ self._update_record_file()
232
+
233
+ load_cell_mon = self._load_cell_monitor
234
+
235
+ for m in measurements:
236
+ # NB: save all measurements to file
237
+ switch_vals.append(m.switch)
238
+ pressure_vals.append(m.pressure)
239
+ temperature_vals.append(m.temperature)
240
+ humidity_vals.append(m.humidity)
241
+
242
+ fh = self._record_file
243
+ if fh is not None:
244
+ try:
245
+ fh.write(
246
+ f"{m.when}, {m.timestamp}, {m.weight}, {int(m.switch)}, {m.pressure}, "
247
+ f"{m.temperature}, {m.humidity}, {int(load_cell_mon.is_engaged)}\n")
248
+ except Exception as err:
249
+ # This could be too much if something major is wrong. Just output once per file rotation.
250
+ if not self._had_write_error:
251
+ logger.exception("<sensor-analysis>: unable to write: %s", err)
252
+ self._had_write_error = True
253
+
254
+ # Load cell monitor. and Auto-Tare monitor
255
+ weight_vals: List[float] = []
256
+ filtered_weight_vals: List[float] = []
257
+ load_cell_cfg = load_cell_mon.config
258
+ for m in measurements:
259
+ value = m.weight
260
+ weight_vals.append(value)
261
+ if not (load_cell_cfg.weight_min_filter < value < load_cell_cfg.weight_max_filter):
262
+ filtered_weight_vals.append(math.nan)
263
+ if not self._filter_invalid_weight_started:
264
+ self._filter_invalid_weight_started = True
265
+ logger.verbose(
266
+ "starting filter value outside accepted range: %s", value
267
+ )
268
+ continue
269
+ if self._filter_invalid_weight_started:
270
+ logger.verbose("finished filter value outside accepted range: %s", value)
271
+ self._filter_invalid_weight_started = False
272
+ #
273
+ filtered_weight_vals.append(value)
274
+ self._load_cell_monitor.update(value, m.when, m.timestamp)
275
+ # (Auto-)tare detection.
276
+ self._tare_detector.update(filtered_weight_vals)
277
+
278
+ # Headbar analog pressure monitor.
279
+ first_measure = measurements[0]
280
+ self._headbar_pressure_monitor.update(pressure_vals, first_measure.when, first_measure.timestamp)
281
+
282
+ # Headbar digital switch - no real implementation at this time.
283
+ self._is_headbar_switch_engaged = numpy.mean(switch_vals) > 0.5
284
+
285
+ # Performance monitoring.
286
+ self._perf_monitor.add_cycles(len(measurements))
287
+
288
+ return weight_vals, switch_vals, pressure_vals, temperature_vals, humidity_vals
289
+
290
+ def audio_spectrum_received(self, spectrum: AudioSpectrumMessage):
291
+ if spectrum is None or not spectrum.magnitudes:
292
+ return
293
+
294
+ self._audio_thrashing_monitor.update(spectrum.magnitudes, spectrum.when, spectrum.index)
295
+
296
+ cur = self._audio_record_file_writer
297
+ if cur is not None:
298
+ file_timestamp = datetime.now()
299
+ needs_update = file_timestamp.hour != self._current_audio_record_interval \
300
+ if self._interval == ProjectInterval.HOUR \
301
+ else file_timestamp.minute != self._current_audio_record_interval
302
+ if needs_update:
303
+ self._update_audio_file()
304
+
305
+ # May or may not exist after the above.
306
+ cur = self._audio_record_file_writer
307
+ if cur is not None:
308
+ _, writer = cur
309
+ try:
310
+ r = dict(
311
+ Time=spectrum.when,
312
+ Index=spectrum.index,
313
+ **{f"Bin {i}": val for i, val in enumerate(spectrum.magnitudes)},
314
+ )
315
+ writer.writerow(r)
316
+ except Exception as err:
317
+ # This could be too much if something major is wrong. Just output once per file rotation.
318
+ if not self._audio_had_write_error:
319
+ logger.exception("<sensor-analysis>: unable to write: %s", err)
320
+ self._audio_had_write_error = True
321
+
322
+ def _update_record_file(self):
323
+ fh = self._record_file
324
+ if fh is not None:
325
+ self._record_file = None
326
+ try:
327
+ fh.flush()
328
+ fh.close()
329
+ except Exception as err:
330
+ logger.warning("Failure closing record file: %s", err)
331
+
332
+ if self._project_info is None:
333
+ return
334
+ interval_file_info = self._project_info.get_monitor_file(interval=self._interval, when=datetime.now())
335
+ if interval_file_info is None:
336
+ logger.error("<sensor-analysis>: unable to write to expected monitor file location")
337
+ return
338
+ dest_path = Path(interval_file_info.file)
339
+ try:
340
+ file_existed = dest_path.exists()
341
+ fh = dest_path.open("a")
342
+ if not file_existed:
343
+ fh.write("Time, Index, Weight, Switch, Pressure, Temperature, Humidity, LoadCellEngaged\n")
344
+ fh.flush()
345
+ self._current_record_interval = interval_file_info.current_interval
346
+ self._had_write_error = False
347
+ self._record_file = fh # last
348
+ except Exception as err:
349
+ logger.error("<sensor-analysis>: unable to write to %s: %s",dest_path, err)
350
+ logger.info("<sensor-analysis>: saving to %s", dest_path)
351
+
352
+ def _update_audio_file(self) -> None:
353
+ cur = self._audio_record_file_writer
354
+ if cur is not None:
355
+ fh, writer = cur
356
+ try:
357
+ fh.flush()
358
+ fh.close()
359
+ except Exception as err:
360
+ logger.warning("audio record file close failed: %s", err)
361
+ self._audio_record_file_writer = None
362
+
363
+ if self._project_info is not None:
364
+ interval_file_info = self._project_info.get_audio_spectrum_file(
365
+ interval=self._interval, when=datetime.now())
366
+
367
+ if interval_file_info is None:
368
+ logger.error("<sensor-analysis>: unable to write to expected audio file location")
369
+ return None
370
+
371
+ try:
372
+ file_existed = os.path.exists(interval_file_info.file)
373
+
374
+ fh = open(interval_file_info.file, "a")
375
+ writer = csv.DictWriter(
376
+ fh,
377
+ fieldnames=('Time', 'Index', *(f'Bin {i}' for i in range(64))),
378
+ )
379
+
380
+ if not file_existed:
381
+ writer.writeheader()
382
+
383
+ self._current_audio_record_interval = interval_file_info.current_interval
384
+ self._audio_record_file_writer = (fh, writer)
385
+ logger.info(f"<sensor-analysis>: saving audio spectrum to {interval_file_info.file}")
386
+ self._audio_had_write_error = False
387
+ except Exception as err:
388
+ logger.error("<sensor-analysis>: unable to write to %r: %s", interval_file_info.file, err)
389
+
390
+ return None
@@ -0,0 +1,81 @@
1
+ from typing import Optional, Set, List
2
+
3
+ import psutil
4
+
5
+ from .detector import BaseDetector
6
+ from ..configuration.persistence_configuration import PersistenceConfiguration
7
+ from ..configuration.system_fault_config import SystemFaultConfig
8
+ from ..event import post_api_detector_event_content
9
+ from ...api import ApiDetectorKind
10
+
11
+
12
+ class SystemFaultMonitor(BaseDetector):
13
+
14
+ use_daemon = True # for free disk space checks
15
+ default_timer_delay = 30 # secs ; check is very fast so can afford do it regularly
16
+
17
+ CONFIG = "config"
18
+
19
+ FREE_DISK_SPACE_ENGAGED = "free_disk_space_engaged"
20
+
21
+ def __init__(self, *, config: SystemFaultConfig):
22
+ super().__init__()
23
+ self._config = config
24
+ self._max_pellet_loaded_engaged = False
25
+ self._max_consecutive_failed_load_engaged = False
26
+ self._free_disk_space_engaged = False
27
+ self._engaged_reasons: Set[str] = set()
28
+ self._persistence_cfg = PersistenceConfiguration()
29
+
30
+ def set_persistence_config(self, config: PersistenceConfiguration):
31
+ self._persistence_cfg = config
32
+ self._logger.verbose("Received persistence config: %s", config)
33
+
34
+ @property
35
+ def config(self) -> SystemFaultConfig:
36
+ return self._config
37
+
38
+ @config.setter
39
+ def config(self, value: SystemFaultConfig):
40
+ prev, self._config = self._config, value
41
+ self._on_property_changed(self.CONFIG, value, prev)
42
+ self._logger.verbose("Received config: %s", value)
43
+
44
+ @property
45
+ def engaged_reasons(self) -> List[str]:
46
+ return list(self._engaged_reasons)
47
+
48
+ @property
49
+ def free_disk_space_engaged(self):
50
+ return self._free_disk_space_engaged
51
+
52
+ @free_disk_space_engaged.setter
53
+ def free_disk_space_engaged(self, value):
54
+ prev, self._free_disk_space_engaged = self._free_disk_space_engaged, value
55
+ self._on_property_changed(self.FREE_DISK_SPACE_ENGAGED, value, prev)
56
+ if value != prev:
57
+ self.check_state_if_not_detector_thread()
58
+ post_api_detector_event_content(self._event_manager, ApiDetectorKind.lowFreeDiskSpace,
59
+ value, self._config.use_free_disk_space)
60
+
61
+ def _check_free_disk_space(self):
62
+ cfg = self._config
63
+ usage = psutil.disk_usage(self._persistence_cfg.output_location)
64
+ # usage free is in bytes:
65
+ engaged = usage.free / 2 ** 20 < cfg.free_disk_space_min_limit_mb
66
+ self.free_disk_space_engaged = engaged
67
+
68
+ def _check_state(self) -> Optional[float]:
69
+ self._check_free_disk_space()
70
+ cfg = self._config
71
+ reasons = set()
72
+ for reason, use, engaged in (
73
+ (self.FREE_DISK_SPACE_ENGAGED, cfg.use_free_disk_space, self._free_disk_space_engaged),
74
+ ):
75
+ if use and engaged:
76
+ reasons.add(reason)
77
+ self._engaged_reasons = reasons
78
+ prev_engaged = self._is_engaged
79
+ self.is_engaged = len(reasons) > 0
80
+ if not prev_engaged and self._is_engaged:
81
+ self._logger.notice("Engaging with %s", reasons)
@@ -0,0 +1,88 @@
1
+ from typing import Optional, Set, List
2
+
3
+ import psutil
4
+
5
+ from .detector import BaseDetector
6
+ from ..configuration.persistence_configuration import PersistenceConfiguration
7
+ from ..configuration.system_maintenance_config import SystemMaintenanceConfig
8
+ from ...api import ApiDetectorKind
9
+
10
+
11
+ class SystemMaintenanceMonitor(BaseDetector):
12
+
13
+ CONFIG = "config"
14
+
15
+ MAX_PELLET_LOADED_ENGAGED = "max_pellet_loaded_engaged"
16
+ MAX_CONSECUTIVE_FAILED_LOAD_ENGAGED = "max_consecutive_failed_load_engaged"
17
+
18
+ def __init__(self, *, config: SystemMaintenanceConfig):
19
+ super().__init__()
20
+ self._config = config
21
+ self._max_pellet_loaded_engaged = False
22
+ self._max_consecutive_failed_load_engaged = False
23
+ self._free_disk_space_engaged = False
24
+ self._engaged_reasons: Set[str] = set()
25
+
26
+ @property
27
+ def config(self) -> SystemMaintenanceConfig:
28
+ return self._config
29
+
30
+ @config.setter
31
+ def config(self, value):
32
+ prev, self._config = self._config, value
33
+ self._on_property_changed(self.CONFIG, value, prev)
34
+ self._logger.verbose("Received config: %s", value)
35
+
36
+ @property
37
+ def engaged_reasons(self) -> List[str]:
38
+ return list(self._engaged_reasons)
39
+
40
+ @property
41
+ def max_pellet_loaded_engaged(self):
42
+ return self._max_pellet_loaded_engaged
43
+
44
+ @max_pellet_loaded_engaged.setter
45
+ def max_pellet_loaded_engaged(self, value):
46
+ prev, self._max_pellet_loaded_engaged = self._max_pellet_loaded_engaged, value
47
+ self._on_property_changed(self.MAX_PELLET_LOADED_ENGAGED, value, prev)
48
+ if value != prev:
49
+ self.post_detector_event(ApiDetectorKind.pelletRefillCountExceeded, value, self._config.use_max_pellet_loaded)
50
+ self.check_state_if_not_detector_thread()
51
+
52
+ @property
53
+ def max_consecutive_failed_load_engaged(self):
54
+ return self._max_consecutive_failed_load_engaged
55
+
56
+ @max_consecutive_failed_load_engaged.setter
57
+ def max_consecutive_failed_load_engaged(self, value):
58
+ prev, self._max_consecutive_failed_load_engaged = self._max_consecutive_failed_load_engaged, value
59
+ self._on_property_changed(self.MAX_CONSECUTIVE_FAILED_LOAD_ENGAGED, value, prev)
60
+ if value != prev:
61
+ self.post_detector_event(ApiDetectorKind.consectivePelletLoadFailureExceeded, value,
62
+ self._config.use_max_consecutive_failed_load)
63
+ self.check_state_if_not_detector_thread()
64
+
65
+ def _check_state(self) -> Optional[float]:
66
+ cfg = self._config
67
+ reasons = set()
68
+ for reason, use, engaged in (
69
+ (self.MAX_PELLET_LOADED_ENGAGED, cfg.use_max_pellet_loaded, self._max_pellet_loaded_engaged),
70
+ (self.MAX_CONSECUTIVE_FAILED_LOAD_ENGAGED, cfg.use_max_consecutive_failed_load, self._max_consecutive_failed_load_engaged),
71
+ ):
72
+ if use and engaged:
73
+ reasons.add(reason)
74
+ self._engaged_reasons = reasons
75
+ prev_engaged = self._is_engaged
76
+ self.is_engaged = len(reasons) > 0
77
+ if not prev_engaged and self._is_engaged:
78
+ self._logger.notice("Engaging with %s", reasons)
79
+
80
+ def update_pellet_loaded(self, loaded: int):
81
+ cfg = self._config
82
+ engaged = loaded >= cfg.max_pellets_loaded_count
83
+ self.max_pellet_loaded_engaged = engaged
84
+
85
+ def update_failed_pellet_load(self, *, consecutive: int):
86
+ cfg = self._config
87
+ engaged = consecutive >= cfg.max_consecutive_failed_loaded
88
+ self.max_consecutive_failed_load_engaged = engaged
@@ -0,0 +1 @@
1
+ from .animal_subject import AnimalSubject