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.
- sl_shared_assets/__init__.py +120 -0
- sl_shared_assets/command_line_interfaces/__init__.py +3 -0
- sl_shared_assets/command_line_interfaces/configure.py +318 -0
- sl_shared_assets/data_classes/__init__.py +121 -0
- sl_shared_assets/data_classes/configuration_data.py +939 -0
- sl_shared_assets/data_classes/dataset_data.py +385 -0
- sl_shared_assets/data_classes/processing_data.py +385 -0
- sl_shared_assets/data_classes/runtime_data.py +237 -0
- sl_shared_assets/data_classes/session_data.py +400 -0
- sl_shared_assets/data_classes/surgery_data.py +138 -0
- sl_shared_assets/data_transfer/__init__.py +12 -0
- sl_shared_assets/data_transfer/checksum_tools.py +125 -0
- sl_shared_assets/data_transfer/transfer_tools.py +181 -0
- sl_shared_assets/py.typed +0 -0
- sl_shared_assets-6.1.1.dist-info/METADATA +830 -0
- sl_shared_assets-6.1.1.dist-info/RECORD +19 -0
- sl_shared_assets-6.1.1.dist-info/WHEEL +4 -0
- sl_shared_assets-6.1.1.dist-info/entry_points.txt +2 -0
- sl_shared_assets-6.1.1.dist-info/licenses/LICENSE +674 -0
|
@@ -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
|