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,112 @@
1
+ from enum import IntEnum
2
+ from typing import Protocol
3
+
4
+
5
+ class SystemStatusMessageKind(IntEnum):
6
+ """
7
+ This class defines the messages types that are sent by the device. If there is a request/command that generates
8
+ a response, the command will be defined in the SystemCommandKind class.
9
+
10
+ A message type is sent to the caller with an optional context/data value. This value is dependent on the message
11
+ type.
12
+
13
+ This is implemented and represented in the send_message() method in the DeviceApi class.
14
+ """
15
+
16
+ ACKNOWLEDGE = 1
17
+ """
18
+ This message should be send when a command completes along with the context that is provided with the command. It
19
+ should not be sent with status messages that are generated independently by the hardware. The is the mechanism by
20
+ which callers determine if and when specific commands are completed.
21
+ """
22
+
23
+ FIRMWARE_VERSION = 101
24
+ """Message will contain the firmware version of the device as a string."""
25
+ # TODO: This needs to be more complex than a string now that different modules are part of the same device and
26
+ # connection.
27
+
28
+ PELLET_MOTOR_X = 201
29
+ """Message will contain the pellet X motor status as a StepperStatusMessage."""
30
+
31
+ PELLET_MOTOR_Y = 202
32
+ """Message will contain the pellet Y motor status as a StepperStatusMessage."""
33
+
34
+ PELLET_MOTOR_Z = 203
35
+ """Message will contain the pellet Z motor status as a StepperStatusMessage."""
36
+
37
+ PELLET_LOAD = 204
38
+ """Message will contain the pellet load arm servo status as a ServoStatusMessage."""
39
+
40
+ PELLET_COVER = 205
41
+ """Message will contain the pellet cover arm servo status as a ServoStatusMessage."""
42
+
43
+ HEAD_MAGNET = 206
44
+ """Message will contain the head fixation magnet servo status as a ServoStatusMessage."""
45
+
46
+ TUNNEL_GATE_SERVO = 207
47
+ """Message will contain the tunnel gate servo status as a ServoStatusMessage."""
48
+
49
+ PELLET_X = 208
50
+ """Message will contain the pellet X motor position as a float."""
51
+
52
+ PELLET_Y = 209
53
+ """Message will contain the pellet Y motor position as a float."""
54
+
55
+ PELLET_Z = 210
56
+ """Message will contain the pellet Z motor position as a float."""
57
+
58
+ FRONT_DOOR = 301
59
+ """Message will contain front door status as True (open) or False (closed)."""
60
+
61
+ DRAWER_DOOR = 302
62
+ """Message will contain drawer door status as True (open) or False (closed)."""
63
+
64
+ SPARE_DOOR = 303
65
+ """Message will contain spare door status as True (open) or False (closed)."""
66
+
67
+ EXT_BUTTON = 304
68
+ """Message will contain external button status as True (pressed) or False (released)"""
69
+
70
+ STIMULUS_INPUTS = 305
71
+ """Message will contain a list of 4 states as True/False"""
72
+
73
+ TUNNEL_FAN = 220
74
+ """Tunnel Fan will contain the tunnel fan status as a ServoStatusMessage"""
75
+
76
+ MEASUREMENTS = 401
77
+ """Message will contain a list of MeasurementMessage objects."""
78
+
79
+ AUDIO_SPECTRUM = 402
80
+ """Message will contain the audio spectrum data as an array list of float values."""
81
+
82
+ MOTOR_CONFIGURATION = 501
83
+
84
+ CAMERA_STATUS_CHANGE = 1001
85
+
86
+ MEASUREMENT = -101
87
+ """This value is deprecated. Use MEASUREMENTS if the object you are passing with this identifier conforms to the
88
+ MeasurementMessage protocol."""
89
+
90
+
91
+ class StepperStatusMessage(Protocol):
92
+ @property
93
+ def position(self) -> float:
94
+ """Current motor position in mm."""
95
+
96
+ @property
97
+ def send_position(self) -> float:
98
+ """Current motor delivery position in mm."""
99
+
100
+ @property
101
+ def is_at_limit(self) -> bool:
102
+ """True if the limit switch has been hit, otherwise False."""
103
+
104
+
105
+ class ServoStatusMessage(Protocol):
106
+ @property
107
+ def location(self) -> float:
108
+ """Current servo position in degrees."""
109
+
110
+ @property
111
+ def status(self) -> int:
112
+ """Current servo status value."""
@@ -0,0 +1,50 @@
1
+ import logging.config
2
+ import multiprocessing
3
+ import signal
4
+ import threading
5
+
6
+
7
+ def get_mp_ctx():
8
+ return multiprocessing.get_context("spawn")
9
+
10
+
11
+ class EmptyWithContext:
12
+
13
+ def __enter__(self):
14
+ pass
15
+
16
+ def __exit__(self, exc_type, exc_val, exc_tb):
17
+ pass
18
+
19
+
20
+ class DaemonTimer(threading.Timer):
21
+ """A Timer that does not block main process exit"""
22
+
23
+ finished: threading.Event
24
+
25
+ def __init__(self, delay, func, args=None, kwargs=None):
26
+ super().__init__(delay, func, args=args, kwargs=kwargs)
27
+ self.daemon = True
28
+
29
+
30
+ def make_daemon_timer(delay, func, *args, **kwargs):
31
+ return DaemonTimer(delay, func, *args, **kwargs)
32
+
33
+
34
+ def pool_init(log_dict_cfg=None):
35
+ """For process pool"""
36
+ # this is to prevent keyboard interrupted being delivered to all child processes of the main process:
37
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
38
+ # given by default this is what is done.
39
+ # inner-import on purpose: prevent import loop:
40
+ from autotrainer.core.logging import setup_logging, install_log_exception_hook
41
+ if log_dict_cfg is None:
42
+ setup_logging()
43
+ else:
44
+ logging.config.dictConfig(log_dict_cfg)
45
+ install_log_exception_hook()
46
+ logging.root.info("Initialized pool worker")
47
+
48
+
49
+ no_op_timer = make_daemon_timer(0, lambda: None)
50
+ no_op_timer.finished.set()
@@ -0,0 +1,2 @@
1
+ from .notification_center import NotificationCenter, Notification
2
+ from .trigger_notification import TriggerNotification, post_trigger_enable
@@ -0,0 +1,80 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any, Callable, Optional, Dict, List, TypeVar
3
+
4
+ from typing_extensions import Self
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Notification:
9
+ """
10
+ A class representing a notification with a type and optional source and context objects.
11
+ """
12
+ event_type: Any
13
+ """
14
+ The type of the event. This is used to identify the event and determine which observers should be notified.
15
+ """
16
+ source: Optional[Any] = None
17
+ """
18
+ The source of the event. This is typically the object that generated the event.
19
+ """
20
+ context: Optional[Any] = None
21
+
22
+
23
+ TCallable = TypeVar("TCallable", bound=Callable[[Notification], None])
24
+
25
+
26
+ class NotificationCenter:
27
+ """
28
+ A publish and subscribe implementation for general message passing where publishers can remain unaware of if there
29
+ are listeners and who they are.
30
+
31
+ Each process has a default notification center. Additional instances may be created to organize notifications in
32
+ specific contexts.
33
+ """
34
+
35
+ @classmethod
36
+ def default_center(cls) -> Self:
37
+ """
38
+ Returns the default instance of NotificationCenter.
39
+ """
40
+ if not hasattr(cls, "_default_center"):
41
+ cls._default_center = cls()
42
+
43
+ return cls._default_center
44
+
45
+ def __init__(self):
46
+ self._subscribers: Dict[str, List[TCallable]] = {}
47
+
48
+ def add_observer(self, event_type: Any, callback: TCallable):
49
+ """
50
+ Subscribe to a specific event type with a callback function.
51
+ """
52
+ if event_type not in self._subscribers:
53
+ self._subscribers[event_type] = []
54
+
55
+ if callback not in self._subscribers[event_type]:
56
+ self._subscribers[event_type].append(callback)
57
+
58
+ def remove_observer(self, event_type: Any, callback: TCallable):
59
+ """
60
+ Unsubscribe from a specific event type.
61
+ """
62
+ if event_type in self._subscribers:
63
+ if callback in self._subscribers[event_type]:
64
+ self._subscribers[event_type].remove(callback)
65
+
66
+ def post_notification(self, **kwargs):
67
+ """
68
+ Notify all subscribers of a specific event type.
69
+ """
70
+ if "notification" in kwargs:
71
+ notification = kwargs["notification"]
72
+ assert isinstance(notification, Notification), "notification must be of type Notification"
73
+ else:
74
+ event_type = kwargs["event_type"]
75
+ assert event_type is not None, "event_type must not be None"
76
+ notification = Notification(event_type, kwargs.get("source"), kwargs.get("context"))
77
+
78
+ if notification.event_type in self._subscribers:
79
+ for callback in self._subscribers[notification.event_type]:
80
+ callback(notification)
@@ -0,0 +1,27 @@
1
+ from enum import Enum
2
+ from typing import Any
3
+
4
+ from .notification_center import NotificationCenter, Notification
5
+
6
+
7
+ class TriggerNotification(Enum):
8
+ CAPTURE_ID = "TriggerNotificationCaptureId"
9
+
10
+
11
+ # Convenience methods
12
+
13
+ def post_trigger_enable(sender: Any, enabled: bool):
14
+ """
15
+ Post a trigger enable notification.
16
+
17
+ Args:
18
+ sender: The sender of the notification.
19
+ enabled: Whether the trigger is enabled or not.
20
+ """
21
+ NotificationCenter.default_center().post_notification(
22
+ notification=Notification(
23
+ event_type=TriggerNotification.CAPTURE_ID,
24
+ source=sender,
25
+ context=enabled
26
+ )
27
+ )
@@ -0,0 +1,105 @@
1
+ import os
2
+ from typing import Protocol, Callable, Any, TypeVar
3
+ from typing_extensions import Self
4
+
5
+ import verboselogs
6
+ from events import Events
7
+
8
+ from autotrainer.core.logging import get_verbose_logger
9
+
10
+ logger = get_verbose_logger(__name__)
11
+
12
+
13
+ _debug_properties_change = {
14
+ v.strip() for v in os.getenv("AUTOTRAINER_DEBUG_PROPERTIES", "").split(",")
15
+ }
16
+
17
+
18
+ HandlerT = TypeVar("HandlerT", bound=Callable[..., None])
19
+
20
+ AnyNewValue = Any
21
+ AnyOldValue = Any
22
+
23
+
24
+ class EventHandler(Protocol[HandlerT]):
25
+ """Single event handler"""
26
+
27
+ __call__: HandlerT
28
+
29
+ def __iadd__(self, handler: HandlerT) -> Self:
30
+ """Register event handler"""
31
+
32
+ def __isub__(self, handler: HandlerT) -> Self:
33
+ """Unregister event handler"""
34
+
35
+
36
+ class ObservableObjectProtocol(Protocol):
37
+ """Allow classes that do not inherit from ObservableObject to indicate they support the property_changed event."""
38
+
39
+ property_changed: EventHandler[Callable[[str, AnyNewValue, AnyOldValue], None]]
40
+
41
+
42
+ class ObservableObject(Events):
43
+ """
44
+ Defines a class with observable property change events. This is a common pattern for UI frameworks in
45
+ particular where some form of a view layer and some type of model layer need to communicate changes in a decoupled
46
+ way. However, it is applicable in many other situations as well.
47
+
48
+ The property_changed event is used by one or more listeners to respond to property changes.
49
+
50
+ _on_property_changed is a convenience method that will only generate an event if the new value does not pass
51
+ the == test with the old value. This prevents needless events and more importantly endless recursion when two
52
+ objects are listening to each other to stay in sync (e.g., UI and model where a change may originate in either).
53
+ """
54
+
55
+ # type hint for dynamic event function (added by Events):
56
+ property_changed: EventHandler[Callable[[str, AnyNewValue, AnyOldValue], None]]
57
+
58
+ def __init__(self, event_names=()):
59
+ super().__init__(event_names + ("property_changed",))
60
+
61
+ # def __bool__(self):
62
+ # # NB: was used to make the `if model:` in Machine.__init__ method works,
63
+ # # when tried mixing an ObservableObject with transitions.Machine
64
+ # # but there were other issues too, so keeping commented, as ref. at worst.
65
+ # # NB2: this is because the __bool__ method is otherwise overridden by the Events.__len__ method
66
+ # # which returns the nbr of events handled by the given instance.
67
+ # return True
68
+
69
+ def _on_property_changed(self, name: str, new_value: AnyNewValue, old_value: AnyOldValue):
70
+ """
71
+ Generate a property-changed event if the new value does not pass the == test with the old value.
72
+
73
+ This is a convenience method for only generating an event when the value has actually changed (as defined by
74
+ `==`). If `==` is not an appropriate test for a given property, a subclass should override this method for
75
+ handling that property, or the caller should use the property_changed event directly.
76
+
77
+ A common pattern for subclasses would be:
78
+
79
+ ```
80
+ def set_age(self, value: int):
81
+ prev, self._age = self._age, value # assign the value before call the _on_property_changed event
82
+ self._on_property_changed("age", value, prev)
83
+ ```
84
+
85
+ This will only generate an event if the new value is different from the old value and update the member variable
86
+ with the new value if it changed.
87
+
88
+ An important note for event handlers is that the property on the object will be the old value at the time the
89
+ event is handled. Handlers must use the `new_value` argument to get the new value.
90
+
91
+ Args:
92
+ name (str): Name of the property that changed.
93
+ new_value: New value of the property.
94
+ old_value: Old/current value of the property.
95
+ """
96
+ if old_value == new_value:
97
+ return old_value
98
+
99
+ if __debug__:
100
+ if name in _debug_properties_change:
101
+ logger.debug("%s: property %r: from %s to %s", self, name, old_value, new_value,
102
+ stack_info=logger.level == verboselogs.SPAM)
103
+
104
+ self.property_changed(name, new_value, old_value)
105
+ return new_value
@@ -0,0 +1,77 @@
1
+ import logging
2
+ import time
3
+ from dataclasses import dataclass, field
4
+ from math import floor
5
+ from typing import Optional
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ @dataclass
11
+ class PerfMonitor:
12
+ """
13
+ Convenience class to measure and report performance metrics. There are likely more robust or feature complete
14
+ packages available. This is intended as a simple implementation to not add to the dependency list of the project.
15
+
16
+ The monitor tracks `cycles` or number of times something happens. What this means is defined by the caller.
17
+ """
18
+ name: str = ""
19
+ """User-friendly name for the performance monitor."""
20
+ units: str = ""
21
+ """Units for the performance monitor."""
22
+ report_window: int = 15
23
+ """Duration between each report"""
24
+ enable_log: bool = True
25
+ """Allow toggling reporting while running."""
26
+ log_level: int = logging.DEBUG
27
+ """Log level for reporting performance metrics."""
28
+ cps: float = 0.0
29
+ """Most recent cycles per second (CPS) value."""
30
+
31
+ _cycle_count: Optional[int] = field(default=None, init=False, repr=False, compare=False)
32
+ _start: float = field(default=0, init=False, repr=False, compare=False)
33
+ _next_refresh: float = field(default=0, init=False, repr=False, compare=False)
34
+
35
+ def reset(self):
36
+ """
37
+ Resets all cycle counts and timing. Calculations will resume on the next `add_cycle` or `add_cycles` call.
38
+ """
39
+ logger.debug("%s: reset", self.name)
40
+ self._cycle_count = None
41
+
42
+ def add_cycle(self) -> bool:
43
+ """
44
+ Add a single cycle to the count.
45
+
46
+ Returns:
47
+ bool: True if the CPS value was reported. False otherwise.
48
+ """
49
+ return self.add_cycles(1)
50
+
51
+ def add_cycles(self, cycles: int) -> bool:
52
+ """
53
+ Add a block of cycles to the count.
54
+
55
+ Returns:
56
+ bool: True if the CPS value was reported. False otherwise.
57
+ """
58
+ if cycles <= 0:
59
+ return False
60
+
61
+ t_perf_now = time.perf_counter()
62
+
63
+ if self._cycle_count is None:
64
+ self._cycle_count = 0
65
+ self._next_refresh = self._start = t_perf_now
66
+ self._next_refresh += self.report_window
67
+
68
+ elif t_perf_now > self._next_refresh:
69
+ self.cps = self._cycle_count / (t_perf_now - self._next_refresh + self.report_window)
70
+ if self.enable_log:
71
+ logger.log(self.log_level, f"{self.name}: {self.cps:.1f} {self.units}")
72
+ self._next_refresh += self.report_window
73
+ self._cycle_count = cycles
74
+ return True
75
+
76
+ self._cycle_count += cycles
77
+ return False
@@ -0,0 +1,196 @@
1
+
2
+ import dataclasses
3
+ import math
4
+ from enum import Enum
5
+ from typing import Dict, Optional
6
+
7
+ from autotrainer.core import get_perf_now
8
+
9
+ _cache_scene_elements: Dict[str, "_BaseSceneElement"] = {}
10
+ _cache_scene_elements_str: Dict[str, str] = {}
11
+
12
+
13
+ class _BaseSceneElement(str): # , Enum):
14
+ """Dedicated str subclass for scene elements,
15
+ main feature is to have the created elements be all cached/singleton,
16
+ allowing use of "is" operator comparison with previous reference to any of them.
17
+ """
18
+ # we might want to use something more flexible, if the model would not use same names than here,
19
+ # like with a mapping dict, and some dynamic attributes + type hints on the class to still help development.
20
+
21
+ @classmethod
22
+ def __init_subclass__(cls, **kwargs):
23
+ super().__init_subclass__(**kwargs)
24
+ # set all string values to SceneElement/cls instance,
25
+ # which pre-loads it in _cache_scene_elements:
26
+ for name, value in vars(cls).items():
27
+ if cls.__annotations__.get(name) != 'SceneElement' or not isinstance(value, str):
28
+ continue
29
+ setattr(cls, name, cls(value))
30
+
31
+ def __new__(cls, value: str):
32
+ cached = _cache_scene_elements.get(value, None)
33
+ if cached is None:
34
+ tentative = super().__new__(cls, value)
35
+ assert isinstance(tentative, _BaseSceneElement)
36
+ cached = _cache_scene_elements.setdefault(value, tentative) # threads-safe
37
+ # return the raw str value:
38
+ # otherwise, when used as column(multiindex or not) and in h5 files that will make
39
+ # the column to be saved with pickle,
40
+ # which when unpickled fails if the pickled object type has been moved meanwhile.
41
+ cached_str = _cache_scene_elements_str.setdefault(value, str(cached))
42
+ return cached_str
43
+
44
+
45
+ class SceneElement(_BaseSceneElement):
46
+
47
+ Pellet: "SceneElement" = 'Pellet'
48
+
49
+ R_Hand: "SceneElement" = 'R_Hand' # composite element part
50
+ RH_flat: "SceneElement" = 'RH_flat'
51
+ RH_spread: "SceneElement" = 'RH_spread'
52
+ RH_grab: "SceneElement" = 'RH_grab'
53
+
54
+ L_Hand: "SceneElement" = 'L_Hand' # composite element part
55
+ LH_flat: "SceneElement" = 'LH_flat'
56
+ LH_spread: "SceneElement" = 'LH_spread'
57
+ LH_grab: "SceneElement" = 'LH_grab'
58
+
59
+ Nose: "SceneElement" = 'Nose'
60
+ Mouth: "SceneElement" = 'Mouth'
61
+ Tongue_mid: "SceneElement" = 'Tongue_mid'
62
+ Tongue_tip: "SceneElement" = 'Tongue_tip'
63
+
64
+ Star: "SceneElement" = 'Star'
65
+ Diamond: "SceneElement" = 'Diamond'
66
+ Triangle: "SceneElement" = 'Triangle'
67
+
68
+ AnyAnimalPart: "SceneElement" = 'AnyAnimalPart'
69
+
70
+
71
+ AllHandsParts = {
72
+ SceneElement.RH_flat, SceneElement.RH_spread, SceneElement.RH_grab,
73
+ SceneElement.LH_flat, SceneElement.LH_spread, SceneElement.LH_grab,
74
+ }
75
+
76
+
77
+ AllAnimalParts = {
78
+ SceneElement.Nose,
79
+ SceneElement.Mouth,
80
+ SceneElement.Tongue_tip,
81
+ SceneElement.Tongue_mid,
82
+ } | AllHandsParts
83
+
84
+
85
+ AllNonAnimalParts = {
86
+ SceneElement.Pellet,
87
+ SceneElement.Diamond,
88
+ SceneElement.Triangle,
89
+ SceneElement.Star,
90
+ }
91
+
92
+
93
+ AllSceneParts = AllAnimalParts | AllNonAnimalParts
94
+
95
+
96
+ @dataclasses.dataclass
97
+ class ScenePartsPresenceContext:
98
+ """Maintain a context of presence/absence status & age of parts/elements"""
99
+
100
+ # TODO: for now only considering presence in either of the 2 possible cameras,
101
+ # but we could handle 3 cases of presence/absence:
102
+ # 1) on any of the possible cams
103
+ # > can be/is used for checking pellet presence at the end of load sequence
104
+ # 2) on all of the possible cams simultaneously
105
+ # > should be used to report pellet successfully loaded (more reliably)
106
+ # 3) on all of the possible cams non-simultaneously
107
+ # > not entirely sure could be useful if we have already 1 + 2
108
+
109
+ last_perf_c: float = -math.inf # last perf counter received, basically the "freshness" of the data
110
+
111
+ present_last_perf_c: Dict[str, float] = dataclasses.field(default_factory=dict)
112
+ missing_last_perf_c: Dict[str, float] = dataclasses.field(default_factory=dict)
113
+
114
+ def get_part_seen(self, part: str) -> bool:
115
+ """Say if the part was seen on last update"""
116
+ p_present = self.present_last_perf_c.get(part, -math.inf)
117
+ p_missing = self.missing_last_perf_c.get(part, -math.inf)
118
+ if math.isinf(p_present):
119
+ return p_present > 0
120
+ return p_present > p_missing
121
+
122
+ def get_presence_age(self, part: str, *, perf_now: Optional[float] = None) -> float:
123
+ """Returns the "presence age" of the part if it's currently present, otherwise -math.inf"""
124
+ if perf_now is None:
125
+ perf_now = self.last_perf_c
126
+ presence_p = self.present_last_perf_c.get(part, None)
127
+ if presence_p is None:
128
+ return -math.inf
129
+ return perf_now - presence_p
130
+
131
+ def get_absence_age(self, part: str, *, perf_now: Optional[float] = None) -> float:
132
+ """Returns the "absence age" of the part if it's currently absent, otherwise -math.inf.
133
+ Age can also be math.inf if never seen yet"""
134
+ if perf_now is None:
135
+ perf_now = self.last_perf_c
136
+ presence_p = self.present_last_perf_c.get(part, None)
137
+ absence_p = self.missing_last_perf_c.get(part, None)
138
+ if absence_p is None:
139
+ return math.inf if presence_p is None else -math.inf
140
+ if presence_p is not None and presence_p > absence_p:
141
+ return -math.inf
142
+ return perf_now - absence_p
143
+
144
+ def get_recently_seen(self, part: str, missing_delay: float, *, perf_now: Optional[float] = None) -> bool:
145
+ """Returns if part was seen before missing_delay, relatively to perf_now, or self.last_perf_c"""
146
+ if perf_now is None:
147
+ perf_now = self.last_perf_c
148
+ prev_miss = self.missing_last_perf_c.get(part, -math.inf)
149
+ prev_pres = self.present_last_perf_c.get(part, -math.inf)
150
+ return (
151
+ not math.isinf(prev_pres)
152
+ and (prev_pres > prev_miss or perf_now - prev_miss < missing_delay)
153
+ )
154
+
155
+ def update_part_seen(self, part: str, seen: bool, *, perf_now: Optional[float] = None):
156
+ if perf_now is None:
157
+ perf_now = get_perf_now()
158
+ prev_seen = self.present_last_perf_c.get(part, -math.inf)
159
+ prev_miss = self.missing_last_perf_c.get(part, -math.inf)
160
+ if seen:
161
+ if prev_seen < prev_miss or math.isinf(prev_seen):
162
+ self.present_last_perf_c[part] = perf_now
163
+ self.missing_last_perf_c.setdefault(part, -math.inf)
164
+ else:
165
+ if prev_miss < prev_seen or math.isinf(prev_miss):
166
+ self.missing_last_perf_c[part] = perf_now
167
+ self.present_last_perf_c.setdefault(part, -math.inf)
168
+ if perf_now > self.last_perf_c:
169
+ self.last_perf_c = perf_now
170
+
171
+ def get_animal_absence_age(self, *, perf_now: Optional[float] = None):
172
+ """Returns the animal "absence" age, how old/elapsed time since last absence started,
173
+ can be math.inf if never been absent"""
174
+ if perf_now is None:
175
+ perf_now = get_perf_now()
176
+ max_perf_c = -math.inf
177
+ parts = set(self.missing_last_perf_c) | set(self.present_last_perf_c)
178
+ for part in parts:
179
+ if part in AllAnimalParts:
180
+ perf_c = self.missing_last_perf_c.get(part, -math.inf)
181
+ if perf_c > max_perf_c:
182
+ max_perf_c = perf_c
183
+ return perf_now - max_perf_c
184
+
185
+ def get_animal_presence_age(self, *, perf_now: Optional[float] = None):
186
+ """Returns the animal most recent "presence" age. ie: how old since it has been seen last time"""
187
+ if perf_now is None:
188
+ perf_now = get_perf_now()
189
+ max_perf_c = -math.inf
190
+ parts = set(self.missing_last_perf_c) | set(self.present_last_perf_c)
191
+ for part in parts:
192
+ if part in AllAnimalParts:
193
+ perf_c = self.present_last_perf_c.get(part, -math.inf)
194
+ if perf_c > max_perf_c:
195
+ max_perf_c = perf_c
196
+ return perf_now - max_perf_c
@@ -0,0 +1,19 @@
1
+ from typing import Protocol
2
+
3
+ from .project_info import ProjectInfo
4
+ from .project_info import ProjectInterval
5
+ from .project_info import video_write_ext
6
+
7
+
8
+ class ProjectDependentProtocol(Protocol):
9
+
10
+ @property
11
+ def project(self) -> ProjectInfo:
12
+ """The associated current project-info"""
13
+
14
+ @project.setter
15
+ def project(self, value: ProjectInfo):
16
+ """Project-info setter"""
17
+
18
+
19
+ ProjectDependentProtol = ProjectDependentProtocol # previous typo alias