mesofield 0.3.2b0__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 (111) hide show
  1. docs/_static/custom.css +40 -0
  2. docs/_static/favicon.png +0 -0
  3. docs/_static/logo.png +0 -0
  4. docs/api/index.md +70 -0
  5. docs/conf.py +200 -0
  6. docs/developer_guide.md +303 -0
  7. docs/index.md +25 -0
  8. docs/tutorial.md +4 -0
  9. docs/user_guide.md +172 -0
  10. examples/teensy_pulse_generator.py +320 -0
  11. experiments/pipeline_demo/experiment.json +24 -0
  12. experiments/pipeline_demo/hardware.yaml +23 -0
  13. experiments/pipeline_demo/procedure.py +50 -0
  14. experiments/two_cam_demo/experiment.json +24 -0
  15. experiments/two_cam_demo/hardware.yaml +58 -0
  16. experiments/two_cam_demo/load_dataset.py +213 -0
  17. experiments/two_cam_demo/procedure.py +87 -0
  18. external/video-codecs/openh264-1.8.0-win64.dll +0 -0
  19. mesofield/__init__.py +45 -0
  20. mesofield/__main__.py +11 -0
  21. mesofield/_version.py +24 -0
  22. mesofield/base.py +750 -0
  23. mesofield/cli/__init__.py +57 -0
  24. mesofield/cli/_richhelp.py +100 -0
  25. mesofield/cli/acquire.py +254 -0
  26. mesofield/cli/datakit.py +165 -0
  27. mesofield/cli/process.py +376 -0
  28. mesofield/cli/rig.py +108 -0
  29. mesofield/cli/tools.py +347 -0
  30. mesofield/config.py +751 -0
  31. mesofield/data/__init__.py +23 -0
  32. mesofield/data/batch.py +633 -0
  33. mesofield/data/manager.py +388 -0
  34. mesofield/data/writer.py +289 -0
  35. mesofield/datakit/__init__.py +44 -0
  36. mesofield/datakit/__main__.py +35 -0
  37. mesofield/datakit/_utils/_logger.py +5 -0
  38. mesofield/datakit/_version.py +141 -0
  39. mesofield/datakit/config.py +50 -0
  40. mesofield/datakit/core.py +783 -0
  41. mesofield/datakit/datamodel.py +200 -0
  42. mesofield/datakit/discover.py +124 -0
  43. mesofield/datakit/explore.py +651 -0
  44. mesofield/datakit/notebooks/pupil_dlc.ipynb +2445 -0
  45. mesofield/datakit/profile.py +535 -0
  46. mesofield/datakit/shell.py +83 -0
  47. mesofield/datakit/sources/__init__.py +65 -0
  48. mesofield/datakit/sources/analysis/mesomap.py +194 -0
  49. mesofield/datakit/sources/analysis/mesoscope.py +77 -0
  50. mesofield/datakit/sources/analysis/pupil.py +246 -0
  51. mesofield/datakit/sources/behavior/__init__.py +0 -0
  52. mesofield/datakit/sources/behavior/dataqueue.py +281 -0
  53. mesofield/datakit/sources/behavior/psychopy.py +364 -0
  54. mesofield/datakit/sources/behavior/treadmill.py +323 -0
  55. mesofield/datakit/sources/behavior/wheel.py +277 -0
  56. mesofield/datakit/sources/camera/mesoscope.py +32 -0
  57. mesofield/datakit/sources/camera/metadata_json.py +130 -0
  58. mesofield/datakit/sources/camera/pupil.py +28 -0
  59. mesofield/datakit/sources/camera/suite2p.py +547 -0
  60. mesofield/datakit/sources/register.py +204 -0
  61. mesofield/datakit/sources/session/config.py +130 -0
  62. mesofield/datakit/sources/session/notes.py +63 -0
  63. mesofield/datakit/sources/session/timestamps.py +58 -0
  64. mesofield/datakit/timeline.py +306 -0
  65. mesofield/devices/__init__.py +42 -0
  66. mesofield/devices/base.py +498 -0
  67. mesofield/devices/base_camera.py +295 -0
  68. mesofield/devices/cameras.py +740 -0
  69. mesofield/devices/daq.py +151 -0
  70. mesofield/devices/encoder.py +384 -0
  71. mesofield/devices/mocks.py +275 -0
  72. mesofield/devices/psychopy_device.py +455 -0
  73. mesofield/devices/subprocesses/__init__.py +0 -0
  74. mesofield/devices/subprocesses/psychopy.py +133 -0
  75. mesofield/devices/treadmill.py +318 -0
  76. mesofield/engines.py +380 -0
  77. mesofield/gui/Mesofield_icon.png +0 -0
  78. mesofield/gui/__init__.py +76 -0
  79. mesofield/gui/config_wizard.py +724 -0
  80. mesofield/gui/controller.py +535 -0
  81. mesofield/gui/dynamic_controller.py +78 -0
  82. mesofield/gui/maingui.py +427 -0
  83. mesofield/gui/mdagui.py +285 -0
  84. mesofield/gui/qt_device_adapter.py +109 -0
  85. mesofield/gui/speedplotter.py +152 -0
  86. mesofield/gui/theme.py +445 -0
  87. mesofield/gui/tiff_viewer.py +1050 -0
  88. mesofield/gui/viewer.py +691 -0
  89. mesofield/hardware.py +549 -0
  90. mesofield/playback.py +1298 -0
  91. mesofield/processing/__init__.py +12 -0
  92. mesofield/processing/runner.py +237 -0
  93. mesofield/processors/__init__.py +13 -0
  94. mesofield/processors/base.py +287 -0
  95. mesofield/processors/frame_mean.py +19 -0
  96. mesofield/protocols.py +378 -0
  97. mesofield/scaffold/__init__.py +34 -0
  98. mesofield/scaffold/experiment.py +400 -0
  99. mesofield/scaffold/rigs.py +121 -0
  100. mesofield/signals.py +85 -0
  101. mesofield/utils/__init__.py +0 -0
  102. mesofield/utils/_logger.py +156 -0
  103. mesofield/utils/retrofit.py +309 -0
  104. mesofield/utils/utils.py +217 -0
  105. mesofield-0.3.2b0.dist-info/METADATA +178 -0
  106. mesofield-0.3.2b0.dist-info/RECORD +111 -0
  107. mesofield-0.3.2b0.dist-info/WHEEL +5 -0
  108. mesofield-0.3.2b0.dist-info/entry_points.txt +2 -0
  109. mesofield-0.3.2b0.dist-info/licenses/LICENSE +21 -0
  110. mesofield-0.3.2b0.dist-info/top_level.txt +6 -0
  111. scripts/bench_frame_processor.py +103 -0
@@ -0,0 +1,204 @@
1
+ """Base classes for loading datakit sources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any, ClassVar, Dict, Iterable, Mapping, Optional, Tuple
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+ from mesofield.datakit.config import settings
14
+ from ..datamodel import LoadedStream
15
+
16
+
17
+ _DOCUMENT_FLAG = "__datakit_document__"
18
+
19
+
20
+ def document(method):
21
+ """Mark a method so its docstring is captured into stream ``meta['docs']``."""
22
+ setattr(method, _DOCUMENT_FLAG, True)
23
+ return method
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class LoadContext:
28
+ """Context object passed to every ``DataSource.load`` call.
29
+
30
+ Carries identity (subject/session/task), the inventory row for that cell
31
+ (so sources can locate sibling files via :meth:`path_for`), and any
32
+ upstream sources that were loaded for the same cell as declared on
33
+ :attr:`DataSource.requires`.
34
+
35
+ For backward parity with previous releases, when ``"dataqueue"`` is
36
+ present in :attr:`dependencies`, the convenience attributes
37
+ :attr:`dataqueue_frame`, :attr:`dataqueue_meta`, :attr:`master_timeline`,
38
+ and :attr:`experiment_window` are populated from it. New sources should
39
+ prefer reading from :attr:`dependencies` directly.
40
+ """
41
+
42
+ subject: str
43
+ session: str
44
+ task: str | None
45
+ inventory_row: Mapping[str, Any]
46
+ dependencies: Mapping[str, "LoadedStream | None"] = field(default_factory=dict)
47
+ master_timeline: np.ndarray | None = None
48
+ experiment_window: tuple[float, float] | None = None
49
+ dataqueue_frame: pd.DataFrame | None = None
50
+ dataqueue_meta: Mapping[str, Any] | None = None
51
+
52
+ def path_for(self, tag: str) -> Path | None:
53
+ """Return the path stored in the inventory row for ``tag``, or None."""
54
+ value = self.inventory_row.get(tag)
55
+ if value is None:
56
+ return None
57
+ try:
58
+ if pd.isna(value):
59
+ return None
60
+ except (TypeError, ValueError):
61
+ pass
62
+ return Path(str(value))
63
+
64
+ def require_path(self, tag: str) -> Path:
65
+ """Like :meth:`path_for` but raises ``FileNotFoundError`` when missing."""
66
+ path = self.path_for(tag)
67
+ if path is None:
68
+ raise FileNotFoundError(
69
+ f"Missing '{tag}' path for ({self.subject}, {self.session}, {self.task})"
70
+ )
71
+ return path
72
+
73
+ def get_dependency(self, tag: str) -> "LoadedStream | None":
74
+ """Return a previously-loaded dependency stream, or None if unavailable."""
75
+ return self.dependencies.get(tag)
76
+
77
+ def require_dependency(self, tag: str) -> "LoadedStream":
78
+ """Like :meth:`get_dependency` but raises if the dependency is missing."""
79
+ dep = self.dependencies.get(tag)
80
+ if dep is None:
81
+ raise RuntimeError(
82
+ f"Missing required dependency '{tag}' for "
83
+ f"({self.subject}, {self.session}, {self.task})"
84
+ )
85
+ return dep
86
+
87
+
88
+ class DataSource:
89
+ """Base class for a file-backed data source."""
90
+
91
+ tag: ClassVar[str]
92
+ patterns: ClassVar[Iterable[str]]
93
+ camera_tag: ClassVar[str | None] = None
94
+ is_timeseries: ClassVar[bool] = True
95
+ flatten_payload: ClassVar[bool] = True
96
+ requires: ClassVar[Tuple[str, ...]] = ()
97
+ """Tag names of upstream sources whose loaded streams should be made
98
+ available via ``LoadContext.dependencies``. Soft contract: a missing or
99
+ failed dependency yields ``None`` in ``dependencies[tag]``; sources are
100
+ responsible for either degrading gracefully or raising."""
101
+
102
+ def load(self, path: Path, *, context: LoadContext | None = None) -> LoadedStream:
103
+ """Load data from the given path."""
104
+ raise NotImplementedError(f"{self.__class__.__name__} must implement load()")
105
+
106
+ def _require_context(self, context: LoadContext | None) -> LoadContext:
107
+ if context is None:
108
+ raise ValueError(
109
+ f"{self.__class__.__name__} requires a LoadContext; call through Dataset"
110
+ )
111
+ return context
112
+
113
+ def _decorate_meta(self, meta: Optional[Dict[str, Any]] = None, *, is_interval: bool = False) -> Dict[str, Any]:
114
+ meta_dict: Dict[str, Any] = dict(meta or {})
115
+ if self.camera_tag is not None:
116
+ meta_dict.setdefault(settings.sources.meta_camera_key, self.camera_tag)
117
+ meta_dict.setdefault(settings.sources.meta_timeseries_key, self.is_timeseries)
118
+ meta_dict.setdefault(settings.sources.meta_source_key, self.tag)
119
+ if is_interval:
120
+ meta_dict.setdefault(settings.sources.meta_interval_key, True)
121
+ docs = {
122
+ name: inspect.getdoc(attr)
123
+ for name, attr in inspect.getmembers(type(self), callable)
124
+ if getattr(attr, _DOCUMENT_FLAG, False)
125
+ }
126
+ if docs:
127
+ meta_dict.setdefault("docs", docs)
128
+ return meta_dict
129
+
130
+
131
+ class TimeseriesSource(DataSource):
132
+ """Base class for time-indexed sources."""
133
+
134
+ is_timeseries: ClassVar[bool] = True
135
+
136
+ def build_timeseries(
137
+ self,
138
+ path: Path,
139
+ *,
140
+ context: LoadContext | None = None,
141
+ ) -> tuple[np.ndarray, Any, Dict[str, Any]]:
142
+ """Return (timeline, value, meta)."""
143
+ raise NotImplementedError(f"{self.__class__.__name__} must implement build_timeseries()")
144
+
145
+ def load(self, path: Path, *, context: LoadContext | None = None) -> LoadedStream:
146
+ t, value, meta = self.build_timeseries(path, context=context)
147
+ timeline = np.asarray(t, dtype=np.float64)
148
+ return LoadedStream(tag=self.tag, t=timeline, value=value, meta=self._decorate_meta(meta))
149
+
150
+
151
+ class TableSource(DataSource):
152
+ """Base class for static table sources."""
153
+
154
+ is_timeseries: ClassVar[bool] = False
155
+
156
+ def build_table(
157
+ self,
158
+ path: Path,
159
+ *,
160
+ context: LoadContext | None = None,
161
+ ) -> tuple[np.ndarray, Any, Dict[str, Any]]:
162
+ """Return (timeline, value, meta)."""
163
+ raise NotImplementedError(f"{self.__class__.__name__} must implement build_table()")
164
+
165
+ def load(self, path: Path, *, context: LoadContext | None = None) -> LoadedStream:
166
+ t, value, meta = self.build_table(path, context=context)
167
+ timeline = np.asarray(t, dtype=np.float64)
168
+ return LoadedStream(tag=self.tag, t=timeline, value=value, meta=self._decorate_meta(meta))
169
+
170
+
171
+ class IntervalSeriesSource(DataSource):
172
+ """Base class for interval-based sources."""
173
+
174
+ is_timeseries: ClassVar[bool] = True
175
+
176
+ def build_intervals(
177
+ self,
178
+ path: Path,
179
+ *,
180
+ context: LoadContext | None = None,
181
+ ) -> tuple[pd.DataFrame, Dict[str, Any]]:
182
+ """Return an intervals table with start/stop columns and meta."""
183
+ raise NotImplementedError(f"{self.__class__.__name__} must implement build_intervals()")
184
+
185
+ def load(self, path: Path, *, context: LoadContext | None = None) -> LoadedStream:
186
+ intervals, meta = self.build_intervals(path, context=context)
187
+ if "start_s" not in intervals.columns or "stop_s" not in intervals.columns:
188
+ raise ValueError("Intervals must include 'start_s' and 'stop_s' columns")
189
+ timeline = pd.to_numeric(intervals["start_s"], errors="coerce").to_numpy(dtype=np.float64)
190
+ return LoadedStream(
191
+ tag=self.tag,
192
+ t=timeline,
193
+ value=intervals,
194
+ meta=self._decorate_meta(meta, is_interval=True),
195
+ )
196
+
197
+
198
+ __all__ = [
199
+ "LoadContext",
200
+ "DataSource",
201
+ "TimeseriesSource",
202
+ "TableSource",
203
+ "IntervalSeriesSource",
204
+ ]
@@ -0,0 +1,130 @@
1
+ """Session configuration data source."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, ClassVar, Dict, Iterable, Mapping
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ from mesofield.datakit.sources.register import LoadContext, TableSource
12
+ from mesofield.datakit.config import settings
13
+
14
+
15
+ def _clean_config_value(value: Any) -> Any:
16
+ """Normalize configuration values for metadata storage."""
17
+
18
+ if isinstance(value, str):
19
+ stripped = value.strip()
20
+ if not stripped:
21
+ return None
22
+ lowered = stripped.lower()
23
+ if lowered in {"nan", "na", "none", "null"}:
24
+ return None
25
+ return stripped
26
+
27
+ if value is None:
28
+ return None
29
+
30
+ if isinstance(value, (np.floating,)):
31
+ if np.isnan(value):
32
+ return None
33
+ return float(value)
34
+
35
+ if isinstance(value, (np.integer,)):
36
+ return int(value)
37
+
38
+ if isinstance(value, float) and np.isnan(value):
39
+ return None
40
+
41
+ return value
42
+
43
+
44
+
45
+ class SessionConfigSource(TableSource):
46
+ """Load configuration CSVs and surface subject/session attributes."""
47
+
48
+ tag = "session_config"
49
+ patterns = ("**/*_configuration.csv",)
50
+ camera_tag = None
51
+ flatten_payload = True
52
+
53
+ DEFAULT_VARIABLE_MAPPING: ClassVar[Dict[str, tuple[str, ...]]] = {
54
+ "subject": ("sex", "genotype", "DOS", "DOB"),
55
+ "session": ("weight", "XYZ", "PT"),
56
+ }
57
+ variable_mapping: ClassVar[Dict[str, tuple[str, ...]]] = DEFAULT_VARIABLE_MAPPING.copy()
58
+
59
+
60
+ def build_table(
61
+ self,
62
+ path: Path,
63
+ *,
64
+ context: LoadContext | None = None,
65
+ ) -> tuple[np.ndarray, pd.DataFrame, dict]:
66
+ raw_df = pd.read_csv(path)
67
+
68
+ required_columns = {"Parameter", "Value"}
69
+ if not required_columns.issubset(raw_df.columns):
70
+ missing = ", ".join(sorted(required_columns - set(raw_df.columns)))
71
+ raise ValueError(f"Configuration CSV missing required columns: {missing}")
72
+
73
+ df = raw_df.set_index("Parameter").T
74
+
75
+ # Config is static - single timepoint
76
+ t = np.arange(len(df), dtype=float)
77
+
78
+ # Normalize parameter values for metadata usage
79
+ values_row = df.iloc[0] if not df.empty else pd.Series(dtype=object)
80
+ parameter_values = {
81
+ str(col).strip(): _clean_config_value(values_row[col])
82
+ for col in values_row.index
83
+ }
84
+
85
+ mapping = self.variable_mapping or {}
86
+ subject_keys = mapping.get("subject", ())
87
+ session_keys = mapping.get("session", ())
88
+
89
+ subject_variables = {
90
+ key: parameter_values[key]
91
+ for key in subject_keys
92
+ if key in parameter_values and parameter_values[key] is not None
93
+ }
94
+ session_variables = {
95
+ key: parameter_values[key]
96
+ for key in session_keys
97
+ if key in parameter_values and parameter_values[key] is not None
98
+ }
99
+
100
+ dedupe_keys = set(subject_variables) | set(session_variables) | {"subject", "session"}
101
+ parameter_values = {
102
+ key: value for key, value in parameter_values.items() if key not in dedupe_keys
103
+ }
104
+
105
+ mapped_keys = set(subject_variables) | set(session_variables)
106
+ remaining_variables = {
107
+ key: value
108
+ for key, value in parameter_values.items()
109
+ if key not in mapped_keys and value is not None
110
+ }
111
+
112
+ flattened_scope = {
113
+ **subject_variables,
114
+ **session_variables,
115
+ **remaining_variables,
116
+ }
117
+
118
+ meta = {
119
+ "source_file": str(path),
120
+ "n_params": len(parameter_values),
121
+ "parameter_values": parameter_values,
122
+ "subject_variables": subject_variables,
123
+ "session_variables": session_variables,
124
+ "unmapped_variables": remaining_variables,
125
+ "variable_mapping": {scope: list(keys) for scope, keys in mapping.items()},
126
+ "scope": settings.dataset.session_scope,
127
+ **flattened_scope,
128
+ }
129
+
130
+ return t, df, meta
@@ -0,0 +1,63 @@
1
+ """Session notes data source.
2
+
3
+ Parses free-form ``*_notes.txt`` files where each line begins with a timestamp
4
+ followed by a colon and a short message. The result is a simple dataframe with
5
+ an index-aligned ``time_elapsed_s`` axis so that manual observations can be
6
+ plotted alongside other timeseries.
7
+ """
8
+
9
+ import pandas as pd
10
+ import numpy as np
11
+ from pathlib import Path
12
+ import re
13
+ from datetime import datetime
14
+
15
+ from mesofield.datakit.sources.register import LoadContext, TimeseriesSource
16
+
17
+
18
+ class SessionNotesSource(TimeseriesSource):
19
+ """Load timestamped notes recorded by the experimenter."""
20
+ tag = "notes"
21
+ patterns = ("**/*_notes.txt",)
22
+ camera_tag = None
23
+ flatten_payload = False
24
+ line_pattern = r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}):\s*(.*)"
25
+ timestamp_format = "%Y-%m-%d %H:%M:%S"
26
+ empty_time_value = 0.0
27
+
28
+ def build_timeseries(
29
+ self,
30
+ path: Path,
31
+ *,
32
+ context: LoadContext | None = None,
33
+ ) -> tuple[np.ndarray, pd.DataFrame, dict]:
34
+ """Parse ``*_notes.txt`` into a timeline-aware dataframe."""
35
+ with open(path, 'r') as f:
36
+ notes = f.readlines()
37
+
38
+ timestamps = []
39
+ note_texts = []
40
+
41
+ for line in notes:
42
+ if not line.strip():
43
+ continue
44
+
45
+ match = re.match(self.line_pattern, line.strip())
46
+ if match:
47
+ timestamp_str, note_text = match.groups()
48
+ try:
49
+ timestamp = datetime.strptime(timestamp_str, self.timestamp_format)
50
+ timestamps.append(timestamp)
51
+ note_texts.append(note_text)
52
+ except ValueError:
53
+ continue
54
+
55
+ if timestamps:
56
+ # Convert to seconds relative to first note
57
+ t = np.array([(ts - timestamps[0]).total_seconds() for ts in timestamps])
58
+ df = pd.DataFrame({'timestamp': timestamps, 'note': note_texts})
59
+ else:
60
+ t = np.array([self.empty_time_value])
61
+ df = pd.DataFrame({'timestamp': [], 'note': []})
62
+
63
+ return t.astype(np.float64), df, {"source_file": str(path), "n_notes": len(note_texts)}
@@ -0,0 +1,58 @@
1
+ """Session timing markers data source.
2
+
3
+ Transforms a CSV of per-device ``started``/``stopped`` timestamps into a static
4
+ stream that downstream tooling can inspect alongside other metadata sources.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ import pandas as pd
12
+
13
+ from mesofield.datakit.sources.register import IntervalSeriesSource, LoadContext
14
+
15
+
16
+ class SessionTimestampsSource(IntervalSeriesSource):
17
+ """Load per-device start/stop timestamps as intervals."""
18
+
19
+ tag = "timestamps"
20
+ patterns = ("**/*_timestamps.csv",)
21
+ camera_tag = None
22
+
23
+ required_columns = ("device_id", "started", "stopped")
24
+
25
+ def build_intervals(
26
+ self,
27
+ path: Path,
28
+ *,
29
+ context: LoadContext | None = None,
30
+ ) -> tuple[pd.DataFrame, dict]:
31
+ frame = pd.read_csv(path)
32
+
33
+ missing = [col for col in self.required_columns if col not in frame.columns]
34
+ if missing:
35
+ raise ValueError(f"Timestamps CSV missing required columns {missing}: {path}")
36
+
37
+ normalized = frame.copy()
38
+ for column in ("started", "stopped"):
39
+ normalized[column] = pd.to_datetime(normalized[column], errors="coerce")
40
+
41
+ if normalized["started"].notna().any():
42
+ origin = normalized["started"].min()
43
+ else:
44
+ origin = pd.Timestamp(0)
45
+
46
+ normalized["start_s"] = (normalized["started"] - origin).dt.total_seconds()
47
+ normalized["stop_s"] = (normalized["stopped"] - origin).dt.total_seconds()
48
+
49
+ meta = {
50
+ "source_file": str(path),
51
+ "devices": normalized["device_id"].dropna().astype(str).tolist(),
52
+ "time_basis": "timestamps_relative",
53
+ }
54
+
55
+ return normalized, meta
56
+
57
+
58
+ __all__ = ["SessionTimestampsSource"]