np-workflows 1.6.89__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. np_workflows/__init__.py +7 -0
  2. np_workflows/assets/images/logo_np_hab.png +0 -0
  3. np_workflows/assets/images/logo_np_vis.png +0 -0
  4. np_workflows/experiments/__init__.py +1 -0
  5. np_workflows/experiments/dynamic_routing/__init__.py +2 -0
  6. np_workflows/experiments/dynamic_routing/main.py +117 -0
  7. np_workflows/experiments/dynamic_routing/widgets.py +82 -0
  8. np_workflows/experiments/openscope_P3/P3_workflow_widget.py +83 -0
  9. np_workflows/experiments/openscope_P3/__init__.py +2 -0
  10. np_workflows/experiments/openscope_P3/main_P3_pilot.py +217 -0
  11. np_workflows/experiments/openscope_barcode/__init__.py +2 -0
  12. np_workflows/experiments/openscope_barcode/barcode_workflow_widget.py +83 -0
  13. np_workflows/experiments/openscope_barcode/camstim_scripts/barcode_mapping_script.py +138 -0
  14. np_workflows/experiments/openscope_barcode/camstim_scripts/barcode_opto_script.py +219 -0
  15. np_workflows/experiments/openscope_barcode/main_barcode_pilot.py +217 -0
  16. np_workflows/experiments/openscope_loop/__init__.py +2 -0
  17. np_workflows/experiments/openscope_loop/camstim_scripts/barcode_mapping_script.py +138 -0
  18. np_workflows/experiments/openscope_loop/camstim_scripts/barcode_opto_script.py +219 -0
  19. np_workflows/experiments/openscope_loop/loop_workflow_widget.py +83 -0
  20. np_workflows/experiments/openscope_loop/main_loop_pilot.py +217 -0
  21. np_workflows/experiments/openscope_psycode/__init__.py +2 -0
  22. np_workflows/experiments/openscope_psycode/main_psycode_pilot.py +217 -0
  23. np_workflows/experiments/openscope_psycode/psycode_workflow_widget.py +83 -0
  24. np_workflows/experiments/openscope_v2/__init__.py +2 -0
  25. np_workflows/experiments/openscope_v2/main_v2_pilot.py +217 -0
  26. np_workflows/experiments/openscope_v2/v2_workflow_widget.py +83 -0
  27. np_workflows/experiments/openscope_vippo/__init__.py +2 -0
  28. np_workflows/experiments/openscope_vippo/main_vippo_pilot.py +217 -0
  29. np_workflows/experiments/openscope_vippo/vippo_workflow_widget.py +83 -0
  30. np_workflows/experiments/task_trained_network/__init__.py +2 -0
  31. np_workflows/experiments/task_trained_network/camstim_scripts/make_tt_stims.py +23 -0
  32. np_workflows/experiments/task_trained_network/camstim_scripts/oct22_tt_stim_script.py +69 -0
  33. np_workflows/experiments/task_trained_network/camstim_scripts/stims/densely_annotated_00.stim +5 -0
  34. np_workflows/experiments/task_trained_network/camstim_scripts/stims/densely_annotated_01.stim +5 -0
  35. np_workflows/experiments/task_trained_network/camstim_scripts/stims/densely_annotated_02.stim +5 -0
  36. np_workflows/experiments/task_trained_network/camstim_scripts/stims/densely_annotated_03.stim +5 -0
  37. np_workflows/experiments/task_trained_network/camstim_scripts/stims/densely_annotated_04.stim +5 -0
  38. np_workflows/experiments/task_trained_network/camstim_scripts/stims/densely_annotated_05.stim +5 -0
  39. np_workflows/experiments/task_trained_network/camstim_scripts/stims/densely_annotated_06.stim +5 -0
  40. np_workflows/experiments/task_trained_network/camstim_scripts/stims/densely_annotated_07.stim +5 -0
  41. np_workflows/experiments/task_trained_network/camstim_scripts/stims/densely_annotated_08.stim +5 -0
  42. np_workflows/experiments/task_trained_network/camstim_scripts/stims/densely_annotated_09.stim +5 -0
  43. np_workflows/experiments/task_trained_network/camstim_scripts/stims/densely_annotated_10.stim +5 -0
  44. np_workflows/experiments/task_trained_network/camstim_scripts/stims/densely_annotated_11.stim +5 -0
  45. np_workflows/experiments/task_trained_network/camstim_scripts/stims/densely_annotated_12.stim +5 -0
  46. np_workflows/experiments/task_trained_network/camstim_scripts/stims/densely_annotated_13.stim +5 -0
  47. np_workflows/experiments/task_trained_network/camstim_scripts/stims/densely_annotated_14.stim +5 -0
  48. np_workflows/experiments/task_trained_network/camstim_scripts/stims/densely_annotated_15.stim +5 -0
  49. np_workflows/experiments/task_trained_network/camstim_scripts/stims/densely_annotated_16.stim +5 -0
  50. np_workflows/experiments/task_trained_network/camstim_scripts/stims/densely_annotated_17.stim +5 -0
  51. np_workflows/experiments/task_trained_network/camstim_scripts/stims/densely_annotated_18.stim +5 -0
  52. np_workflows/experiments/task_trained_network/camstim_scripts/stims/flash_250ms.stim +20 -0
  53. np_workflows/experiments/task_trained_network/camstim_scripts/stims/gabor_20_deg_250ms.stim +30 -0
  54. np_workflows/experiments/task_trained_network/camstim_scripts/stims/old_stim.stim +5 -0
  55. np_workflows/experiments/task_trained_network/camstim_scripts/stims/shuffle_reversed.stim +5 -0
  56. np_workflows/experiments/task_trained_network/camstim_scripts/stims/shuffle_reversed_1st.stim +5 -0
  57. np_workflows/experiments/task_trained_network/camstim_scripts/stims/shuffle_reversed_2nd.stim +5 -0
  58. np_workflows/experiments/task_trained_network/camstim_scripts/ttn_main_script.py +130 -0
  59. np_workflows/experiments/task_trained_network/camstim_scripts/ttn_mapping_script.py +138 -0
  60. np_workflows/experiments/task_trained_network/camstim_scripts/ttn_opto_script.py +219 -0
  61. np_workflows/experiments/task_trained_network/main_ttn_pilot.py +263 -0
  62. np_workflows/experiments/task_trained_network/ttn_session_widget.py +83 -0
  63. np_workflows/experiments/task_trained_network/ttn_stim_config.py +213 -0
  64. np_workflows/experiments/templeton/__init__.py +2 -0
  65. np_workflows/experiments/templeton/main.py +105 -0
  66. np_workflows/experiments/templeton/widgets.py +82 -0
  67. np_workflows/shared/__init__.py +3 -0
  68. np_workflows/shared/base_experiments.py +826 -0
  69. np_workflows/shared/camstim_scripts/flash_250ms.stim +20 -0
  70. np_workflows/shared/camstim_scripts/gabor_20_deg_250ms.stim +30 -0
  71. np_workflows/shared/npxc.py +187 -0
  72. np_workflows/shared/widgets.py +705 -0
  73. np_workflows-1.6.89.dist-info/METADATA +85 -0
  74. np_workflows-1.6.89.dist-info/RECORD +76 -0
  75. np_workflows-1.6.89.dist-info/WHEEL +4 -0
  76. np_workflows-1.6.89.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,826 @@
1
+ import abc
2
+ import configparser
3
+ import contextlib
4
+ import copy
5
+ import enum
6
+ import functools
7
+ import pathlib
8
+ import re
9
+ import shutil
10
+ import time
11
+ from typing import Any, ClassVar, Iterable, Literal, Mapping, Optional, Protocol, Sequence, Type
12
+
13
+ import fabric
14
+ import invoke
15
+ import ipylab
16
+ import np_config
17
+ import np_logging
18
+ import np_services
19
+ import np_session
20
+ import upath
21
+ from np_services import (
22
+ Finalizable,
23
+ Initializable,
24
+ Pretestable,
25
+ Service,
26
+ Shutdownable,
27
+ Startable,
28
+ Stoppable,
29
+ Testable,
30
+ Validatable,
31
+ Verifiable,
32
+ )
33
+ import np_services.resources.zro
34
+ import np_workflows.shared.npxc as npxc
35
+
36
+ logger = np_logging.getLogger(__name__)
37
+
38
+ class WithSessionInfo(Protocol):
39
+ @property
40
+ def session(self) -> np_session.Session: ...
41
+ @property
42
+ def mouse(self) -> np_session.Mouse: ...
43
+ @property
44
+ def user(self) -> np_session.User: ...
45
+ @property
46
+ def rig(self) -> np_config.Rig: ...
47
+
48
+
49
+ class WithSession(abc.ABC):
50
+
51
+ default_session_subclass: ClassVar[Type[np_session.Session]] = np_session.PipelineSession
52
+ default_session_type: Literal['ephys', 'hab'] = 'ephys'
53
+
54
+ services: tuple[Service, ...] = ()
55
+ "All services. Devices, databases, etc."
56
+
57
+ workflow: enum.Enum = enum.Enum('BaseWithSessionWorkflow', ('BASECLASS')).BASECLASS # type: ignore
58
+ """Enum for workflow type, e.g. PRETEST, HAB_AUD, HAB_VIS, EPHYS_ etc."""
59
+
60
+ def log(self, message: str, weblog_name: Optional[str] = None) -> None:
61
+ logger.info(message)
62
+ if not weblog_name:
63
+ weblog_name = self.workflow.name
64
+ with contextlib.suppress(AttributeError):
65
+ np_logging.web(f'{weblog_name.lower()}_{self.mouse}').info(message)
66
+
67
+ @property
68
+ @abc.abstractmethod
69
+ def recorders(self) -> tuple[Startable | Stoppable, ...]:
70
+ """Services that record data. These are started and stopped as a group."""
71
+ return NotImplemented
72
+
73
+ def __init__(self,
74
+ mouse: Optional[str | int | np_session.LIMS2MouseInfo] = None,
75
+ operator: Optional[str | np_session.LIMS2UserInfo] = None,
76
+ session: Optional[str | pathlib.Path | int | np_session.PipelineSession] = None,
77
+ session_type: Optional[Literal['ephys', 'hab']] = None,
78
+ **kwargs,
79
+ ):
80
+
81
+ if session and not isinstance(session, np_session.Session):
82
+ session = np_session.Session(session)
83
+ logger.debug('%s | Initialized with existing session %s', self.__class__.__name__, session)
84
+ if session_type and ((a := session_type == 'hab') != (b := session.is_hab)):
85
+ logger.warning('session_type arg specified (%r) does not match that of supplied %r: %r', a, b, session)
86
+ elif operator and mouse:
87
+ logger.debug('%s | Creating new session for mouse %r, operator %r', self.__class__.__name__, mouse, operator)
88
+ session = self.generate_session(mouse, operator, session_type or self.default_session_type)
89
+ elif not session:
90
+ raise ValueError('Must specify either a mouse + operator, or an existing session')
91
+
92
+ self.session = session
93
+
94
+ self.configure_services()
95
+ self.session.npexp_path.mkdir(parents=True, exist_ok=True)
96
+
97
+ def __repr__(self) -> str:
98
+ return f'{self.__class__.__name__}({self.session})'
99
+
100
+ @classmethod
101
+ def generate_session(cls, *args, **kwargs):
102
+ return cls.default_session_subclass.new(*args, **kwargs)
103
+
104
+ @property
105
+ def session(self) -> np_session.PipelineSession:
106
+ return self._session
107
+
108
+ @session.setter
109
+ def session(self, value: str | np_session.Session | pathlib.Path | int | np_session.LIMS2SessionInfo):
110
+ self._session = np_session.Session(value) if not isinstance(value, np_session.Session) else value
111
+ logger.debug('Set experiment.session to %r', self._session)
112
+
113
+ @property
114
+ def session_type(self) -> Literal['ephys', 'hab']:
115
+ with contextlib.suppress(AttributeError):
116
+ return self._session_type
117
+ if self.session:
118
+ if self.session.is_hab:
119
+ return 'hab'
120
+ return 'ephys'
121
+ raise AttributeError('Session has not been set')
122
+
123
+ @session_type.setter
124
+ def session_type(self, value: Literal['ephys', 'hab']):
125
+ if value not in ('ephys', 'hab'):
126
+ raise ValueError(f'Session type must be either "ephys" or "hab": got {value!r}')
127
+ self._session_type = value
128
+ logger.debug('Set session_type to %r', value)
129
+
130
+ @property
131
+ def rig(self) -> np_config.Rig | None:
132
+ "Computer hostnames and configuration for the rig we're currently on."
133
+ with contextlib.suppress(AttributeError):
134
+ return self._rig
135
+ with contextlib.suppress(ValueError):
136
+ self._rig = np_config.Rig()
137
+ return self.rig
138
+
139
+ @property
140
+ def mouse(self) -> np_session.Mouse:
141
+ if isinstance(self.session.mouse, str | int):
142
+ return np_session.Mouse(self.session.mouse)
143
+ return self.session.mouse
144
+
145
+ @property
146
+ def user(self) -> np_session.User | None:
147
+ return self.session.user
148
+
149
+ @property
150
+ def imager(self) -> Type[np_services.Cam3d] | Type[np_services.ImageMVR]:
151
+ if not self.rig:
152
+ raise ValueError('Rig has not been set')
153
+ if self.rig.idx == 0:
154
+ return np_services.Cam3d
155
+ return np_services.ImageMVR
156
+
157
+ @property
158
+ def config(self) -> dict[Any, Any]:
159
+ "Top-level keys include names of Services. Each Service then has a config dict."
160
+ with contextlib.suppress(AttributeError):
161
+ return self._config
162
+ if self.rig:
163
+ self._config = self.rig.config
164
+ return self.config
165
+
166
+ def configure_services(self) -> None:
167
+ """For each service, apply every key in self.config['service'] as an attribute."""
168
+
169
+ def apply_config(service):
170
+ if config := self.config["services"].get(service.__name__):
171
+ for key, value in config.items():
172
+ setattr(service, key, value)
173
+ logger.debug(
174
+ f"{self.__class__.__name__} | Configuring {service.__name__}.{key} = {getattr(service, key)}"
175
+ )
176
+
177
+ for service in self.services:
178
+ for base in service.__class__.__bases__:
179
+ apply_config(base)
180
+ apply_config(service)
181
+
182
+ def initialize_and_test_services(self) -> None:
183
+
184
+ for service in self.services:
185
+
186
+ if isinstance(service, Initializable):
187
+ service.initialize()
188
+
189
+ if isinstance(service, Testable):
190
+ service.test()
191
+
192
+ def pretest_services(self) -> None:
193
+ for service in (_ for _ in self.services if isinstance(_, Pretestable)):
194
+ service.pretest()
195
+
196
+ def start_recording(self, *recorders: Startable) -> None:
197
+ if not recorders and hasattr(self, 'recorders'):
198
+ recorders = self.recorders
199
+ stoppables = tuple(_ for _ in recorders if isinstance(_, Stoppable))
200
+ with np_services.stop_on_error(*stoppables):
201
+ for recorder in recorders:
202
+ recorder.start()
203
+ time.sleep(2)
204
+ if isinstance(recorder, Verifiable):
205
+ recorder.verify()
206
+
207
+ def stop_recording_after_stim_finished(self,
208
+ recorders: Optional[Iterable[Stoppable]] = None,
209
+ stims: Optional[Iterable[Stoppable]] = None,
210
+ ) -> None:
211
+ """Stop recording after all stims have finished.
212
+
213
+ - object's `.recorders` attr used by default, stopped in reverse order.
214
+ - stims will be awaited
215
+ """
216
+ if not recorders and hasattr(self, 'recorders'):
217
+ recorders = reversed(self.recorders)
218
+ if not stims and hasattr(self, 'stims'):
219
+ stims = self.stims
220
+ while not all(_.is_ready_to_start() for _ in stims):
221
+ time.sleep(5)
222
+ for stoppable in (_ for _ in recorders if isinstance(_, Stoppable)):
223
+ stoppable.stop()
224
+ if 'videomvr' in stoppable.__name__.lower():
225
+ sleep_s = 4
226
+ time.sleep(sleep_s)
227
+ logger.warning(f'Waiting additional {sleep_s} s for MVR to finish writing...')
228
+
229
+ def start_services(self, *services: Service) -> None:
230
+ if not services:
231
+ services = self.services
232
+ for service in (_ for _ in services if isinstance(_, Startable)):
233
+ service.start()
234
+ if isinstance(service, Verifiable):
235
+ service.verify()
236
+
237
+ def stop_services(self) -> None:
238
+ while not np_services.ScriptCamstim.is_ready_to_start():
239
+ time.sleep(10)
240
+ for service in (_ for _ in self.services if isinstance(_, Stoppable)):
241
+ service.stop()
242
+ if isinstance(service, Finalizable):
243
+ service.finalize()
244
+
245
+ def validate_services(self, *services: Service) -> None:
246
+ if not services:
247
+ services = self.services
248
+ for service in (_ for _ in services if isinstance(_, Validatable)):
249
+ service.validate()
250
+
251
+ def finalize_services(self, *services: Service) -> None:
252
+ if not services:
253
+ services = self.services
254
+ for service in (_ for _ in services if isinstance(_, Finalizable)):
255
+ service.finalize()
256
+
257
+ def shutdown_services(self) -> None:
258
+ for service in (_ for _ in self.services if isinstance(_, Shutdownable)):
259
+ service.shutdown()
260
+
261
+ def copy_files(self) -> None:
262
+ """Copy files from raw data storage to session folder for all services."""
263
+ self.copy_data_files()
264
+ self.copy_workflow_files()
265
+ self.copy_mpe_configs()
266
+ if self.session_type != 'hab':
267
+ self.copy_ephys()
268
+
269
+ @abc.abstractmethod
270
+ def copy_data_files(self) -> None:
271
+ """Copy files from raw data storage to session folder for all services."""
272
+ return NotImplemented
273
+
274
+ @abc.abstractmethod
275
+ def copy_ephys(self) -> None:
276
+ """Copy ephys data from Acq to session folder."""
277
+ return NotImplemented
278
+
279
+ def copy_workflow_files(self) -> None:
280
+ """Copy working directory (with ipynb, logs folder) and lock/pyproject files
281
+ from np_notebooks root."""
282
+
283
+ self.save_current_notebook()
284
+
285
+ cwd = pathlib.Path('.').resolve()
286
+ dest = self.session.npexp_path / 'exp'
287
+ dest.mkdir(exist_ok=True, parents=True)
288
+
289
+ shutil.copytree(cwd, dest, dirs_exist_ok=True)
290
+
291
+ lock = cwd.parent / 'pdm.lock'
292
+ pyproject = cwd.parent / 'pyproject.toml'
293
+
294
+ for _ in (lock, pyproject):
295
+ shutil.copy2(_, dest)
296
+
297
+ def copy_mpe_configs(self) -> None:
298
+ """Copy MPE config files to session folder."""
299
+ for path in (
300
+ self.rig.mvr_config,
301
+ self.rig.sync_config,
302
+ self.rig.camstim_config):
303
+ shutil.copy2(path, self.session.npexp_path)
304
+
305
+ def save_current_notebook(self) -> None:
306
+ app = ipylab.JupyterFrontEnd()
307
+ app.commands.execute('docmanager:save')
308
+ # TODO use the following to export to html (shows input to widgets and
309
+ # output of cells)
310
+ #! currently can't be run automatically as save as path dialog opens
311
+ # app.commands.execute('notebook:export-to-format', {
312
+ # 'format': 'html',
313
+ # # 'download': 'false',
314
+ # # 'path': 'c:/users/svc_neuropix/documents/github/np_notebooks/task_trained_network/ttn_pilot.html',
315
+ # })
316
+
317
+ @functools.cached_property
318
+ def system_camstim_params(self) -> dict[str, Any]:
319
+ """Try to load defaults from camstim config file on the Stim computer.
320
+
321
+ May encounter permission error if not running as svc_neuropix.
322
+ """
323
+ with contextlib.suppress(OSError):
324
+ parser = configparser.RawConfigParser()
325
+ parser.read(
326
+ (self.rig.paths["Camstim"].parent / "config" / "stim.cfg").as_posix()
327
+ )
328
+
329
+ camstim_default_config = {}
330
+ for section in parser.sections():
331
+ camstim_default_config[section] = {}
332
+ for k, v in parser[section].items():
333
+ try:
334
+ value = eval(
335
+ v
336
+ ) # this removes comments in config and converts values to expected datatype
337
+ except:
338
+ continue
339
+ else:
340
+ camstim_default_config[section][k] = value
341
+ return camstim_default_config
342
+ logger.warning("Could not load camstim defaults from config file on Stim computer.")
343
+ return {}
344
+
345
+ class PipelineExperiment(WithSession):
346
+ @property
347
+ def platform_json(self) -> np_session.PlatformJson:
348
+ self.session.platform_json.update('rig_id', str(self.rig))
349
+ return self.session.platform_json
350
+
351
+ def start_recording(self, *recorders: Startable) -> None:
352
+ super().start_recording(*recorders)
353
+ self.platform_json.ExperimentStartTime = npxc.now()
354
+ self.platform_json.write()
355
+
356
+
357
+ def stop_recording_after_stim_finished(self,
358
+ recorders: Optional[Iterable[Stoppable]] = None,
359
+ stims: Optional[Iterable[Stoppable]] = None,
360
+ ) -> None:
361
+ """Stop recording after all stims have finished."""
362
+ super().stop_recording_after_stim_finished(recorders, stims)
363
+ self.platform_json.ExperimentCompleteTime = npxc.now()
364
+ self.platform_json.write()
365
+
366
+ def rename_split_ephys_folders(self) -> None:
367
+ "Add `_probeABC` or `_probeDEF` to ephys folders recorded on two drives."
368
+ folders = np_services.OpenEphys.data_files
369
+ if not folders:
370
+ logger.info('Renaming: no ephys folders have been recorded')
371
+ renamed_folders = []
372
+ for name in set(_.name for _ in folders):
373
+ if '_probeABC' in name or '_probeDEF' in name:
374
+ logger.debug(f'Renaming: {name} already has probe letter suffix - aborted')
375
+ continue
376
+ if length := len(split_folders := [_ for _ in folders if _.name == name]) != 2:
377
+ logger.info(f'Renaming: {length} folders found for {name}, expected 2 - aborted')
378
+ continue
379
+ logger.debug('Renaming split ephys folders %r', split_folders)
380
+ for folder, probe_letters in zip(sorted(split_folders, key=lambda x: x.as_posix()), ('ABC', 'DEF')):
381
+ renamed = folder.replace(folder.with_name(f'{name}_probe{probe_letters}'))
382
+ renamed_folders.append(renamed)
383
+ logger.info('Renamed split ephys folders %r', split_folders)
384
+ np_services.OpenEphys.data_files = renamed_folders
385
+
386
+ @staticmethod
387
+ def contains_uuid(text: str) -> bool:
388
+ hexchars = '[0-9a-fA-F]'
389
+ pattern = rf"{hexchars}{{8}}-{hexchars}{{4}}-{hexchars}{{4}}-{hexchars}{{4}}-{hexchars}{{12}}"
390
+ return re.search(pattern, text) is not None
391
+
392
+ def copy_data_files(self) -> None:
393
+ """Copy data files from raw data storage to session folder for all services."""
394
+ for service in self.services:
395
+ match service.__name__:
396
+ case "np_services.open_ephys":
397
+ continue # copy ephys after other files
398
+ case _:
399
+ files = None
400
+ with contextlib.suppress(AttributeError):
401
+ files = service.data_files
402
+ if not files:
403
+ continue
404
+ files = set(files)
405
+ logger.info("%s | Copying files %r", service.__name__, files)
406
+ for file in files:
407
+ renamed = None
408
+ if file.suffix == '.h5':
409
+ renamed = f'{self.session.folder}.sync'
410
+ elif file.suffix == '.pkl':
411
+ for _ in ('opto', 'main', 'mapping', 'behavior'):
412
+ if _ in file.name:
413
+ renamed = f'{self.session.folder}.{"stim" if _ == "main" else _}.pkl'
414
+ break
415
+ else:
416
+ if self.contains_uuid(file.name):
417
+ renamed = f'{self.session.folder}.behavior.pkl'
418
+ elif file.suffix in ('.json', '.mp4') and (cam_label := re.match('Behavior|Eye|Face',file.name)):
419
+ renamed = f'{self.session.folder}.{cam_label.group().lower()}{file.suffix}'
420
+ elif file.suffix in ('.json', '.mp4') and (cam_label := re.match('BEH|EYE|FACE',file.name)):
421
+ file_label = {'BEH':'behavior', 'EYE':'eye', 'FACE':'face'}
422
+ renamed = f'{self.session.folder}.{file_label[cam_label.group()]}{file.suffix}'
423
+ elif service in (np_services.NewScaleCoordinateRecorder, ):
424
+ renamed = f'{self.session.folder}.motor-locs.csv'
425
+ elif service in (np_services.Cam3d, np_services.MVR):
426
+ for lims_label, img_label in {
427
+ 'pre_experiment_surface_image_left': '_surface-image1-left',
428
+ 'pre_experiment_surface_image_right': '_surface-image1-right',
429
+ 'brain_surface_image_left': '_surface-image2-left',
430
+ 'brain_surface_image_right': '_surface-image2-right',
431
+ 'pre_insertion_surface_image_left': '_surface-image3-left',
432
+ 'pre_insertion_surface_image_right': '_surface-image3-right',
433
+ 'post_insertion_surface_image_left': '_surface-image4-left',
434
+ 'post_insertion_surface_image_right': '_surface-image4-right',
435
+ 'post_stimulus_surface_image_left': '_surface-image5-left',
436
+ 'post_stimulus_surface_image_right': '_surface-image5-right',
437
+ 'post_experiment_surface_image_left': '_surface-image6-left',
438
+ 'post_experiment_surface_image_right': '_surface-image6-right',
439
+ }.items():
440
+ if lims_label in file.name:
441
+ renamed = f'{self.session.folder}{img_label}{file.suffix}'
442
+ shutil.copy2(file, self.session.npexp_path / (renamed or file.name))
443
+
444
+ def copy_ephys(self) -> None:
445
+ # copy ephys
446
+ self.rename_split_ephys_folders()
447
+ password = np_config.fetch('/logins')['svc_neuropix']['password']
448
+ ssh = fabric.Connection(host=np_services.OpenEphys.host, user='svc_neuropix', connect_kwargs=dict(password=password))
449
+ for ephys_folder in np_services.OpenEphys.data_files:
450
+ with contextlib.suppress(Exception):
451
+ with ssh:
452
+ ssh.run(
453
+ f'robocopy "{ephys_folder}" "{self.session.npexp_path / ephys_folder.name}" /j /s /xo'
454
+ # /j unbuffered, /s incl non-empty subdirs, /xo exclude src files older than dest
455
+ )
456
+
457
+ class PipelineEphys(PipelineExperiment):
458
+ default_session_type = 'ephys'
459
+
460
+ class PipelineHab(PipelineExperiment):
461
+ default_session_type = 'hab'
462
+
463
+
464
+ class DynamicRoutingExperiment(WithSession):
465
+
466
+ default_session_subclass: ClassVar[Type[np_session.Session]]
467
+
468
+
469
+ use_github: bool = True
470
+
471
+ class Workflow(enum.Enum):
472
+ """Enum for the different sessions available.
473
+
474
+ Used in the workflow to determine branches (e.g. HAB workflow should
475
+ skip set up and use of OpenEphys).
476
+
477
+ Can also be used to switch different task names, scripts, etc.
478
+
479
+ - names are used to set the experiment subclass
480
+ - values must be unique!
481
+ """
482
+ PRETEST = "test EPHYS"
483
+ HAB = "EPHYS minus probes"
484
+ EPHYS = "opto in task optional"
485
+ OPTO = "opto in task, no ephys"
486
+ TRAINING = "task only, with sync + video"
487
+ workflow: Workflow
488
+
489
+ @property
490
+ def preset_task_names(self) -> tuple[str, ...]:
491
+ return tuple(np_config.fetch('/projects/dynamicrouting')['preset_task_names'])
492
+
493
+ @property
494
+ def commit_hash(self) -> str:
495
+ if hasattr(self, '_commit_hash'):
496
+ return self._commit_hash
497
+ self._commit_hash = self.config['dynamicrouting_task_script']['commit_hash']
498
+ return self.commit_hash
499
+
500
+ @commit_hash.setter
501
+ def commit_hash(self, value: str):
502
+ self._commit_hash = value
503
+
504
+ @property
505
+ def github_url(self) -> str:
506
+ if hasattr(self, '_github_url'):
507
+ return self._github_url
508
+ self._github_url = self.config['dynamicrouting_task_script']['url']
509
+ return self.github_url
510
+
511
+ @github_url.setter
512
+ def github_url(self, value: str):
513
+ self._github_url = value
514
+
515
+ @property
516
+ def base_url(self) -> upath.UPath:
517
+ return upath.UPath(self.github_url) / self.commit_hash
518
+
519
+ @property
520
+ def base_path(self) -> pathlib.Path:
521
+ return pathlib.Path('//allen/programs/mindscope/workgroups/dynamicrouting/DynamicRoutingTask/')
522
+
523
+ @property
524
+ def is_pretest(self) -> bool:
525
+ return 'PRETEST' in self.workflow.name
526
+
527
+ @property
528
+ def is_hab(self) -> bool:
529
+ return 'HAB' in self.workflow.name
530
+
531
+ @property
532
+ def is_opto(self) -> bool:
533
+ """Opto will run during behavior task trials - independent of `is_ephys`."""
534
+ return 'opto' in self.task_name
535
+
536
+ @property
537
+ def is_optotagging(self) -> bool:
538
+ return self.is_ephys and 'AI32' in str(self.mouse.lims['full_genotype']).upper()
539
+
540
+ @property
541
+ def is_ephys(self) -> bool:
542
+ return 'EPHYS' in self.workflow.name
543
+
544
+ @property
545
+ def task_name(self) -> str:
546
+ """For sending to runTask.py and controlling implementation details of the task."""
547
+ if hasattr(self, '_task_name'):
548
+ return self._task_name
549
+ return ""
550
+
551
+ @task_name.setter
552
+ def task_name(self, task_name: str) -> None:
553
+ self._task_name = task_name
554
+ if task_name not in self.preset_task_names:
555
+ print(f"{task_name = !r} doesn't correspond to a preset value, but the attribute is updated anyway!")
556
+ else:
557
+ print(f"Updated {self.__class__.__name__}.{task_name = !r}")
558
+
559
+ stims = (np_services.ScriptCamstim,)
560
+
561
+ @property
562
+ def recorders(self) -> tuple[Service, ...]:
563
+ """Services to be started before stimuli run, and stopped after. Session-dependent."""
564
+ if self.is_ephys:
565
+ return (np_services.Sync, np_services.VideoMVR, np_services.OpenEphys)
566
+ return (np_services.Sync, np_services.VideoMVR)
567
+
568
+
569
+ @property
570
+ def hdf5_dir(self) -> pathlib.Path:
571
+ return self.base_path / 'Data' / str(self.mouse)
572
+
573
+ @property
574
+ def task_script_base(self) -> upath.UPath:
575
+ return self.base_url if self.use_github else upath.UPath(self.base_path)
576
+
577
+ @property
578
+ def task_params(self) -> dict[str, str | bool]:
579
+ """For sending to runTask.py"""
580
+ return dict(
581
+ rigName = str(self.rig).replace('.',''),
582
+ subjectName = str(self.mouse),
583
+ taskScript = 'DynamicRouting1.py',
584
+ taskVersion = self.task_name,
585
+ saveSoundArray = True,
586
+ )
587
+
588
+ @property
589
+ def spontaneous_params(self) -> dict[str, str]:
590
+ """For sending to runTask.py"""
591
+ return dict(
592
+ rigName = str(self.rig).replace('.',''),
593
+ subjectName = str(self.mouse),
594
+ taskScript = 'TaskControl.py',
595
+ taskVersion = 'spontaneous',
596
+ )
597
+
598
+ @property
599
+ def spontaneous_rewards_params(self) -> dict[str, str]:
600
+ """For sending to runTask.py"""
601
+ return dict(
602
+ rigName = str(self.rig).replace('.',''),
603
+ subjectName = str(self.mouse),
604
+ taskScript = 'TaskControl.py',
605
+ taskVersion = 'spontaneous rewards',
606
+ rewardSound = "device",
607
+ )
608
+
609
+ def get_latest_optogui_txt(self, opto_or_optotagging: Literal['opto', 'optotagging']) -> pathlib.Path:
610
+ dirname = dict(opto='optoParams', optotagging='optotagging')[opto_or_optotagging]
611
+ file_prefix = dirname
612
+
613
+ rig = str(self.rig).replace('.', '')
614
+ locs_root = self.base_path / 'OptoGui' / f'{dirname}'
615
+ available_locs = sorted(tuple(locs_root.glob(f"{file_prefix}_{self.mouse.id}_{rig}_*")), reverse=True)
616
+ if not available_locs:
617
+ raise FileNotFoundError(f"No optotagging locs found for {self.mouse}/{rig} - have you run OptoGui?")
618
+ return available_locs[0]
619
+
620
+
621
+ @property
622
+ def optotagging_params(self):
623
+ """For sending to runTask.py"""
624
+ if hasattr(self, '_optotagging_params'):
625
+ return self._optotagging_params
626
+
627
+ else:
628
+ #set to defaults through setter
629
+ self.optotagging_params = {}
630
+ return self._optotagging_params
631
+
632
+ @optotagging_params.setter
633
+ def optotagging_params(self, paramsdict):
634
+
635
+ self._optotagging_params = dict(
636
+ rigName = str(self.rig).replace('.',''),
637
+ subjectName = str(self.mouse),
638
+ taskScript = 'OptoTagging.py',
639
+ optoTaggingLocs = self.get_latest_optogui_txt('optotagging').as_posix(),
640
+ )
641
+ self._optotagging_params.update(paramsdict)
642
+
643
+ @property
644
+ def opto_params(self) -> dict[str, str | bool]:
645
+ """Opto params are handled by runTask.py and don't need to be passed from
646
+ here. Just check they exist on disk here.
647
+ """
648
+ _ = self.get_latest_optogui_txt('opto') # raises FileNotFoundError if not found
649
+ return dict(
650
+ rigName = str(self.rig).replace('.',''),
651
+ subjectName = str(self.mouse),
652
+ taskScript = 'DynamicRouting1.py',
653
+ saveSoundArray = True,
654
+ )
655
+
656
+ @property
657
+ def mapping_params(self) -> dict[str, str | bool]:
658
+ """For sending to runTask.py"""
659
+ return dict(
660
+ rigName = str(self.rig).replace('.',''),
661
+ subjectName = str(self.mouse),
662
+ taskScript = 'RFMapping.py',
663
+ saveSoundArray = True,
664
+ )
665
+
666
+ @property
667
+ def sound_test_params(self) -> dict[str, str]:
668
+ """For sending to runTask.py"""
669
+ return dict(
670
+ rigName = str(self.rig).replace('.',''),
671
+ subjectName = 'sound',
672
+ taskScript = 'TaskControl.py',
673
+ taskVersion = 'sound test',
674
+ )
675
+
676
+ def get_github_file_content(self, address: str) -> str:
677
+ import requests
678
+ response = requests.get(address)
679
+ if response.status_code not in (200, ):
680
+ response.raise_for_status()
681
+ return response.content.decode("utf-8")
682
+
683
+ @property
684
+ def camstim_script(self) -> upath.UPath:
685
+ return self.task_script_base / 'runTask.py'
686
+
687
+ def run_script(self, stim: Literal['sound_test', 'mapping', 'task', 'opto', 'optotagging', 'spontaneous', 'spontaneous_rewards']) -> None:
688
+
689
+ params = copy.deepcopy(getattr(self, f'{stim.replace(" ", "_")}_params'))
690
+
691
+ # add mouse and user info for MPE
692
+ params['mouse_id'] = str(self.mouse.id)
693
+ params['user_id'] = self.user.id if self.user else 'ben.hardcastle'
694
+
695
+ if self.task_script_base.as_posix() not in params['taskScript']:
696
+ params['taskScript'] = (self.task_script_base / params['taskScript']).as_posix()
697
+
698
+ if self.is_pretest:
699
+ params['maxFrames'] = 60 * 15
700
+ params['maxTrials'] = 3
701
+
702
+ if self.use_github:
703
+
704
+ params['GHTaskScriptParams'] = {
705
+ 'taskScript': params['taskScript'],
706
+ 'taskControl': (self.task_script_base / 'TaskControl.py').as_posix(),
707
+ 'taskUtils': (self.task_script_base / 'TaskUtils.py').as_posix(),
708
+ }
709
+ params['task_script_commit_hash'] = self.commit_hash
710
+
711
+ np_services.ScriptCamstim.script = self.camstim_script.read_text()
712
+ else:
713
+ np_services.ScriptCamstim.script = self.camstim_script.as_posix()
714
+
715
+ if (script_override_params := getattr(self, 'script_override_params', None)) is not None:
716
+ if not isinstance(script_override_params, Mapping):
717
+ raise TypeError(f"script_override_params must be a dict/Mapping, got {type(script_override_params)}")
718
+ else:
719
+ script_override_params = {}
720
+
721
+ np_services.ScriptCamstim.params = params | script_override_params
722
+
723
+ self.update_state()
724
+ self.log(f"{stim} started")
725
+
726
+ np_services.ScriptCamstim.start()
727
+ with contextlib.suppress(np_services.resources.zro.ZroError):
728
+ while not np_services.ScriptCamstim.is_ready_to_start():
729
+ time.sleep(1)
730
+
731
+ self.log(f"{stim} complete")
732
+
733
+ with contextlib.suppress(np_services.resources.zro.ZroError):
734
+ np_services.ScriptCamstim.finalize()
735
+
736
+ run_mapping = functools.partialmethod(run_script, 'mapping')
737
+ run_sound_test = functools.partialmethod(run_script, 'sound_test')
738
+ run_task = functools.partialmethod(run_script, 'task')
739
+ run_opto = functools.partialmethod(run_script, 'opto') # if opto params are handled by runTask then this is the same as run_task
740
+ run_optotagging = functools.partialmethod(run_script, 'optotagging')
741
+ run_spontaneous = functools.partialmethod(run_script, 'spontaneous')
742
+ run_spontaneous_rewards = functools.partialmethod(run_script, 'spontaneous_rewards')
743
+
744
+
745
+ def update_state(self) -> None:
746
+ "Persist useful but non-essential info."
747
+ self.mouse.state['last_session'] = self.session.id
748
+ self.mouse.state['last_workflow'] = str(self.workflow.name)
749
+ self.mouse.state['last_task'] = str(self.task_name)
750
+
751
+ def initialize_and_test_services(self) -> None:
752
+ """Configure, initialize (ie. reset), then test all services."""
753
+
754
+ np_services.ScriptCamstim.script = '//allen/programs/mindscope/workgroups/dynamicrouting/DynamicRoutingTask/runTask.py'
755
+ np_services.ScriptCamstim.data_root = self.hdf5_dir
756
+
757
+ np_services.MouseDirector.user = self.user.id
758
+ np_services.MouseDirector.mouse = self.mouse.id
759
+
760
+ np_services.OpenEphys.folder = self.session.folder
761
+
762
+ np_services.NewScaleCoordinateRecorder.log_root = self.session.npexp_path
763
+
764
+ self.configure_services()
765
+
766
+ super().initialize_and_test_services()
767
+
768
+ def copy_ephys(self) -> None:
769
+ # copy ephys
770
+ password = np_config.fetch('/logins')['svc_neuropix']['password']
771
+ ssh = fabric.Connection(host=np_services.OpenEphys.host, user='svc_neuropix', connect_kwargs=dict(password=password))
772
+ for ephys_folder in np_services.OpenEphys.data_files:
773
+ if '__temp__' in ephys_folder.name:
774
+ continue
775
+ if isinstance(self.session, np_session.TempletonPilotSession):
776
+ ephys_folder = next(ephys_folder.glob('Record Node*'))
777
+ with ssh, contextlib.suppress(invoke.UnexpectedExit):
778
+ ssh.run(
779
+ f'robocopy "{ephys_folder}" "{self.session.npexp_path / ephys_folder.name}" /j /s /xo'
780
+ # /j unbuffered, /s incl non-empty subdirs, /xo exclude src files older than dest
781
+ )
782
+
783
+
784
+ def copy_data_files(self) -> None:
785
+ """Copy files from raw data storage to session folder for all services
786
+ except Open Ephys."""
787
+
788
+ # copy vimba files:
789
+ for file in pathlib.Path(
790
+ np_config.local_to_unc(self.rig.mon, np_services.config_from_zk()['ImageVimba']['data'])
791
+ ).glob(f'{self.session.npexp_path.name}*'):
792
+ shutil.copy2(file, self.session.npexp_path)
793
+ npxc.validate_or_overwrite(self.session.npexp_path / file.name, file)
794
+ print(file)
795
+ continue
796
+
797
+ for service in self.services:
798
+ match service.__name__:
799
+ case "ScriptCamstim" | "SessionCamstim":
800
+ files = tuple(_ for _ in self.hdf5_dir.glob('*') if _.stat().st_ctime > self.stims[0].initialization)
801
+ case "np_services.open_ephys":
802
+ continue # copy ephys after other files
803
+ case "NewScaleCoordinateRecorder":
804
+ files = tuple(service.data_root.glob('*')) + tuple(self.rig.paths['NewScaleCoordinateRecorder'].glob('*'))
805
+ case _:
806
+ files: Iterable[pathlib.Path] = service.data_files or service.get_latest_data('*')
807
+ if not files:
808
+ continue
809
+ files = set(files)
810
+ print(files)
811
+ for file in files:
812
+ shutil.copy2(file, self.session.npexp_path)
813
+ npxc.validate_or_overwrite(self.session.npexp_path / file.name, file)
814
+
815
+ #TODO move this to a dedicated np_service class instead of using ScriptCamstim
816
+ def run_stim_desktop_theme_script(self, selection: str) -> None:
817
+ np_services.ScriptCamstim.script = '//allen/programs/mindscope/workgroups/dynamicrouting/ben/change_desktop.py'
818
+ np_services.ScriptCamstim.params = {'selection': selection}
819
+ np_services.ScriptCamstim.start()
820
+ while not np_services.ScriptCamstim.is_ready_to_start():
821
+ time.sleep(0.1)
822
+
823
+ set_grey_desktop_on_stim = functools.partialmethod(run_stim_desktop_theme_script, 'grey')
824
+ set_dark_desktop_on_stim = functools.partialmethod(run_stim_desktop_theme_script, 'dark')
825
+ reset_desktop_on_stim = functools.partialmethod(run_stim_desktop_theme_script, 'reset')
826
+