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,211 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ from dataclasses import dataclass, field, asdict
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import List, Dict, Optional, Union, ClassVar, TextIO, Type, Any
9
+ from typing_extensions import Self
10
+ import yaml
11
+
12
+ import humps
13
+
14
+ from autotrainer.core.logging import get_verbose_logger
15
+ from . import GenericSafeLoader, SystemConfigurationLoader, SystemConfigurationDumper, SystemConfigurationSafeLoader
16
+ from .. import make_camelize_representer, make_decamelize_constructor
17
+ from .behavior_configuration import BehaviorConfiguration, add_behavior_configuration_representers, \
18
+ add_behavior_configuration_constructors
19
+ from .camera_configuration import CameraConfiguration, CameraId, camera_configuration_representer, \
20
+ camera_configuration_constructor
21
+ from .hardware_configuration import HardwareConfiguration, hardware_configuration_representer, \
22
+ hardware_configuration_constructor
23
+ from .inference_configuration import InferenceConfiguration, inference_configuration_representer, \
24
+ inference_configuration_constructor
25
+ from .persistence_configuration import PersistenceConfiguration, persistence_configuration_representer, \
26
+ persistence_configuration_constructor
27
+ from ..project.project_info import DATE_FORMAT, TIME_FORMAT
28
+
29
+ logger = get_verbose_logger(__name__)
30
+
31
+ #
32
+
33
+
34
+ @dataclass
35
+ class SystemConfiguration:
36
+ """
37
+ The user-visible and editable elements of the system configuration are evolving. This class is largely intended
38
+ manage transitions and maintain a consistent interface to applications.
39
+ """
40
+
41
+ DEFAULT_CONFIG_DIR: ClassVar[Path] = Path("~/Autotrainer") # caller/user must expanduser() on it
42
+ DEFAULT_NAME: ClassVar[str] = "system_configuration"
43
+
44
+ DEFAULT_PATH: ClassVar[Path] = DEFAULT_CONFIG_DIR.joinpath(f"{DEFAULT_NAME}.yaml") # caller/user must expanduser() on it
45
+
46
+ version: int = 36
47
+
48
+ cameras: List[CameraConfiguration] = field(default_factory=list)
49
+ hardware: HardwareConfiguration = field(default_factory=HardwareConfiguration)
50
+ inference: InferenceConfiguration = field(default_factory=InferenceConfiguration)
51
+ behavior: BehaviorConfiguration = field(default_factory=BehaviorConfiguration)
52
+ persistence: PersistenceConfiguration = field(default_factory=PersistenceConfiguration)
53
+
54
+ _camera_map = None # do not include in fields
55
+
56
+ def __post_init__(self):
57
+ self._camera_map = {}
58
+
59
+ @classmethod
60
+ def load_yaml(cls: Type[Self], data: TextIO, *, file_path: Optional[Path] = None) -> Self:
61
+ raw_content: Dict[str, Any] = yaml.load(data, GenericSafeLoader)
62
+ data.seek(0)
63
+ version = raw_content.get("version", 0)
64
+ if version == SystemConfiguration.version:
65
+ # easy case
66
+ configuration: Self = yaml.load(data, SystemConfigurationLoader)
67
+ elif version < SystemConfiguration.version:
68
+ content = humps.decamelize(raw_content)
69
+ configuration = cls()
70
+ if version == 0:
71
+ configuration._deserialize_version_zero(content)
72
+ elif version == 1:
73
+ configuration._deserialize_version_one(content)
74
+ else:
75
+ configuration: Self = yaml.load(data, SystemConfigurationSafeLoader)
76
+ else:
77
+ assert version > SystemConfiguration.version
78
+ logger.warning("Loading configuration version %s while SystemConfiguration.version == %s, "
79
+ "only considering known config attributes/properties.",
80
+ version, SystemConfiguration.version)
81
+ configuration: Self = yaml.load(data, SystemConfigurationSafeLoader)
82
+
83
+ if version != SystemConfiguration.version and file_path is not None:
84
+ now = datetime.now()
85
+ now_str = now.strftime(f"{DATE_FORMAT}_{TIME_FORMAT}")
86
+ new_p = file_path.parent.joinpath(
87
+ f"{file_path.stem}_v{version}_{now_str}{file_path.suffix}")
88
+ logger.notice("Detected config version change/mismatch, saving old config to %s,"
89
+ " and replacing with new after.", new_p)
90
+ shutil.copy2(file_path, new_p)
91
+ configuration.version = SystemConfiguration.version
92
+ # and save new one over previous:
93
+ configuration.save_file(file_path.with_suffix(""), as_yaml=True)
94
+
95
+ return configuration
96
+
97
+ @classmethod
98
+ def load_yaml_file(cls: Type[Self], path: Union[Path, str], *, save_backup: bool = True) -> Self:
99
+ path: Path = Path(path)
100
+ logger.debug("loading configuration from %r", path)
101
+ with path.open() as fh:
102
+ return cls.load_yaml(fh, file_path=path if save_backup else None)
103
+
104
+ @classmethod
105
+ def make_default_yaml_config_path(cls, dir_path: Path) -> Path:
106
+ return dir_path.joinpath(f"{SystemConfiguration.DEFAULT_NAME}.yaml")
107
+
108
+ @classmethod
109
+ def load_default(cls: Type[Self], location: Union[str, Path]) -> Optional[Self]:
110
+ path = cls.make_default_yaml_config_path(Path(location))
111
+ if path.is_file():
112
+ return cls.load_yaml_file(path)
113
+ logger.debug("cannot load default from %s ; not a file", path)
114
+ return None
115
+
116
+ def save_default(self, dir_path: Union[Path, str]):
117
+ path = self.make_default_yaml_config_path(Path(dir_path))
118
+ save_path: Path = path.with_suffix("") # noqa
119
+ self.save_file(save_path, as_yaml=True)
120
+
121
+ def dump_yaml(self) -> str:
122
+ return yaml.dump(self, Dumper=SystemConfigurationDumper,
123
+ # we sort/iter by dataclasses.fields() order in our representer function
124
+ sort_keys=False)
125
+
126
+ def save_file(self, path: Union[Path, str], as_yaml: bool = False, as_json: bool = False):
127
+ if not (as_json or as_yaml):
128
+ raise ValueError("Missing one of as_json or as_yaml")
129
+ path: Path = Path(path)
130
+ path.parent.mkdir(parents=True, exist_ok=True)
131
+ if as_json:
132
+ p = path.with_suffix(".json")
133
+ logger.notice("Writing to %r as json", p.as_posix())
134
+ content = json.dumps(asdict(self))
135
+ # dump before writing to file, to prevent empty file on dump issue
136
+ with p.open("w") as file:
137
+ file.write(content)
138
+ if as_yaml:
139
+ p = path.with_suffix(".yaml")
140
+ logger.notice("Writing to %r as yaml", p.as_posix())
141
+ content = self.dump_yaml()
142
+ # dump before writing to file, to prevent empty file on dump issue
143
+ with p.open("w") as file:
144
+ file.write(content)
145
+
146
+ def get_camera(self, camera_id: CameraId) -> Optional[CameraConfiguration]:
147
+ if len(self._camera_map) == 0 or camera_id not in self._camera_map:
148
+ for camera in self.cameras:
149
+ self._camera_map[camera.id] = camera
150
+
151
+ return self._camera_map.get(camera_id, None)
152
+
153
+ def _deserialize_version_zero(self, content: Dict):
154
+ self.cameras.clear()
155
+
156
+ self._try_append_version_zero_camera("camera1", content)
157
+ self._try_append_version_zero_camera("camera2", content)
158
+ self._try_append_version_zero_camera("camera3", content)
159
+
160
+ self.hardware = HardwareConfiguration.from_version_zero(content)
161
+
162
+ # Typo from earlier version of the file.
163
+ self.inference = InferenceConfiguration.from_version_zero(content.get("pellet", {}))
164
+
165
+ self.behavior = BehaviorConfiguration.from_version_zero(content)
166
+ self.persistence = PersistenceConfiguration.from_version_zero(content)
167
+
168
+ def _try_append_version_zero_camera(self, entry: str, content: Dict) -> bool:
169
+ if entry in content:
170
+ camera = CameraConfiguration.from_version_zero(entry, content[entry])
171
+ if camera is not None:
172
+ self.cameras.append(camera)
173
+ return True
174
+
175
+ return False
176
+
177
+ def _deserialize_version_one(self, content: Dict):
178
+ self.cameras = [
179
+ CameraConfiguration(**kw)
180
+ for kw in content.get("cameras", [])
181
+ ]
182
+ self.hardware = HardwareConfiguration(**content.get("hardware", {}))
183
+ self.inference = InferenceConfiguration(**content.get("inference", {}))
184
+ self.behavior = BehaviorConfiguration.from_version_one(content.get("behavior", {}))
185
+ self.persistence = PersistenceConfiguration(**content.get("persistence", {}))
186
+
187
+
188
+ #
189
+
190
+ system_configuration_representer = make_camelize_representer("!SystemConfiguration")
191
+
192
+ SystemConfigurationDumper.add_representer(SystemConfiguration, system_configuration_representer)
193
+ SystemConfigurationDumper.add_representer(CameraConfiguration, camera_configuration_representer)
194
+ SystemConfigurationDumper.add_representer(HardwareConfiguration, hardware_configuration_representer)
195
+ SystemConfigurationDumper.add_representer(InferenceConfiguration, inference_configuration_representer)
196
+ SystemConfigurationDumper.add_representer(PersistenceConfiguration, persistence_configuration_representer)
197
+ add_behavior_configuration_representers(SystemConfigurationDumper)
198
+
199
+
200
+ system_configuration_constructor = make_decamelize_constructor(SystemConfiguration)
201
+
202
+ for cls in SystemConfigurationLoader, :
203
+ # SystemConfigurationSafeLoader:
204
+ # no need also add on SystemConfigurationSafeLoader given it subclass SystemConfigurationLoader
205
+
206
+ cls.add_constructor("!SystemConfiguration", system_configuration_constructor)
207
+ cls.add_constructor("!CameraConfiguration", camera_configuration_constructor)
208
+ cls.add_constructor("!HardwareConfiguration", hardware_configuration_constructor)
209
+ cls.add_constructor("!InferenceConfiguration", inference_configuration_constructor)
210
+ cls.add_constructor("!PersistenceConfiguration", persistence_configuration_constructor)
211
+ add_behavior_configuration_constructors(cls)
@@ -0,0 +1,9 @@
1
+ import dataclasses
2
+
3
+
4
+ @dataclasses.dataclass
5
+ class SystemFaultConfig:
6
+
7
+ use_free_disk_space: bool = True
8
+ auto_resume_on_free_disk_space: bool = True
9
+ free_disk_space_min_limit_mb: int = 500
@@ -0,0 +1,17 @@
1
+ import dataclasses
2
+
3
+
4
+ @dataclasses.dataclass
5
+ class SystemMaintenanceConfig:
6
+
7
+ use_max_pellet_loaded: bool = True
8
+ auto_resume_on_max_pellets_loaded: bool = True
9
+ max_pellets_loaded_count: int = 500
10
+
11
+ use_max_consecutive_failed_load: bool = True
12
+ auto_resume_on_max_consecutive_failed_load: bool = True
13
+ max_consecutive_failed_loaded: int = 10
14
+
15
+ use_free_disk_space: bool = True
16
+ auto_resume_on_free_disk_space: bool = True
17
+ free_disk_space_min_limit_mb: int = 500
@@ -0,0 +1,150 @@
1
+ import dataclasses
2
+ import itertools
3
+ import math
4
+ import os
5
+ from pathlib import Path
6
+ from typing import ClassVar, Optional
7
+
8
+ import yaml
9
+ from typing_extensions import Self
10
+
11
+ from autotrainer.core import Offset3DTuple
12
+ from autotrainer.core.logging import get_verbose_logger
13
+
14
+
15
+ logger = get_verbose_logger(__name__)
16
+
17
+
18
+ DEFAULT_DIAMOND_TRIANGLE_CONFIG_PATH = Path(
19
+ os.getenv("AUTOTRAINER_DIAMOND_TRIANGLE_CONFIG", "~/Autotrainer/diamond_triangle_offset.yaml")
20
+ ).expanduser()
21
+
22
+ _offset_nans = Offset3DTuple(math.nan, math.nan, math.nan)
23
+
24
+
25
+ @dataclasses.dataclass
26
+ class DiamondTriangleOffsetConfig:
27
+
28
+ used_position: Offset3DTuple # motor coordinate used
29
+ measured_offset: Offset3DTuple # inference coordinate offset between diamond and triangle (diamond - triangle)
30
+
31
+ raw_diamond_coord: Offset3DTuple = _offset_nans # output from triangulate_3d
32
+ diamond_coord: Offset3DTuple = _offset_nans # inference diamond coordinate, output from reorient_and_center
33
+
34
+ version: Optional[int] = None
35
+ current_config_version: ClassVar[int] = 2 # NB: bump me when need to
36
+
37
+ DEFAULT_CONFIG_PATH: ClassVar = DEFAULT_DIAMOND_TRIANGLE_CONFIG_PATH
38
+
39
+ # 3 coordinate systems,
40
+ # but each has some axis direction difference (when one increases, the other decreases)
41
+ flips_inference_motor: ClassVar[Offset3DTuple] = Offset3DTuple(1, -1, -1)
42
+ flips_inference_diamond: ClassVar[Offset3DTuple] = Offset3DTuple(1, 1, 1)
43
+ # we also want inference diamond flips equals (1, 1, 1), given inference == diamond
44
+
45
+ # defining flips between motor and diamond with flips between motor and inference * flips between inference and diamond:
46
+ flips_motor_diamond: ClassVar[Offset3DTuple] = flips_inference_diamond * flips_inference_motor # Offset3DTuple(1, -1, -1)
47
+ # assert flips_inference_diamond * flips_inference_motor == flips_motor_diamond
48
+
49
+ # custom init to ensure kwarg only :
50
+ def __init__(
51
+ self,
52
+ *,
53
+ used_position: Offset3DTuple,
54
+ measured_offset: Offset3DTuple,
55
+ diamond_coord: Offset3DTuple = _offset_nans,
56
+ raw_diamond_coord: Offset3DTuple = _offset_nans,
57
+ version: Optional[int] = None,
58
+ ):
59
+ super().__init__()
60
+ self.used_position = used_position
61
+ self.measured_offset = measured_offset
62
+ self.diamond_coord = diamond_coord
63
+ self.raw_diamond_coord = raw_diamond_coord
64
+ self.version = version
65
+
66
+ @property
67
+ def fully_valid(self):
68
+ return self.version == self.current_config_version and all(map(math.isfinite,
69
+ itertools.chain(
70
+ self.used_position,
71
+ self.measured_offset,
72
+ self.diamond_coord,
73
+ self.raw_diamond_coord,
74
+ )))
75
+
76
+ @classmethod
77
+ def load_config(cls, cfg_path: Optional[Path]) -> Optional[Self]:
78
+ if cfg_path is None:
79
+ logger.notice("No diamond-triangle offset config path provided")
80
+ else:
81
+ if not cfg_path.expanduser().is_file():
82
+ logger.warning("Diamond triangle config %r not a file", cfg_path.as_posix())
83
+ else:
84
+ logger.verbose("Loading diamond-triangle file %r", cfg_path.as_posix())
85
+ cfg = DiamondTriangleOffsetConfig.from_file(cfg_path)
86
+ if not all(math.isfinite(c) for c in cfg.diamond_coord):
87
+ logger.warning("Diamond coordinate undefined or not finite in config %r",
88
+ cfg_path.as_posix())
89
+ return cfg
90
+ return None
91
+
92
+ @classmethod
93
+ def from_file(cls, path: Path) -> Self:
94
+ with path.expanduser().open() as fh:
95
+ dct = yaml.safe_load(fh)
96
+ if dct is None:
97
+ # in case of empty file
98
+ return cls(used_position=_offset_nans, measured_offset=_offset_nans, version=cls.current_config_version)
99
+ return cls(**dict((k, v if k == "version" else Offset3DTuple(v)) for k, v in dct.items()))
100
+
101
+ def to_file(self, path: Path):
102
+ d = dataclasses.asdict(self)
103
+ for k, v in d.items():
104
+ if k != "version":
105
+ d[k] = list(v) # getting yaml type error with Offset3D not natively supported by yaml
106
+ with path.expanduser().open("w") as fh:
107
+ yaml.safe_dump(d, fh)
108
+
109
+ def inference_to_motor(self, inference_xyz: Offset3DTuple) -> Offset3DTuple:
110
+ """Transform an inference coordinate to motor corresponding coordinate,
111
+ relatively to the diamond-triangle known position & relative offset"""
112
+ # only used by tests
113
+ assert isinstance(inference_xyz, Offset3DTuple), inference_xyz
114
+ return self.flips_inference_motor * (
115
+ self.flips_inference_diamond * self.measured_offset - inference_xyz
116
+ ) + self.used_position
117
+
118
+ def motor_to_inference(self, motor_xyz: Offset3DTuple) -> Offset3DTuple:
119
+ # only used by tests
120
+ assert isinstance(motor_xyz, Offset3DTuple), motor_xyz
121
+ # return self.flips_inference_motor * (motor_xyz - self.used_position) - self.flips_inference_diamond * self.measured_offset
122
+ return (
123
+ self.flips_inference_diamond * self.measured_offset
124
+ - self.flips_inference_motor * (motor_xyz - self.used_position)
125
+ )
126
+
127
+ def motor_to_diamond(self, motor_xyz: Offset3DTuple) -> Offset3DTuple:
128
+ """Transform the motor coordinate to corresponding triangle coordinate in DCS"""
129
+ assert isinstance(motor_xyz, Offset3DTuple), motor_xyz
130
+ return (
131
+ self.flips_motor_diamond * (motor_xyz - self.used_position)
132
+ + self.measured_offset
133
+ )
134
+
135
+ def diamond_to_motor(self, diamond_xyz: Offset3DTuple) -> Offset3DTuple:
136
+ """Transform the triangle coordinate from DCS to corresponding motor coordinates"""
137
+ assert isinstance(diamond_xyz, Offset3DTuple), diamond_xyz
138
+ return self.used_position + self.flips_motor_diamond * (
139
+ diamond_xyz - self.measured_offset
140
+ )
141
+
142
+ def inference_to_diamond(self, inference_xyz: Offset3DTuple) -> Offset3DTuple:
143
+ assert isinstance(inference_xyz, Offset3DTuple), inference_xyz
144
+ return self.motor_to_diamond(self.inference_to_motor(inference_xyz))
145
+ # return self.flips_inference_diamond * inference_xyz
146
+
147
+ def diamond_to_inference(self, diamond_xyz: Offset3DTuple) -> Offset3DTuple:
148
+ assert isinstance(diamond_xyz, Offset3DTuple), diamond_xyz
149
+ return self.motor_to_inference(self.diamond_to_motor(diamond_xyz))
150
+ # return self.flips_inference_diamond * diamond_xyz
@@ -0,0 +1,54 @@
1
+
2
+ from typing import Optional, Any
3
+
4
+ from autotrainer.api.api_event_kind import ApiEventKind
5
+ from autotrainer.api.api_options import create_default_api_options
6
+
7
+ from autotrainer.core.logging import get_verbose_logger
8
+ from autotrainer.core.event.api_event_plugin import ApiEventPlugin
9
+
10
+ from .event_manager import EventManager
11
+
12
+
13
+ logger = get_verbose_logger(__name__)
14
+
15
+
16
+ __all__ = [
17
+ "ApiEventKind",
18
+ "try_register_api_event_plugin",
19
+ "post_api_event_content",
20
+ "post_api_detector_event_content",
21
+ ]
22
+
23
+
24
+ def try_register_api_event_plugin() -> Optional[ApiEventPlugin]:
25
+ """
26
+ Attempt to register the Autotrainer External API plugin with the default instance of the event manager.
27
+
28
+ Returns:
29
+ bool: True if the plugin was successfully registered, False otherwise.
30
+ """
31
+ try:
32
+ # NB: keeping import here,
33
+ # given otherwise it gives app crash at start if it's imported before the main qt app instance is created
34
+
35
+ plugin = ApiEventPlugin(create_default_api_options())
36
+ plugin.set_enable(True)
37
+ EventManager.default().register_plugin(plugin)
38
+ return plugin
39
+ except Exception as err:
40
+ logger.exception("API plugin creation or registration failed: %s", err)
41
+
42
+ return None
43
+
44
+
45
+ def post_api_event_content(kind: int, data: Any):
46
+ EventManager.default().post_event_content(kind, data=data)
47
+
48
+
49
+ def post_api_detector_event_content(manager: EventManager, detector_id: int, active: bool, enabled: bool):
50
+ manager.post_event_content(ApiEventKind.detectorChanged, data=dict(
51
+ detector_id=detector_id,
52
+ is_active=active,
53
+ is_enabled=enabled,
54
+ ))
@@ -0,0 +1,5 @@
1
+
2
+ from autotrainer.api.api_event_kind import ApiEventKind
3
+
4
+
5
+ __all__ = ["ApiEventKind"]
@@ -0,0 +1,57 @@
1
+ from dataclasses import asdict
2
+ from typing import Optional
3
+
4
+ from autotrainer.api import ApiOptions, RpcService, create_api_service, ApiTopic
5
+
6
+ from ..logging import get_verbose_logger
7
+ from ..project import ProjectInfo
8
+ from .event_info import EventInfo
9
+
10
+ from .event_manager_plugin import EventManagerPlugin
11
+
12
+ logger = get_verbose_logger(__name__)
13
+
14
+
15
+ class ApiEventPlugin(EventManagerPlugin):
16
+ """
17
+ An EventManager plugin that sends events to the auto-trainer-api facilities.
18
+
19
+ This plugin requires the auto-trainer-api package to be installed.
20
+
21
+ See auto-trainer-api for additional details.
22
+ """
23
+
24
+ def __init__(self, options: ApiOptions):
25
+ self._options = options
26
+ self._service: Optional[RpcService] = None
27
+
28
+ @property
29
+ def service(self) -> Optional[RpcService]:
30
+ return self._service
31
+
32
+ def set_project(self, project: Optional[ProjectInfo]) -> None:
33
+ pass
34
+
35
+ def set_enable(self, enable: bool) -> None:
36
+ svc = self._service
37
+ if enable and svc is None:
38
+ svc = self._service = create_api_service(self._options)
39
+ if svc is not None:
40
+ svc.start()
41
+ elif not enable and svc is not None:
42
+ svc.stop()
43
+ self._service = None
44
+
45
+ def process_event(self, info: EventInfo, repeat_count: int) -> None:
46
+ svc = self._service
47
+ if svc is not None:
48
+ svc.send_dict(ApiTopic.EVENT, asdict(info))
49
+
50
+ def flush(self) -> None:
51
+ pass
52
+
53
+ def close(self) -> None:
54
+ svc = self._service
55
+ if svc is not None:
56
+ svc.stop()
57
+ self._service = None
@@ -0,0 +1,53 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+ from typing import Optional
4
+
5
+ from typing_extensions import Self
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class EventInfo:
10
+ """
11
+ EventInfo is a structured definition for "events" in the system. The primary objective is to provide a history of
12
+ actions and changes that are consequential to animal training in structured format that can be parsed in
13
+ conjunction with outer data files. This is generally a subset of the information that is needed or useful for
14
+ general logging.
15
+ """
16
+ kind: int
17
+ """
18
+ The kind of event. This is a unique identifier for the event type. Some coordination is required across modules
19
+ to prevent id duplication, if desired. For most event sinks, the `str()` value is also used as part of the
20
+ output. Using and `IntEnum` or similar that generates a descriptive string by default is encouraged.
21
+ """
22
+ when: datetime
23
+ """
24
+ The time the event occurred. This is typically presented as a date/time string in the output.
25
+ """
26
+ index: int
27
+ """
28
+ An time-based index that may be more precise than standard date-time functions. Typically set via
29
+ `time.perf_counter_ns()`. The same method of indexing must be used across all `EventInfo` providers if indexing
30
+ is expected to be consistent across all event types.
31
+ """
32
+ context: Optional[object] = None
33
+ """
34
+ An optional object containing event specific information. `EventInfo` collections are typically presented in a
35
+ structured, row-column format. Complex hierarchical data structures may not be represented well in this format.
36
+ The object must be serializable to JSON using the default JSON encoder and be pickleable to ensure support in all
37
+ event manager sinks.
38
+ """
39
+
40
+ def is_same(self, info: Self) -> bool:
41
+ """
42
+ Provide a deeper than ReferenceEquals comparison of two `EventInfo` objects to determine repeat events. It is
43
+ less stringent than a raw shallow compare as fields like when and index are expected to be different for repeat
44
+ events. There may be specialized `EventInfo` instances require a custom comparison of `context` to determine
45
+ sameness in which case this can be overridden in a subclass.
46
+
47
+ Args:
48
+ info: another `EventInfo` object to compare against.
49
+
50
+ Returns:
51
+ True if the two `EventInfo` objects are the same, False otherwise.
52
+ """
53
+ return info is not None and self.kind == info.kind and self.context == info.context