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.
- docs/_static/custom.css +40 -0
- docs/_static/favicon.png +0 -0
- docs/_static/logo.png +0 -0
- docs/api/index.md +70 -0
- docs/conf.py +200 -0
- docs/developer_guide.md +303 -0
- docs/index.md +25 -0
- docs/tutorial.md +4 -0
- docs/user_guide.md +172 -0
- examples/teensy_pulse_generator.py +320 -0
- experiments/pipeline_demo/experiment.json +24 -0
- experiments/pipeline_demo/hardware.yaml +23 -0
- experiments/pipeline_demo/procedure.py +50 -0
- experiments/two_cam_demo/experiment.json +24 -0
- experiments/two_cam_demo/hardware.yaml +58 -0
- experiments/two_cam_demo/load_dataset.py +213 -0
- experiments/two_cam_demo/procedure.py +87 -0
- external/video-codecs/openh264-1.8.0-win64.dll +0 -0
- mesofield/__init__.py +45 -0
- mesofield/__main__.py +11 -0
- mesofield/_version.py +24 -0
- mesofield/base.py +750 -0
- mesofield/cli/__init__.py +57 -0
- mesofield/cli/_richhelp.py +100 -0
- mesofield/cli/acquire.py +254 -0
- mesofield/cli/datakit.py +165 -0
- mesofield/cli/process.py +376 -0
- mesofield/cli/rig.py +108 -0
- mesofield/cli/tools.py +347 -0
- mesofield/config.py +751 -0
- mesofield/data/__init__.py +23 -0
- mesofield/data/batch.py +633 -0
- mesofield/data/manager.py +388 -0
- mesofield/data/writer.py +289 -0
- mesofield/datakit/__init__.py +44 -0
- mesofield/datakit/__main__.py +35 -0
- mesofield/datakit/_utils/_logger.py +5 -0
- mesofield/datakit/_version.py +141 -0
- mesofield/datakit/config.py +50 -0
- mesofield/datakit/core.py +783 -0
- mesofield/datakit/datamodel.py +200 -0
- mesofield/datakit/discover.py +124 -0
- mesofield/datakit/explore.py +651 -0
- mesofield/datakit/notebooks/pupil_dlc.ipynb +2445 -0
- mesofield/datakit/profile.py +535 -0
- mesofield/datakit/shell.py +83 -0
- mesofield/datakit/sources/__init__.py +65 -0
- mesofield/datakit/sources/analysis/mesomap.py +194 -0
- mesofield/datakit/sources/analysis/mesoscope.py +77 -0
- mesofield/datakit/sources/analysis/pupil.py +246 -0
- mesofield/datakit/sources/behavior/__init__.py +0 -0
- mesofield/datakit/sources/behavior/dataqueue.py +281 -0
- mesofield/datakit/sources/behavior/psychopy.py +364 -0
- mesofield/datakit/sources/behavior/treadmill.py +323 -0
- mesofield/datakit/sources/behavior/wheel.py +277 -0
- mesofield/datakit/sources/camera/mesoscope.py +32 -0
- mesofield/datakit/sources/camera/metadata_json.py +130 -0
- mesofield/datakit/sources/camera/pupil.py +28 -0
- mesofield/datakit/sources/camera/suite2p.py +547 -0
- mesofield/datakit/sources/register.py +204 -0
- mesofield/datakit/sources/session/config.py +130 -0
- mesofield/datakit/sources/session/notes.py +63 -0
- mesofield/datakit/sources/session/timestamps.py +58 -0
- mesofield/datakit/timeline.py +306 -0
- mesofield/devices/__init__.py +42 -0
- mesofield/devices/base.py +498 -0
- mesofield/devices/base_camera.py +295 -0
- mesofield/devices/cameras.py +740 -0
- mesofield/devices/daq.py +151 -0
- mesofield/devices/encoder.py +384 -0
- mesofield/devices/mocks.py +275 -0
- mesofield/devices/psychopy_device.py +455 -0
- mesofield/devices/subprocesses/__init__.py +0 -0
- mesofield/devices/subprocesses/psychopy.py +133 -0
- mesofield/devices/treadmill.py +318 -0
- mesofield/engines.py +380 -0
- mesofield/gui/Mesofield_icon.png +0 -0
- mesofield/gui/__init__.py +76 -0
- mesofield/gui/config_wizard.py +724 -0
- mesofield/gui/controller.py +535 -0
- mesofield/gui/dynamic_controller.py +78 -0
- mesofield/gui/maingui.py +427 -0
- mesofield/gui/mdagui.py +285 -0
- mesofield/gui/qt_device_adapter.py +109 -0
- mesofield/gui/speedplotter.py +152 -0
- mesofield/gui/theme.py +445 -0
- mesofield/gui/tiff_viewer.py +1050 -0
- mesofield/gui/viewer.py +691 -0
- mesofield/hardware.py +549 -0
- mesofield/playback.py +1298 -0
- mesofield/processing/__init__.py +12 -0
- mesofield/processing/runner.py +237 -0
- mesofield/processors/__init__.py +13 -0
- mesofield/processors/base.py +287 -0
- mesofield/processors/frame_mean.py +19 -0
- mesofield/protocols.py +378 -0
- mesofield/scaffold/__init__.py +34 -0
- mesofield/scaffold/experiment.py +400 -0
- mesofield/scaffold/rigs.py +121 -0
- mesofield/signals.py +85 -0
- mesofield/utils/__init__.py +0 -0
- mesofield/utils/_logger.py +156 -0
- mesofield/utils/retrofit.py +309 -0
- mesofield/utils/utils.py +217 -0
- mesofield-0.3.2b0.dist-info/METADATA +178 -0
- mesofield-0.3.2b0.dist-info/RECORD +111 -0
- mesofield-0.3.2b0.dist-info/WHEEL +5 -0
- mesofield-0.3.2b0.dist-info/entry_points.txt +2 -0
- mesofield-0.3.2b0.dist-info/licenses/LICENSE +21 -0
- mesofield-0.3.2b0.dist-info/top_level.txt +6 -0
- 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"]
|