sl-shared-assets 6.1.1__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.
@@ -0,0 +1,939 @@
1
+ """This module provides the assets used to configure data acquisition and processing runtimes in the Sun lab."""
2
+
3
+ from copy import deepcopy
4
+ from enum import StrEnum
5
+ from pathlib import Path
6
+ from dataclasses import field, dataclass
7
+
8
+ import appdirs
9
+ from ataraxis_base_utilities import LogLevel, console, ensure_directory_exists
10
+ from ataraxis_data_structures import YamlConfig
11
+
12
+ # Constants for validation
13
+ _UINT8_MAX = 255
14
+ """Maximum value for uint8 cue codes."""
15
+ _PROBABILITY_SUM_TOLERANCE = 0.001
16
+ """Tolerance for validating probability sums to 1.0."""
17
+
18
+
19
+ class AcquisitionSystems(StrEnum):
20
+ """Defines the data acquisition systems currently used in the Sun lab."""
21
+
22
+ MESOSCOPE_VR = "mesoscope"
23
+ """This system is built around the 2-Photon Random Access Mesoscope (2P-RAM) and relies on Virtual Reality (VR)
24
+ environments running in Unity game engine to conduct experiments."""
25
+
26
+
27
+ @dataclass()
28
+ class Cue:
29
+ """Defines a single visual cue used in the experiment task's Virtual Reality (VR) environment.
30
+
31
+ Notes:
32
+ Each cue has a unique name (used in the Unity segment (prefab) definitions) and a unique uint8 code (used during
33
+ MQTT communication and analysis). Cues are not loaded as individual prefabs - they are baked into segment
34
+ prefabs.
35
+ """
36
+
37
+ name: str
38
+ """The visual identifier for the cue (e.g., 'A', 'B', 'Gray'). Used to reference the cue in segment definitions."""
39
+ code: int
40
+ """The unique uint8 code (0-255) that identifies the cue during MQTT communication and data analysis."""
41
+ length_cm: float
42
+ """The length of the cue in centimeters."""
43
+
44
+ def __post_init__(self) -> None:
45
+ """Validates cue definition parameters."""
46
+ if not 0 <= self.code <= _UINT8_MAX:
47
+ message = f"Cue code must be a uint8 value (0-255), got {self.code} for cue '{self.name}'."
48
+ console.error(message=message, error=ValueError)
49
+ if self.length_cm <= 0:
50
+ message = f"Cue length must be positive, got {self.length_cm} cm for cue '{self.name}'."
51
+ console.error(message=message, error=ValueError)
52
+
53
+
54
+ @dataclass()
55
+ class Segment:
56
+ """Defines a visual segment (sequence of cues) used in the experiment task's Virtual Reality (VR) environment.
57
+
58
+ Notes:
59
+ Segments are the building blocks of the infinite corridor, each containing a sequence of visual cues
60
+ and optional transition probabilities for segment-to-segment transitions.
61
+ """
62
+
63
+ name: str
64
+ """The unique identifier of the segment's Unity prefab file."""
65
+ cue_sequence: list[str]
66
+ """The ordered sequence of cue names that comprise this segment."""
67
+ transition_probabilities: list[float] | None = None
68
+ """Optional transition probabilities to other segments that make up the task's corridor environment. If provided,
69
+ must sum to 1.0."""
70
+
71
+ def __post_init__(self) -> None:
72
+ """Validates segment definition parameters."""
73
+ if not self.cue_sequence:
74
+ message = f"Segment '{self.name}' must have at least one cue in its cue_sequence."
75
+ console.error(message=message, error=ValueError)
76
+
77
+ if self.transition_probabilities:
78
+ prob_sum = sum(self.transition_probabilities)
79
+ if abs(prob_sum - 1.0) > _PROBABILITY_SUM_TOLERANCE:
80
+ message = f"Segment '{self.name}' transition probabilities sum to {prob_sum}, but must sum to 1.0."
81
+ console.error(message=message, error=ValueError)
82
+
83
+
84
+ @dataclass()
85
+ class VREnvironment:
86
+ """Defines the Unity VR corridor system configuration.
87
+
88
+ Notes:
89
+ This class is primarily used by Unity to configure the task environment. Python parses these values
90
+ from the YAML configuration file but does not use them at runtime.
91
+ """
92
+
93
+ corridor_spacing_cm: float = 20.0
94
+ """The horizontal spacing between corridor instances in centimeters."""
95
+ segments_per_corridor: int = 3
96
+ """The number of segments visible in each corridor instance (corridor depth)."""
97
+ padding_prefab_name: str = "Padding"
98
+ """The name of the Unity prefab used for corridor padding."""
99
+ cm_per_unity_unit: float = 10.0
100
+ """The conversion factor from centimeters to Unity units."""
101
+
102
+
103
+ @dataclass()
104
+ class MesoscopeExperimentState:
105
+ """Defines the structure and runtime parameters of an experiment state (phase)."""
106
+
107
+ experiment_state_code: int
108
+ """The unique identifier code of the experiment state."""
109
+ system_state_code: int
110
+ """The data acquisition system's state (configuration snapshot) code associated with the experiment state."""
111
+ state_duration_s: float
112
+ """The time, in seconds, to maintain the experiment state while executing the experiment."""
113
+ supports_trials: bool = True
114
+ """Determines whether trials are executed during this experiment state. When False, no trial-related processing
115
+ occurs during this phase."""
116
+ # Reinforcing (water reward) trial guidance parameters
117
+ reinforcing_initial_guided_trials: int = 0
118
+ """The number of reinforcing trials after the onset of the experiment state that use the guidance mode."""
119
+ reinforcing_recovery_failed_threshold: int = 0
120
+ """The number of sequentially failed reinforcing trials after which to enable the recovery guidance mode."""
121
+ reinforcing_recovery_guided_trials: int = 0
122
+ """The number of guided reinforcing trials to use in the recovery guidance mode."""
123
+ # Aversive (gas puff) trial guidance parameters
124
+ aversive_initial_guided_trials: int = 0
125
+ """The number of aversive trials after the onset of the experiment state that use the guidance mode."""
126
+ aversive_recovery_failed_threshold: int = 0
127
+ """The number of sequentially failed aversive trials after which to enable the recovery guidance mode."""
128
+ aversive_recovery_guided_trials: int = 0
129
+ """The number of guided aversive trials to use in the recovery guidance mode."""
130
+
131
+
132
+ @dataclass()
133
+ class _MesoscopeBaseTrial:
134
+ """Defines the shared structure and task parameters common to all supported experiment trial types.
135
+
136
+ Notes:
137
+ The trigger mode and guidance behavior are determined by the trial type:
138
+ - WaterRewardTrial: Uses 'lick' trigger mode; guidance delivers stimulus on collision.
139
+ - GasPuffTrial: Uses 'occupancy' trigger mode; guidance emits OccupancyFailed for movement blocking.
140
+ """
141
+
142
+ segment_name: str
143
+ """The name of the Unity Segment this trial is based on."""
144
+ stimulus_trigger_zone_start_cm: float
145
+ """The position of the trial stimulus trigger zone starting boundary, in centimeters."""
146
+ stimulus_trigger_zone_end_cm: float
147
+ """The position of the trial stimulus trigger zone ending boundary, in centimeters."""
148
+ stimulus_location_cm: float
149
+ """The location of the invisible boundary (wall) with which the animal must collide to elicit the stimulus."""
150
+ show_stimulus_collision_boundary: bool = False
151
+ """Determines whether the stimulus collision boundary is visible to the animal during this trial type. When True,
152
+ the boundary marker is displayed in the Virtual Reality environment at the stimulus location."""
153
+
154
+ # Derived fields - populated by MesoscopeExperimentConfiguration.__post_init__
155
+ cue_sequence: list[int] = field(default_factory=list)
156
+ """The sequence of segment wall cues identifiers experienced by the animal when participating in this type of
157
+ trials."""
158
+ trial_length_cm: float = 0.0
159
+ """The total length of the trial environment in centimeters."""
160
+
161
+ def validate_zones(self) -> None:
162
+ """Validates stimulus zone positions.
163
+
164
+ Notes:
165
+ This method must be called after trial_length_cm is populated by MesoscopeExperimentConfiguration.
166
+ """
167
+ if self.trial_length_cm <= 0:
168
+ message = "Cannot validate zones: trial_length_cm must be populated first."
169
+ console.error(message=message, error=ValueError)
170
+
171
+ if self.stimulus_trigger_zone_end_cm < self.stimulus_trigger_zone_start_cm:
172
+ message = (
173
+ f"The 'stimulus_trigger_zone_end_cm' ({self.stimulus_trigger_zone_end_cm}) must be greater than or "
174
+ f"equal to 'stimulus_trigger_zone_start_cm' ({self.stimulus_trigger_zone_start_cm})."
175
+ )
176
+ console.error(message=message, error=ValueError)
177
+
178
+ if not 0 <= self.stimulus_trigger_zone_start_cm <= self.trial_length_cm:
179
+ message = (
180
+ f"The 'stimulus_trigger_zone_start_cm' ({self.stimulus_trigger_zone_start_cm}) must be within the "
181
+ f"trial length (0 to {self.trial_length_cm} cm)."
182
+ )
183
+ console.error(message=message, error=ValueError)
184
+
185
+ if not 0 <= self.stimulus_trigger_zone_end_cm <= self.trial_length_cm:
186
+ message = (
187
+ f"The 'stimulus_trigger_zone_end_cm' ({self.stimulus_trigger_zone_end_cm}) must be within the "
188
+ f"trial length (0 to {self.trial_length_cm} cm)."
189
+ )
190
+ console.error(message=message, error=ValueError)
191
+
192
+ if not 0 <= self.stimulus_location_cm <= self.trial_length_cm:
193
+ message = (
194
+ f"The 'stimulus_location_cm' ({self.stimulus_location_cm}) must be within the "
195
+ f"trial length (0 to {self.trial_length_cm} cm)."
196
+ )
197
+ console.error(message=message, error=ValueError)
198
+
199
+ if self.stimulus_location_cm < self.stimulus_trigger_zone_start_cm:
200
+ message = (
201
+ f"The 'stimulus_location_cm' ({self.stimulus_location_cm}) cannot precede the "
202
+ f"'stimulus_trigger_zone_start_cm' ({self.stimulus_trigger_zone_start_cm}). The stimulus location must "
203
+ f"be at or after the start of the trigger zone."
204
+ )
205
+ console.error(message=message, error=ValueError)
206
+
207
+
208
+ @dataclass()
209
+ class WaterRewardTrial(_MesoscopeBaseTrial):
210
+ """Defines a trial that delivers water rewards (reinforcing stimuli) when the animal licks in the trigger zone.
211
+
212
+ Notes:
213
+ Trigger mode: The animal must lick while inside the stimulus trigger zone to receive the water reward.
214
+ Guidance mode: The animal receives the reward upon colliding with the stimulus boundary (no lick required).
215
+ """
216
+
217
+ reward_size_ul: float = 5.0
218
+ """The volume of water, in microliters, to deliver when the animal successfully completes the trial."""
219
+ reward_tone_duration_ms: int = 300
220
+ """The duration, in milliseconds, to sound the auditory tone when delivering the water reward."""
221
+
222
+
223
+ @dataclass()
224
+ class GasPuffTrial(_MesoscopeBaseTrial):
225
+ """Defines a trial that delivers N2 gas puffs (aversive stimuli) when the animal fails to meet occupancy duration.
226
+
227
+ Notes:
228
+ Trigger mode: The animal must occupy the stimulus trigger zone for the specified duration to disarm the
229
+ stimulus boundary and avoid the gas puff. If the animal exits the zone early or collides with the boundary
230
+ before meeting the occupancy threshold, the gas puff is delivered.
231
+ Guidance mode: When the animal exits the zone early, an OccupancyFailed message is emitted, allowing
232
+ sl-experiment to block movement and prevent the animal from reaching the armed boundary.
233
+ """
234
+
235
+ puff_duration_ms: int = 100
236
+ """The duration, in milliseconds, for which to deliver the N2 gas puff when the animal fails the trial."""
237
+ occupancy_duration_ms: int = 1000
238
+ """The time, in milliseconds, the animal must occupy the trigger zone to disarm the stimulus boundary and avoid
239
+ the gas puff."""
240
+
241
+
242
+ # noinspection PyArgumentList
243
+ @dataclass()
244
+ class MesoscopeExperimentConfiguration(YamlConfig):
245
+ """Defines an experiment session that uses the Mesoscope_VR data acquisition system.
246
+
247
+ This is the unified configuration that serves both the data acquisition system (sl-experiment),
248
+ the analysis pipeline (sl-forgery), and the Unity VR environment (sl-unity-tasks).
249
+ """
250
+
251
+ # Virtual Reality building block configuration
252
+ cues: list[Cue]
253
+ """Defines the Virtual Reality environment wall cues used in the experiment."""
254
+ segments: list[Segment]
255
+ """Defines the Virtual Reality environment segments (sequences of wall cues) for the Unity corridor system."""
256
+
257
+ # Task configuration
258
+ trial_structures: dict[str, WaterRewardTrial | GasPuffTrial]
259
+ """Defines experiment's structure by specifying the types of trials used by the phases (states) of the
260
+ experiment."""
261
+ experiment_states: dict[str, MesoscopeExperimentState]
262
+ """Defines the experiment's flow by specifying the sequence of experiment and data acquisition system states
263
+ executed during runtime."""
264
+
265
+ # VR environment configuration
266
+ vr_environment: VREnvironment
267
+ """Defines the Virtual Reality corridor used during the experiment."""
268
+ unity_scene_name: str
269
+ """The name of the Virtual Reality task (Unity Scene) used during the experiment."""
270
+ cue_offset_cm: float = 0.0
271
+ """Specifies the offset of the animal's starting position relative to the Virtual Reality (VR) environment's cue
272
+ sequence origin, in centimeters."""
273
+
274
+ @property
275
+ def _cue_by_name(self) -> dict[str, Cue]:
276
+ """Returns the mapping of cue names to their Cue class instances for all VR cues used in the experiment."""
277
+ return {cue.name: cue for cue in self.cues}
278
+
279
+ @property
280
+ def _cue_name_to_code(self) -> dict[str, int]:
281
+ """Returns the mapping of cue names to their unique identifier codes for all VR cues used in the experiment."""
282
+ return {cue.name: cue.code for cue in self.cues}
283
+
284
+ @property
285
+ def _segment_by_name(self) -> dict[str, Segment]:
286
+ """Returns the mapping of segment names to their Segment class instances for all VR segments used in the
287
+ experiment.
288
+ """
289
+ return {seg.name: seg for seg in self.segments}
290
+
291
+ def _get_segment_length_cm(self, segment_name: str) -> float:
292
+ """Returns the total length of the VR segment in centimeters."""
293
+ segment = self._segment_by_name[segment_name]
294
+ cue_map = self._cue_by_name
295
+ return sum(cue_map[cue_name].length_cm for cue_name in segment.cue_sequence)
296
+
297
+ def _get_segment_cue_codes(self, segment_name: str) -> list[int]:
298
+ """Returns the sequence of cue codes for the specified segment's cue sequence."""
299
+ segment = self._segment_by_name[segment_name]
300
+ return [self._cue_name_to_code[name] for name in segment.cue_sequence]
301
+
302
+ def __post_init__(self) -> None:
303
+ """Validates experiment configuration and populates derived trial fields."""
304
+ # Ensures cue codes are unique
305
+ codes = [cue.code for cue in self.cues]
306
+ if len(codes) != len(set(codes)):
307
+ duplicate_codes = [c for c in codes if codes.count(c) > 1]
308
+ message = (
309
+ f"Duplicate cue codes found: {set(duplicate_codes)} in the {self.vr_environment} VR environment "
310
+ f"definition. Each cue must use a unique integer code."
311
+ )
312
+ console.error(message=message, error=ValueError)
313
+
314
+ # Ensures cue names are unique
315
+ names = [cue.name for cue in self.cues]
316
+ if len(names) != len(set(names)):
317
+ duplicate_names = [n for n in names if names.count(n) > 1]
318
+ message = (
319
+ f"Duplicate cue names found: {set(duplicate_names)} in the {self.vr_environment} VR environment "
320
+ f"definition. Each cue must use a unique name."
321
+ )
322
+ console.error(message=message, error=ValueError)
323
+
324
+ # Ensures segment cue sequences reference valid cues
325
+ cue_names = {cue.name for cue in self.cues}
326
+ for seg in self.segments:
327
+ for cue_name in seg.cue_sequence:
328
+ if cue_name not in cue_names:
329
+ message = (
330
+ f"Segment '{seg.name}' references unknown cue '{cue_name}'. "
331
+ f"Available cues: {', '.join(sorted(cue_names))}."
332
+ )
333
+ console.error(message=message, error=ValueError)
334
+
335
+ # Populates the derived trial fields and validates them
336
+ segment_names = {seg.name for seg in self.segments}
337
+ for trial_name, trial in self.trial_structures.items():
338
+ # Validates segment reference
339
+ if trial.segment_name not in segment_names:
340
+ message = (
341
+ f"Trial '{trial_name}' references unknown segment '{trial.segment_name}'. "
342
+ f"Available segments: {', '.join(sorted(segment_names))}."
343
+ )
344
+ console.error(message=message, error=ValueError)
345
+
346
+ # Populates cue_sequence from segment
347
+ trial.cue_sequence = self._get_segment_cue_codes(trial.segment_name)
348
+
349
+ # Populates trial_length_cm from segment
350
+ trial.trial_length_cm = self._get_segment_length_cm(trial.segment_name)
351
+
352
+ # Validates zone positions with populated trial_length_cm
353
+ trial.validate_zones()
354
+
355
+
356
+ @dataclass()
357
+ class MesoscopeFileSystem:
358
+ """Stores the filesystem configuration of the Mesoscope-VR data acquisition system."""
359
+
360
+ root_directory: Path = Path()
361
+ """The absolute path to the directory where all projects are stored on the main data acquisition system PC."""
362
+ server_directory: Path = Path()
363
+ """The absolute path to the local-filesystem-mounted directory where all projects are stored on the remote compute
364
+ server."""
365
+ nas_directory: Path = Path()
366
+ """The absolute path to the local-filesystem-mounted directory where all projects are stored on the NAS backup
367
+ storage volume."""
368
+ mesoscope_directory: Path = Path()
369
+ """The absolute path to the local-filesystem-mounted directory where all Mesoscope-acquired data is aggregated
370
+ during acquisition by the PC that manages the Mesoscope during runtime."""
371
+
372
+
373
+ @dataclass()
374
+ class MesoscopeGoogleSheets:
375
+ """Stores the identifiers for the Google Sheets used by the Mesoscope-VR data acquisition system."""
376
+
377
+ surgery_sheet_id: str = ""
378
+ """The identifier of the Google Sheet that stores information about surgical interventions performed on the animals
379
+ that participate in data acquisition sessions."""
380
+ water_log_sheet_id: str = ""
381
+ """The identifier of the Google Sheet that stores information about water restriction and handling for all
382
+ animals that participate in data acquisition sessions."""
383
+
384
+
385
+ @dataclass()
386
+ class MesoscopeCameras:
387
+ """Stores the video camera configuration of the Mesoscope-VR data acquisition system."""
388
+
389
+ face_camera_index: int = 0
390
+ """The index of the face camera in the list of all available Harvester-managed cameras."""
391
+ body_camera_index: int = 1
392
+ """The index of the body camera in the list of all available Harvester-managed cameras."""
393
+ face_camera_quantization: int = 20
394
+ """The quantization parameter used by the face camera to encode acquired frames as video files."""
395
+ face_camera_preset: int = 7
396
+ """The encoding speed preset used by the face camera to encode acquired frames as video files. Must be one of the
397
+ valid members of the EncoderSpeedPresets enumeration from the ataraxis-video-system library."""
398
+ body_camera_quantization: int = 20
399
+ """The quantization parameter used by the body camera to encode acquired frames as video files."""
400
+ body_camera_preset: int = 7
401
+ """The encoding speed preset used by the body camera to encode acquired frames as video files. Must be one of the
402
+ valid members of the EncoderSpeedPresets enumeration from the ataraxis-video-system library."""
403
+
404
+
405
+ @dataclass()
406
+ class MesoscopeMicroControllers:
407
+ """Stores the microcontroller configuration of the Mesoscope-VR data acquisition system."""
408
+
409
+ actor_port: str = "/dev/ttyACM0"
410
+ """The USB port used by the Actor Microcontroller."""
411
+ sensor_port: str = "/dev/ttyACM1"
412
+ """The USB port used by the Sensor Microcontroller."""
413
+ encoder_port: str = "/dev/ttyACM2"
414
+ """The USB port used by the Encoder Microcontroller."""
415
+ keepalive_interval_ms: int = 500
416
+ """The interval, in milliseconds, at which the microcontrollers are expected to receive and send the keepalive
417
+ messages used to ensure that all controllers function as expected during runtime."""
418
+ minimum_brake_strength_g_cm: float = 43.2047
419
+ """The torque applied by the running wheel brake at the minimum operational voltage, in gram centimeter."""
420
+ maximum_brake_strength_g_cm: float = 1152.1246
421
+ """The torque applied by the running wheel brake at the maximum operational voltage, in gram centimeter."""
422
+ wheel_diameter_cm: float = 15.0333
423
+ """The diameter of the running wheel, in centimeters."""
424
+ lick_threshold_adc: int = 600
425
+ """The threshold voltage, in raw analog units recorded by a 3.3 Volt 12-bit Analog-to-Digital-Converter (ADC),
426
+ interpreted as the animal's tongue contacting the lick sensor."""
427
+ lick_signal_threshold_adc: int = 300
428
+ """The minimum voltage, in raw analog units recorded by a 3.3 Volt 12-bit Analog-to-Digital-Converter (ADC),
429
+ reported to the PC as a non-zero value. Voltages below this level are interpreted as 'no-lick' noise and are
430
+ pulled to 0."""
431
+ lick_delta_threshold_adc: int = 300
432
+ """The minimum absolute difference between two consecutive lick sensor readouts, in raw analog units recorded by
433
+ a 3.3 Volt 12-bit Analog-to-Digital-Converter (ADC), for the change to be reported to the PC."""
434
+ lick_averaging_pool_size: int = 2
435
+ """The number of lick sensor readouts to average together to produce the final lick sensor readout value."""
436
+ torque_baseline_voltage_adc: int = 2048
437
+ """The voltage level, in raw analog units measured by a 3.3 Volt 12-bit Analog-to-Digital-Converter (ADC) after the
438
+ AD620 amplifier, that corresponds to no torque (0) readout."""
439
+ torque_maximum_voltage_adc: int = 3443
440
+ """The voltage level, in raw analog units measured by a 3.3 Volt 12-bit Analog-to-Digital-Converter (ADC)
441
+ after the AD620 amplifier, that corresponds to the absolute maximum torque detectable by the sensor."""
442
+ torque_sensor_capacity_g_cm: float = 720.0779
443
+ """The maximum torque detectable by the sensor, in grams centimeter (g cm)."""
444
+ torque_report_cw: bool = True
445
+ """Determines whether the torque sensor should report torques in the Clockwise (CW) direction."""
446
+ torque_report_ccw: bool = True
447
+ """Determines whether the sensor should report torque in the Counter-Clockwise (CCW) direction."""
448
+ torque_signal_threshold_adc: int = 150
449
+ """The minimum voltage, in raw analog units recorded by a 3.3 Volt 12-bit Analog-to-Digital-Converter (ADC),
450
+ reported to the PC as a non-zero value. Voltages below this level are interpreted as noise and are pulled to 0."""
451
+ torque_delta_threshold_adc: int = 100
452
+ """The minimum absolute difference between two consecutive torque sensor readouts, in raw analog units recorded by
453
+ a 3.3 Volt 12-bit Analog-to-Digital-Converter (ADC), for the change to be reported to the PC."""
454
+ torque_averaging_pool_size: int = 4
455
+ """The number of torque sensor readouts to average together to produce the final torque sensor readout value."""
456
+ wheel_encoder_ppr: int = 8192
457
+ """The resolution of the wheel's quadrature encoder, in Pulses Per Revolution (PPR)."""
458
+ wheel_encoder_report_cw: bool = False
459
+ """Determines whether the encoder should report rotation in the Clockwise (CW) direction."""
460
+ wheel_encoder_report_ccw: bool = True
461
+ """Determines whether the encoder should report rotation in the CounterClockwise (CCW) direction."""
462
+ wheel_encoder_delta_threshold_pulse: int = 15
463
+ """The minimum absolute difference between two consecutive encoder readouts, in encoder pulse counts, for the
464
+ change to be reported to the PC."""
465
+ wheel_encoder_polling_delay_us: int = 500
466
+ """The delay, in microseconds, between consecutive encoder state readouts."""
467
+ cm_per_unity_unit: float = 10.0
468
+ """The length of each Virtual Reality (VR) environment's distance 'unit' (Unity unit) in real-world centimeters."""
469
+ screen_trigger_pulse_duration_ms: int = 500
470
+ """The duration, in milliseconds, of the TTL pulse used to toggle the VR screen power state."""
471
+ sensor_polling_delay_ms: int = 1
472
+ """The delay, in milliseconds, between any two successive readouts of any sensor other than the encoder."""
473
+ mesoscope_frame_averaging_pool_size = 0
474
+ """The number of digital pin readouts to average together when determining the current logic level of the incoming
475
+ TTL signal sent by the mesoscope at the onset of each frame's acquisition."""
476
+ valve_calibration_data: dict[int | float, int | float] | tuple[tuple[int | float, int | float], ...] = (
477
+ (15000, 1.10),
478
+ (30000, 3.0),
479
+ (45000, 6.25),
480
+ (60000, 10.90),
481
+ )
482
+ """Maps water delivery solenoid valve open times, in microseconds, to the dispensed volumes of water, in
483
+ microliters."""
484
+
485
+
486
+ @dataclass()
487
+ class MesoscopeExternalAssets:
488
+ """Stores the third-party asset configuration of the Mesoscope-VR data acquisition system."""
489
+
490
+ headbar_port: str = "/dev/ttyUSB0"
491
+ """The USB port used by the HeadBar Zaber motor controllers."""
492
+ lickport_port: str = "/dev/ttyUSB1"
493
+ """The USB port used by the LickPort Zaber motor controllers."""
494
+ wheel_port: str = "/dev/ttyUSB2"
495
+ """The USB port used by the Wheel Zaber motor controllers."""
496
+ unity_ip: str = "127.0.0.1"
497
+ """The IP address of the MQTT broker used to communicate with the Unity game engine."""
498
+ unity_port: int = 1883
499
+ """The port number of the MQTT broker used to communicate with the Unity game engine."""
500
+
501
+
502
+ @dataclass()
503
+ class MesoscopeSystemConfiguration(YamlConfig):
504
+ """Defines the hardware and software asset configuration for the Mesoscope-VR data acquisition system."""
505
+
506
+ name: str = str(AcquisitionSystems.MESOSCOPE_VR)
507
+ """The descriptive name of the data acquisition system."""
508
+ filesystem: MesoscopeFileSystem = field(default_factory=MesoscopeFileSystem)
509
+ """Stores the filesystem configuration."""
510
+ sheets: MesoscopeGoogleSheets = field(default_factory=MesoscopeGoogleSheets)
511
+ """Stores the identifiers and access credentials for the Google Sheets."""
512
+ cameras: MesoscopeCameras = field(default_factory=MesoscopeCameras)
513
+ """Stores the video cameras configuration."""
514
+ microcontrollers: MesoscopeMicroControllers = field(default_factory=MesoscopeMicroControllers)
515
+ """Stores the microcontrollers configuration."""
516
+ assets: MesoscopeExternalAssets = field(default_factory=MesoscopeExternalAssets)
517
+ """Stores the third-party hardware and firmware assets configuration."""
518
+
519
+ def __post_init__(self) -> None:
520
+ """Ensures that all instance assets are stored as the expected types."""
521
+ # Restores Path objects from strings.
522
+ self.filesystem.root_directory = Path(self.filesystem.root_directory)
523
+ self.filesystem.server_directory = Path(self.filesystem.server_directory)
524
+ self.filesystem.nas_directory = Path(self.filesystem.nas_directory)
525
+ self.filesystem.mesoscope_directory = Path(self.filesystem.mesoscope_directory)
526
+
527
+ # Converts valve_calibration data from a dictionary to a tuple of tuples.
528
+ if not isinstance(self.microcontrollers.valve_calibration_data, tuple):
529
+ self.microcontrollers.valve_calibration_data = tuple(
530
+ (k, v) for k, v in self.microcontrollers.valve_calibration_data.items()
531
+ )
532
+
533
+ # Verifies the contents of the valve calibration data loaded from the config file.
534
+ valve_calibration_data = self.microcontrollers.valve_calibration_data
535
+ element_count = 2
536
+ if not all(
537
+ isinstance(item, tuple)
538
+ and len(item) == element_count
539
+ and isinstance(item[0], (int | float))
540
+ and isinstance(item[1], (int | float))
541
+ for item in valve_calibration_data
542
+ ):
543
+ message = (
544
+ f"Unable to initialize the MesoscopeSystemConfiguration class. Expected each item under the "
545
+ f"'valve_calibration_data' field of the Mesoscope-VR acquisition system configuration .yaml file to be "
546
+ f"a tuple of two integer or float values, but instead encountered {valve_calibration_data} with at "
547
+ f"least one incompatible element."
548
+ )
549
+ console.error(message=message, error=TypeError)
550
+
551
+ def save(self, path: Path) -> None:
552
+ """Saves the instance's data to disk as a .YAML file.
553
+
554
+ Args:
555
+ path: The path to the .YAML file to save the data to.
556
+ """
557
+ # Copies instance data to prevent it from being modified by reference when executing the steps below
558
+ original = deepcopy(self)
559
+
560
+ # Converts all Path objects to strings before dumping the data, as .YAML encoder does not recognize Path objects
561
+ original.filesystem.root_directory = str(original.filesystem.root_directory) # type: ignore[assignment]
562
+ original.filesystem.server_directory = str(original.filesystem.server_directory) # type: ignore[assignment]
563
+ original.filesystem.nas_directory = str(original.filesystem.nas_directory) # type: ignore[assignment]
564
+ original.filesystem.mesoscope_directory = str( # type: ignore[assignment]
565
+ original.filesystem.mesoscope_directory
566
+ )
567
+
568
+ # Converts valve calibration data into dictionary format
569
+ if isinstance(original.microcontrollers.valve_calibration_data, tuple):
570
+ original.microcontrollers.valve_calibration_data = dict(original.microcontrollers.valve_calibration_data)
571
+
572
+ # Saves the data to the YAML file
573
+ original.to_yaml(file_path=path)
574
+
575
+
576
+ @dataclass()
577
+ class ServerConfiguration(YamlConfig):
578
+ """Defines the access credentials and the filesystem layout of the Sun lab's remote compute server."""
579
+
580
+ username: str = ""
581
+ """The username to use for server authentication."""
582
+ password: str = ""
583
+ """The password to use for server authentication."""
584
+ host: str = "cbsuwsun.biohpc.cornell.edu"
585
+ """The hostname or IP address of the server to connect to."""
586
+ storage_root: str = "/local/storage"
587
+ """The path to the server's storage (slow) HDD RAID volume."""
588
+ working_root: str = "/local/workdir"
589
+ """The path to the server's working (fast) NVME RAID volume."""
590
+ shared_directory_name: str = "sun_data"
591
+ """The name of the shared directory that stores Sun lab's project data on both server volumes."""
592
+ shared_storage_root: str = field(init=False, default_factory=lambda: "/local/storage/sun_data")
593
+ """The path to the root Sun lab's shared directory on the storage server's volume."""
594
+ shared_working_root: str = field(init=False, default_factory=lambda: "/local/workdir/sun_data")
595
+ """The path to the root Sun lab's shared directory on the working server's volume."""
596
+ user_data_root: str = field(init=False, default_factory=lambda: "/local/storage/YourNetID")
597
+ """The path to the root user's directory on the storage server's volume."""
598
+ user_working_root: str = field(init=False, default_factory=lambda: "/local/workdir/YourNetID")
599
+ """The path to the root user's directory on the working server's volume."""
600
+
601
+ def __post_init__(self) -> None:
602
+ """Resolves all server-side directory paths."""
603
+ # Stores directory paths as strings as this is used by the paramiko bindings in the Server class from the
604
+ # sl-forgery library.
605
+ self.shared_storage_root = str(Path(self.storage_root).joinpath(self.shared_directory_name))
606
+ self.shared_working_root = str(Path(self.working_root).joinpath(self.shared_directory_name))
607
+ self.user_data_root = str(Path(self.storage_root).joinpath(f"{self.username}"))
608
+ self.user_working_root = str(Path(self.working_root).joinpath(f"{self.username}"))
609
+
610
+
611
+ def set_working_directory(path: Path) -> None:
612
+ """Sets the specified directory as the Sun lab's working directory for the local machine (PC).
613
+
614
+ Notes:
615
+ This function caches the path to the working directory in the user's data directory.
616
+
617
+ If the input path does not point to an existing directory, the function creates the requested directory.
618
+
619
+ Args:
620
+ path: The path to the directory to set as the local Sun lab's working directory.
621
+ """
622
+ # Resolves the path to the static .txt file used to store the path to the system configuration file
623
+ app_dir = Path(appdirs.user_data_dir(appname="sun_lab_data", appauthor="sun_lab"))
624
+ path_file = app_dir.joinpath("working_directory_path.txt")
625
+
626
+ # In case this function is called before the app directory is created, ensures the app directory exists
627
+ ensure_directory_exists(path_file)
628
+
629
+ # Ensures that the input path's directory exists
630
+ ensure_directory_exists(path)
631
+
632
+ # Also ensures that the working directory contains the 'configuration' subdirectory.
633
+ ensure_directory_exists(path.joinpath("configuration"))
634
+
635
+ # Replaces the contents of the working_directory_path.txt file with the provided path
636
+ with path_file.open("w") as f:
637
+ f.write(str(path))
638
+
639
+ console.echo(message=f"Sun lab's working directory set to: {path}.", level=LogLevel.SUCCESS)
640
+
641
+
642
+ def get_working_directory() -> Path:
643
+ """Resolves and returns the path to the local Sun lab's working directory.
644
+
645
+ Returns:
646
+ The path to the local working directory.
647
+
648
+ Raises:
649
+ FileNotFoundError: If the local working directory has not been configured for the host-machine.
650
+ """
651
+ # Uses appdirs to locate the user data directory and resolve the path to the configuration file
652
+ app_dir = Path(appdirs.user_data_dir(appname="sun_lab_data", appauthor="sun_lab"))
653
+ path_file = app_dir.joinpath("working_directory_path.txt")
654
+
655
+ # If the cache file or the Sun lab's data directory does not exist, aborts with an error
656
+ if not path_file.exists():
657
+ message = (
658
+ "Unable to resolve the path to the local Sun lab's working directory, as it has not been set. "
659
+ "Set the local working directory by using the 'sl-configure directory' CLI command."
660
+ )
661
+ console.error(message=message, error=FileNotFoundError)
662
+
663
+ # Loads the path to the local working directory
664
+ with path_file.open() as f:
665
+ working_directory = Path(f.read().strip())
666
+
667
+ # If the configuration file does not exist, also aborts with an error
668
+ if not working_directory.exists():
669
+ message = (
670
+ "Unable to resolve the path to the local Sun lab's working directory, as the currently configured "
671
+ "directory does not exist at the expected path. Set a new working directory by using the 'sl-configure "
672
+ "directory' CLI command."
673
+ )
674
+ console.error(message=message, error=FileNotFoundError)
675
+
676
+ # Returns the path to the working directory
677
+ return working_directory
678
+
679
+
680
+ def create_system_configuration_file(system: AcquisitionSystems | str) -> None:
681
+ """Creates the .YAML configuration file for the requested Sun lab's data acquisition system and configures the local
682
+ machine (PC) to use this file for all future acquisition-system-related calls.
683
+
684
+ Notes:
685
+ This function creates the configuration file inside the local Sun lab's working directory.
686
+
687
+ Args:
688
+ system: The name (type) of the data acquisition system for which to create the configuration file.
689
+
690
+ Raises:
691
+ ValueError: If the input acquisition system name (type) is not recognized.
692
+ """
693
+ # Resolves the path to the local Sun lab's working directory.
694
+ directory = get_working_directory()
695
+ directory = directory.joinpath("configuration") # Navigates to the 'configuration' subdirectory
696
+
697
+ # Removes any existing system configuration files to ensure only one system configuration exists on each configured
698
+ # machine
699
+ existing_configs = tuple(directory.glob("*_system_configuration.yaml"))
700
+ for config_file in existing_configs:
701
+ console.echo(f"Removing the existing configuration file {config_file.name}...")
702
+ config_file.unlink()
703
+
704
+ if system == AcquisitionSystems.MESOSCOPE_VR:
705
+ # Creates the precursor configuration file for the mesoscope-vr system
706
+ configuration = MesoscopeSystemConfiguration()
707
+ configuration_path = directory.joinpath(f"{system}_system_configuration.yaml")
708
+ configuration.save(path=configuration_path)
709
+
710
+ # Prompts the user to finish configuring the system by editing the parameters inside the configuration file
711
+ message = (
712
+ f"Mesoscope-VR data acquisition system configuration file: Saved to {configuration_path}. Edit the "
713
+ f"default parameters inside the configuration file to finish configuring the system."
714
+ )
715
+ console.echo(message=message, level=LogLevel.SUCCESS)
716
+ input("Enter anything to continue...")
717
+
718
+ # If the input acquisition system is not recognized, raises a ValueError
719
+ else:
720
+ systems = tuple(AcquisitionSystems)
721
+ message = (
722
+ f"Unable to generate the system configuration file for the acquisition system '{system}'. The specified "
723
+ f"acquisition system is not supported (not recognized). Currently, only the following acquisition systems "
724
+ f"are supported: {', '.join(systems)}."
725
+ )
726
+ console.error(message=message, error=ValueError)
727
+
728
+
729
+ def get_system_configuration_data() -> MesoscopeSystemConfiguration:
730
+ """Resolves the path to the local data acquisition system configuration file and loads the configuration data as
731
+ a SystemConfiguration instance.
732
+
733
+ Returns:
734
+ The initialized SystemConfiguration class instance that stores the loaded configuration parameters.
735
+
736
+ Raises:
737
+ FileNotFoundError: If the local machine does not have a valid data acquisition system configuration file.
738
+ """
739
+ # Maps supported file names to configuration classes.
740
+ _supported_configuration_files = {
741
+ f"{AcquisitionSystems.MESOSCOPE_VR}_system_configuration.yaml": MesoscopeSystemConfiguration,
742
+ }
743
+
744
+ # Resolves the path to the local Sun lab's working directory.
745
+ directory = get_working_directory()
746
+ directory = directory.joinpath("configuration") # Navigates to the 'configuration' subdirectory
747
+
748
+ # Finds all configuration files stored in the local working directory.
749
+ config_files = tuple(directory.glob("*_system_configuration.yaml"))
750
+
751
+ # Ensures exactly one configuration file exists in the working directory
752
+ if len(config_files) != 1:
753
+ file_names = [f.name for f in config_files]
754
+ message = (
755
+ f"Expected a single data acquisition system configuration file to be found inside the local Sun lab's "
756
+ f"working directory ({directory}), but found {len(config_files)} files ({', '.join(file_names)}). Call the "
757
+ f"'sl-configure system' CLI command to reconfigure the host-machine to only contain a single data "
758
+ f"acquisition system configuration file."
759
+ )
760
+ console.error(message=message, error=FileNotFoundError)
761
+ # Fallback to appease mypy, should not be reachable
762
+ raise FileNotFoundError(message) # pragma: no cover
763
+
764
+ # Gets the single configuration file
765
+ configuration_file = config_files[0]
766
+ file_name = configuration_file.name
767
+
768
+ # Ensures that the file name is supported
769
+ if file_name not in _supported_configuration_files:
770
+ message = (
771
+ f"The data acquisition system configuration file '{file_name}' stored in the local Sun lab's working "
772
+ f"directory is not recognized. Call the 'sl-configure system' CLI command to reconfigure the host-machine "
773
+ f"to use a supported configuration file."
774
+ )
775
+ console.error(message=message, error=ValueError)
776
+ # Fallback to appease mypy, should not be reachable
777
+ raise ValueError(message) # pragma: no cover
778
+
779
+ # Loads and return the configuration data
780
+ configuration_class = _supported_configuration_files[file_name]
781
+ return configuration_class.from_yaml(file_path=configuration_file)
782
+
783
+
784
+ def set_google_credentials_path(path: Path) -> None:
785
+ """Configures the local machine (PC) to use the provided Google Sheets service account credentials .JSON file for
786
+ all future interactions with the Google's API.
787
+
788
+ Notes:
789
+ This function caches the path to the Google Sheets credentials file in the user's data directory.
790
+
791
+ Args:
792
+ path: The path to the .JSON file containing the Google Sheets service account credentials.
793
+
794
+ Raises:
795
+ FileNotFoundError: If the specified .JSON file does not exist at the provided path.
796
+ """
797
+ # Verifies that the specified credentials file exists
798
+ if not path.exists():
799
+ message = (
800
+ f"Unable to set the Google Sheets credentials path. The specified file ({path}) does not exist. "
801
+ f"Ensure the .JSON credentials file exists at the specified path before calling this function."
802
+ )
803
+ console.error(message=message, error=FileNotFoundError)
804
+
805
+ # Verifies that the file has a .json extension
806
+ if path.suffix.lower() != ".json":
807
+ message = (
808
+ f"Unable to set the Google Sheets credentials path. The specified file ({path}) does not have a .json "
809
+ f"extension. Provide the path to the Google Sheets service account credentials .JSON file."
810
+ )
811
+ console.error(message=message, error=ValueError)
812
+
813
+ # Resolves the path to the static .txt file used to store the path to the Google Sheets credentials file
814
+ app_dir = Path(appdirs.user_data_dir(appname="sun_lab_data", appauthor="sun_lab"))
815
+ path_file = app_dir.joinpath("google_credentials_path.txt")
816
+
817
+ # In case this function is called before the app directory is created, ensures the app directory exists
818
+ ensure_directory_exists(path_file)
819
+
820
+ # Writes the absolute path to the credentials file
821
+ with path_file.open("w") as f:
822
+ f.write(str(path.resolve()))
823
+
824
+
825
+ def get_google_credentials_path() -> Path:
826
+ """Resolves and returns the path to the Google service account credentials .JSON file.
827
+
828
+ Returns:
829
+ The path to the Google service account credentials .JSON file.
830
+
831
+ Raises:
832
+ FileNotFoundError: If the Google service account credentials path has not been configured for the host-machine,
833
+ or if the previously configured credentials file no longer exists at the expected path.
834
+ """
835
+ # Uses appdirs to locate the user data directory and resolve the path to the credentials' path cache file
836
+ app_dir = Path(appdirs.user_data_dir(appname="sun_lab_data", appauthor="sun_lab"))
837
+ path_file = app_dir.joinpath("google_credentials_path.txt")
838
+
839
+ # If the cache file does not exist, aborts with an error
840
+ if not path_file.exists():
841
+ message = (
842
+ "Unable to resolve the path to the Google account credentials file, as it has not been set. "
843
+ "Set the Google service account credentials path by using the 'sl-configure google' CLI command."
844
+ )
845
+ console.error(message=message, error=FileNotFoundError)
846
+
847
+ # Once the location of the path storage file is resolved, reads the file path from the file
848
+ with path_file.open() as f:
849
+ credentials_path = Path(f.read().strip())
850
+
851
+ # If the credentials' file does not exist at the cached path, aborts with an error
852
+ if not credentials_path.exists():
853
+ message = (
854
+ "Unable to resolve the path to the Google account credentials file, as the previously configured "
855
+ f"credentials file does not exist at the expected path ({credentials_path}). Set a new credentials path "
856
+ "by using the 'sl-configure google' CLI command."
857
+ )
858
+ console.error(message=message, error=FileNotFoundError)
859
+
860
+ # Returns the path to the credentials' file
861
+ return credentials_path
862
+
863
+
864
+ def create_server_configuration_file(
865
+ username: str,
866
+ password: str,
867
+ host: str = "cbsuwsun.biopic.cornell.edu",
868
+ storage_root: str = "/local/workdir",
869
+ working_root: str = "/local/storage",
870
+ shared_directory_name: str = "sun_data",
871
+ ) -> None:
872
+ """Creates the .YAML configuration file for the Sun lab compute server and configures the local machine (PC) to use
873
+ this file for all future server-related calls.
874
+
875
+ Notes:
876
+ This function creates the configuration file inside the shared Sun lab's working directory on the local machine.
877
+
878
+ Args:
879
+ username: The username to use for server authentication.
880
+ password: The password to use for server authentication.
881
+ host: The hostname or IP address of the server to connect to.
882
+ storage_root: The path to the server's storage (slow) HDD RAID volume.
883
+ working_root: The path to the server's working (fast) NVME RAID volume.
884
+ shared_directory_name: The name of the shared directory that stores Sun lab's project data on both server
885
+ volumes.
886
+ """
887
+ output_directory = get_working_directory().joinpath("configuration")
888
+ ServerConfiguration(
889
+ username=username,
890
+ password=password,
891
+ host=host,
892
+ storage_root=storage_root,
893
+ working_root=working_root,
894
+ shared_directory_name=shared_directory_name,
895
+ ).to_yaml(file_path=output_directory.joinpath("server_configuration.yaml"))
896
+ console.echo(message="Server configuration file: Created.", level=LogLevel.SUCCESS)
897
+
898
+
899
+ def get_server_configuration() -> ServerConfiguration:
900
+ """Resolves and returns the Sun lab compute server's configuration data as a ServerConfiguration instance.
901
+
902
+ Returns:
903
+ The loaded and validated server configuration data, stored in a ServerConfiguration instance.
904
+
905
+ Raises:
906
+ FileNotFoundError: If the configuration file does not exist in the local Sun lab's working directory.
907
+ ValueError: If the configuration file exists, but is not properly configured.
908
+ """
909
+ # Gets the path to the local working directory.
910
+ working_directory = get_working_directory().joinpath("configuration")
911
+
912
+ # Resolves the path to the server configuration file.
913
+ config_path = working_directory.joinpath("server_configuration.yaml")
914
+
915
+ # Ensures that the configuration file exists.
916
+ if not config_path.exists():
917
+ message = (
918
+ f"Unable to locate the 'server_configuration.yaml' file in the Sun lab's working directory "
919
+ f"{config_path}. Call the 'sl-configure server' CLI command to create the server configuration file."
920
+ )
921
+ console.error(message=message, error=FileNotFoundError)
922
+ raise FileNotFoundError(message) # Fallback to appease mypy, should not be reachable
923
+
924
+ # Loads the configuration file.
925
+ configuration = ServerConfiguration.from_yaml(file_path=config_path)
926
+
927
+ # Validates that the configuration is properly set up.
928
+ if configuration.username == "" or configuration.password == "":
929
+ message = (
930
+ "The 'server_configuration.yaml' file appears to be unconfigured or contains placeholder access "
931
+ "credentials. Call the 'sl-configure server' CLI command to reconfigure the server access credentials."
932
+ )
933
+ console.error(message=message, error=ValueError)
934
+ raise ValueError(message) # Fallback to appease mypy, should not be reachable
935
+
936
+ # Returns the loaded configuration data to the caller.
937
+ message = f"Server configuration: Resolved. Using the {configuration.username} account."
938
+ console.echo(message=message, level=LogLevel.SUCCESS)
939
+ return configuration