sfm-analysis 0.1.0__tar.gz
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.
- sfm_analysis-0.1.0/LICENSE +21 -0
- sfm_analysis-0.1.0/PKG-INFO +434 -0
- sfm_analysis-0.1.0/README.md +402 -0
- sfm_analysis-0.1.0/pyproject.toml +68 -0
- sfm_analysis-0.1.0/setup.cfg +4 -0
- sfm_analysis-0.1.0/src/sfm_analysis/__init__.py +18 -0
- sfm_analysis-0.1.0/src/sfm_analysis/analysis/__init__.py +50 -0
- sfm_analysis-0.1.0/src/sfm_analysis/analysis/intervals.py +149 -0
- sfm_analysis-0.1.0/src/sfm_analysis/analysis/session.py +127 -0
- sfm_analysis-0.1.0/src/sfm_analysis/analysis/tables.py +203 -0
- sfm_analysis-0.1.0/src/sfm_analysis/cli/__init__.py +1 -0
- sfm_analysis-0.1.0/src/sfm_analysis/cli/report.py +309 -0
- sfm_analysis-0.1.0/src/sfm_analysis/logs.py +53 -0
- sfm_analysis-0.1.0/src/sfm_analysis/protocol.py +564 -0
- sfm_analysis-0.1.0/src/sfm_analysis/py.typed +0 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/__init__.py +137 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/align.py +49 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/analyses/__init__.py +6 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/analyses/bandit.py +354 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/analyses/fixed_random.py +79 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/analyses/free_feeding.py +86 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/analyses/probability.py +62 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/charts.py +694 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/demo/Bandit_test_01.csv +522 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/demo/Bandit_test_01_heartbeats.csv +45 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/demo/__init__.py +40 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/designs/__init__.py +12 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/designs/default.json +30 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/designs/fixed_and_random.json +29 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/designs/free_feeding.json +30 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/designs/probability_delivery.json +29 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/designs/two_armed_bandit.json +39 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/explorer.py +101 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/explorer_render.py +576 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/loader.py +311 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/metrics.py +647 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/naming.py +137 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/render.py +169 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/schema.py +236 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/sections/__init__.py +8 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/sections/bandit.py +224 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/sections/compare.py +278 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/sections/fixed_random.py +90 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/sections/free_feeding.py +107 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/sections/generic.py +384 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/sections/probability.py +100 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/sections/timeline.py +294 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/session.py +195 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/stats.py +158 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/style.py +237 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/timeline_data.py +143 -0
- sfm_analysis-0.1.0/src/sfm_analysis/report/timezones.py +85 -0
- sfm_analysis-0.1.0/src/sfm_analysis/storage.py +81 -0
- sfm_analysis-0.1.0/src/sfm_analysis/targeting.py +54 -0
- sfm_analysis-0.1.0/src/sfm_analysis.egg-info/PKG-INFO +434 -0
- sfm_analysis-0.1.0/src/sfm_analysis.egg-info/SOURCES.txt +78 -0
- sfm_analysis-0.1.0/src/sfm_analysis.egg-info/dependency_links.txt +1 -0
- sfm_analysis-0.1.0/src/sfm_analysis.egg-info/entry_points.txt +2 -0
- sfm_analysis-0.1.0/src/sfm_analysis.egg-info/requires.txt +9 -0
- sfm_analysis-0.1.0/src/sfm_analysis.egg-info/top_level.txt +1 -0
- sfm_analysis-0.1.0/tests/test_analysis_intervals.py +134 -0
- sfm_analysis-0.1.0/tests/test_analysis_session.py +68 -0
- sfm_analysis-0.1.0/tests/test_analysis_tables.py +103 -0
- sfm_analysis-0.1.0/tests/test_cli_report.py +191 -0
- sfm_analysis-0.1.0/tests/test_examples.py +53 -0
- sfm_analysis-0.1.0/tests/test_report_bandit.py +160 -0
- sfm_analysis-0.1.0/tests/test_report_charts.py +131 -0
- sfm_analysis-0.1.0/tests/test_report_compare.py +137 -0
- sfm_analysis-0.1.0/tests/test_report_explorer.py +317 -0
- sfm_analysis-0.1.0/tests/test_report_loader.py +228 -0
- sfm_analysis-0.1.0/tests/test_report_metrics.py +304 -0
- sfm_analysis-0.1.0/tests/test_report_naming.py +84 -0
- sfm_analysis-0.1.0/tests/test_report_other_experiments.py +143 -0
- sfm_analysis-0.1.0/tests/test_report_render.py +145 -0
- sfm_analysis-0.1.0/tests/test_report_schema.py +113 -0
- sfm_analysis-0.1.0/tests/test_report_smoke.py +148 -0
- sfm_analysis-0.1.0/tests/test_report_timeline.py +161 -0
- sfm_analysis-0.1.0/tests/test_report_timezones.py +106 -0
- sfm_analysis-0.1.0/tests/test_targeting.py +37 -0
- sfm_analysis-0.1.0/tests/test_version.py +16 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Neurotech Hub
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sfm-analysis
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Analysis and printable HTML behavior reports for SFM/VFM feeder session logs
|
|
5
|
+
Author: Neurotech Hub
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Neurotech-Hub/VFM
|
|
8
|
+
Project-URL: Source, https://github.com/Neurotech-Hub/VFM/tree/main/packages/sfm-analysis
|
|
9
|
+
Project-URL: Issues, https://github.com/Neurotech-Hub/VFM/issues
|
|
10
|
+
Keywords: neuroscience,behavior,operant,feeder,CAN,reports
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Provides-Extra: pandas
|
|
26
|
+
Requires-Dist: pandas>=1.3; extra == "pandas"
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
29
|
+
Provides-Extra: all
|
|
30
|
+
Requires-Dist: sfm-analysis[pandas]; extra == "all"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# sfm-analysis
|
|
34
|
+
|
|
35
|
+
Cross-platform analysis and printable HTML behavior reports for
|
|
36
|
+
[VFM](https://github.com/Neurotech-Hub/VFM) / SFM feeder session logs.
|
|
37
|
+
Runs on Windows, macOS, and Linux — no Raspberry Pi, no hardware
|
|
38
|
+
libraries. Pure standard library: no jinja2, no pandas, no matplotlib. A
|
|
39
|
+
report is one self-contained HTML file with inline SVG charts, printable
|
|
40
|
+
via Ctrl+P.
|
|
41
|
+
|
|
42
|
+
This package is carved out of the VFM repository's `packages/dev_gui/`
|
|
43
|
+
(the Raspberry Pi base station that records the logs) precisely so that
|
|
44
|
+
analysis doesn't have to happen on the Pi. The base station's own
|
|
45
|
+
`run_report.py` is a thin wrapper around the CLI documented below.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install sfm-analysis
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Or, before the first PyPI release (or to track `main`):
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install "git+https://github.com/Neurotech-Hub/VFM.git#subdirectory=packages/sfm-analysis"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Quick start
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Try it right now, with no rig, no log directory, and no real data:
|
|
63
|
+
sfm-report --demo --open
|
|
64
|
+
|
|
65
|
+
# List every session found in the default log directory
|
|
66
|
+
# ($SFM_LOG_DIR, else an external drive's sfm_logs/, else ~/sfm_logs):
|
|
67
|
+
sfm-report --list
|
|
68
|
+
|
|
69
|
+
# One session, opened in your browser when done:
|
|
70
|
+
sfm-report EXP-Test-02 --open
|
|
71
|
+
|
|
72
|
+
# Every session for a cohort, combined into one comparative report:
|
|
73
|
+
sfm-report "cohortA_*" --combine -o /tmp/cohortA.html
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`--demo` renders a real, bundled two_armed_bandit session
|
|
77
|
+
(`sfm_analysis.report.demo`) — the fastest way to see what a report looks
|
|
78
|
+
like on a fresh install.
|
|
79
|
+
|
|
80
|
+
## The `sfm-report` CLI
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
sfm-report --help
|
|
84
|
+
|
|
85
|
+
target... Session name, shell glob, or path to a .csv (repeatable)
|
|
86
|
+
--log-dir Default: $SFM_LOG_DIR, else an external drive, else ~/sfm_logs
|
|
87
|
+
--list List discovered sessions and exit
|
|
88
|
+
--list-designs List report designs and exit
|
|
89
|
+
--check-names Show how each session name parses (subject/cohort/day) and exit
|
|
90
|
+
--combine One comparative report over all targets
|
|
91
|
+
--all Every session in --log-dir
|
|
92
|
+
--demo Render the bundled demo session (no rig or log dir needed)
|
|
93
|
+
--since, --until Filter by date (YYYY-MM-DD)
|
|
94
|
+
--run Only this run_id (a file can hold several)
|
|
95
|
+
--design Force a report design instead of resolving by experiment
|
|
96
|
+
--align Combined-report time alignment: relative | wall | trial | event:<name>
|
|
97
|
+
--out, -o Output file (single report) or directory (multiple)
|
|
98
|
+
--no-explorer Skip the embedded interactive timeline (guaranteed script-free, leaner output)
|
|
99
|
+
--open Open the result in your default browser when done
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
A **session** is one CSV written by the base station's unified 15-column
|
|
103
|
+
log format (`timestamp_iso, timestamp_ms, ..., fields_json, details`).
|
|
104
|
+
Files in an older schema are skipped with a warning rather than crashing
|
|
105
|
+
the run — see `report.loader.sniff_schema`.
|
|
106
|
+
|
|
107
|
+
## The interactive timeline
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
sfm-report EXP-Test-02 --open
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
is enough — every design embeds a real pan/zoom/brush-zoom timeline
|
|
114
|
+
directly in the one report file by default, right where the static
|
|
115
|
+
raster panels used to sit alone. It's the answer to "I don't want 12
|
|
116
|
+
stacked panels, I want to point at the timeline and zoom in there", and
|
|
117
|
+
it's the whole report you get from one self-contained `.html` you can
|
|
118
|
+
email or drop somewhere — never a pair of files that has to travel
|
|
119
|
+
together:
|
|
120
|
+
|
|
121
|
+
- **Drag on the main view** to brush-select a range and zoom into it.
|
|
122
|
+
- **Drag the overview strip** (the thin bar above the main view) to pan.
|
|
123
|
+
- **Scroll** to zoom in/out centered on the cursor.
|
|
124
|
+
- **Reset zoom** returns to the full run.
|
|
125
|
+
- Hovering shows the cursor's wall-clock time, time-of-day, and time
|
|
126
|
+
into the run, all three always visible — the wall-clock and time-of-
|
|
127
|
+
day readouts use the *recording rig's* local clock (the same
|
|
128
|
+
`timestamp_iso`-based reasoning as `report.timezones`), never the
|
|
129
|
+
browser's, regardless of what machine you're viewing it on.
|
|
130
|
+
|
|
131
|
+
It's drawn on `<canvas>`, not SVG — the same design decision as the
|
|
132
|
+
static report's panels would make a bad interactive widget at real data
|
|
133
|
+
volumes (a raster with tens of thousands of DOM nodes is what makes a
|
|
134
|
+
browser tab hang). The data itself — lanes, spans, marks — comes from
|
|
135
|
+
exactly the same `report.timeline_data.panel_marks_spans` the static
|
|
136
|
+
raster panels use, just for the whole run's `[0, duration]` window
|
|
137
|
+
instead of one paginated slice, so the two can never disagree about
|
|
138
|
+
what a "presence bout" or a "dispense cycle" is. Those static panels
|
|
139
|
+
don't disappear — they go **print-only**: hidden on screen (the
|
|
140
|
+
interactive view replaces them there), but still present for `Ctrl+P`,
|
|
141
|
+
since the printed PDF can't run the JS that draws the canvas.
|
|
142
|
+
|
|
143
|
+
`build_session_report(..., include_explorer=False)` — or `--no-explorer`
|
|
144
|
+
on the CLI — drops the embedded timeline (and its one inline `<script>`)
|
|
145
|
+
entirely, for a caller that wants the guaranteed-script-free,
|
|
146
|
+
leaner document. It's the only place in this package that ships
|
|
147
|
+
JavaScript — everything else, including the rest of the printed report,
|
|
148
|
+
is deliberately script-free. Self-containment is still the invariant
|
|
149
|
+
either way: no `http(s)://` reference anywhere, nothing that needs the
|
|
150
|
+
network to render or print.
|
|
151
|
+
|
|
152
|
+
## How a report is put together
|
|
153
|
+
|
|
154
|
+
A report **design** (`report/designs/<name>.json`) declares which
|
|
155
|
+
**sections** to render and in what order; sections are plain Python
|
|
156
|
+
functions registered under `report/sections/`. This mirrors the VFM
|
|
157
|
+
experiment engine's own JSON-schema/Python split (`base_station/experiment/`
|
|
158
|
+
in the parent repo — `matches: []` in a design plays the same role as
|
|
159
|
+
`build(**kwargs)` in an experiment template).
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
src/sfm_analysis/report/
|
|
163
|
+
├── designs/<name>.json # "matches": ["<experiment>"] + ordered refs
|
|
164
|
+
├── analyses/<name>.py # numbers (no HTML)
|
|
165
|
+
└── sections/<name>.py # HTML; SECTIONS = {"func": ...}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Copy-from starter: [`examples/report_design/`](examples/report_design/).
|
|
169
|
+
Full walkthrough: [docs/ANALYSIS_GUIDE.md](docs/ANALYSIS_GUIDE.md#9-turning-an-analysis-into-a-report-section).
|
|
170
|
+
|
|
171
|
+
The design is picked automatically from the session's `experiment` field
|
|
172
|
+
(the fields_json on its `session_start` row), falling back to
|
|
173
|
+
`default.json` for anything without a matching design or with no
|
|
174
|
+
`session_start` at all — see `resolve_design`. Force one explicitly with
|
|
175
|
+
`--design <name>` or `build_session_report(..., design=...)`.
|
|
176
|
+
|
|
177
|
+
Metrics (retrieval latency, presence bouts, dispense cycles, bandit
|
|
178
|
+
choice/WSLS/reversal, …) live in `report/analyses/` and
|
|
179
|
+
`report/metrics.py`, separate from the HTML that renders them — the
|
|
180
|
+
metrics are plain dataclasses, computed once per run
|
|
181
|
+
(`compute_run_metrics`) and handed to every section read-only. This is
|
|
182
|
+
what makes the numbers themselves testable without parsing HTML (see
|
|
183
|
+
`tests/test_report_metrics.py`, which never touches `render_report_html`
|
|
184
|
+
at all).
|
|
185
|
+
|
|
186
|
+
Add a new comparative view or per-experiment section by writing a
|
|
187
|
+
function that returns a `SectionResult` (`report/schema.py`) and adding
|
|
188
|
+
its `module.function` ref to the relevant `report/designs/*.json` — no
|
|
189
|
+
changes to the CLI or the render pipeline needed. A section that raises
|
|
190
|
+
never kills the whole report; it renders a visible error block with the
|
|
191
|
+
traceback instead (`schema.run_section`), so one broken section can't take
|
|
192
|
+
the rest of the report down with it.
|
|
193
|
+
|
|
194
|
+
### Long and multi-day sessions
|
|
195
|
+
|
|
196
|
+
`timeline.session_raster`'s detail-panel width (`window_s`) scales with
|
|
197
|
+
run duration by default — `max(600, duration/12)` — so a 24h run gets
|
|
198
|
+
about 12 panels instead of 144 stuck at a fixed 600s. Pin a fixed width
|
|
199
|
+
regardless of duration with `"options": {"window_s": ...}`; raise only
|
|
200
|
+
the short-run floor (e.g. free_feeding's slower cadence) with
|
|
201
|
+
`"options": {"min_window_s": ...}`. When `timeline.explorer` is also
|
|
202
|
+
active (the default — see "The interactive timeline" above), these
|
|
203
|
+
panels go print-only: hidden on screen since the embedded explorer
|
|
204
|
+
covers the same ground with real zoom, still present in the printed PDF.
|
|
205
|
+
|
|
206
|
+
`timeline.actogram` — one row per calendar day, time-of-day on the
|
|
207
|
+
x-axis, night-phase shaded (`lights_on`/`lights_off` options, default
|
|
208
|
+
06:00/18:00) — renders automatically whenever a run spans 2 or more
|
|
209
|
+
distinct days; it's the figure that matters for a multi-day experiment,
|
|
210
|
+
where session_raster's panels (even adaptively sized) stop being the
|
|
211
|
+
right tool. Absent entirely for shorter runs.
|
|
212
|
+
|
|
213
|
+
## Python API
|
|
214
|
+
|
|
215
|
+
For the full reference — every log column, every event name, every
|
|
216
|
+
`RunMetrics` field and its `None` semantics, every tidy-table column, and a
|
|
217
|
+
worked "question to tested answer" walkthrough — see
|
|
218
|
+
[docs/ANALYSIS_GUIDE.md](docs/ANALYSIS_GUIDE.md). What follows here is the
|
|
219
|
+
quick-start version.
|
|
220
|
+
|
|
221
|
+
### Custom analysis: `sfm_analysis.analysis`
|
|
222
|
+
|
|
223
|
+
This is the entry point for asking your own question of a session — one
|
|
224
|
+
import, tidy `list[dict]` tables, and a small generic interval algebra
|
|
225
|
+
for "was X happening while Y" questions, rather than five imports across
|
|
226
|
+
four modules or a bespoke query language.
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
from sfm_analysis.analysis import load_session
|
|
230
|
+
|
|
231
|
+
s = load_session("EXP-Test-02") # name, glob, or path — same resolution as the CLI
|
|
232
|
+
cycles = s.cycles_table() # list[dict], one row per dispense cycle
|
|
233
|
+
bouts = s.bouts_table() # presence / dome / fault intervals, unified
|
|
234
|
+
|
|
235
|
+
import pandas as pd
|
|
236
|
+
df = pd.DataFrame(cycles) # or: from sfm_analysis.analysis import to_dataframe
|
|
237
|
+
df.groupby("node")["retrieval_latency"].median()
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`load_session` resolves a target exactly the way `sfm-report` does
|
|
241
|
+
(`$SFM_LOG_DIR`, then an external drive, then `~/sfm_logs`, or pass
|
|
242
|
+
`log_dir=...`), loads the CSV, splits it into runs, and precomputes
|
|
243
|
+
metrics for every run once. If the file holds more than one run — a
|
|
244
|
+
9-row aborted restart followed by the real session, say — `s.run()`
|
|
245
|
+
picks the run with the most rows by default; pass `run_id=` anywhere to
|
|
246
|
+
be explicit instead.
|
|
247
|
+
|
|
248
|
+
**Tidy tables** (`s.cycles_table()`, `s.bouts_table()`, `s.events_table()`,
|
|
249
|
+
`s.trials_table()`) are the fastest way to get real freedom: every row is
|
|
250
|
+
a plain dict with the same keys, ready for `pandas.DataFrame`, `csv.DictWriter`,
|
|
251
|
+
or a plain list comprehension. `to_dataframe(rows)` is the explicit,
|
|
252
|
+
optional pandas handoff (`pip install sfm-analysis[pandas]`) — the core
|
|
253
|
+
package stays pandas-free.
|
|
254
|
+
|
|
255
|
+
**Interval algebra** (`sfm_analysis.analysis.intervals`) answers the
|
|
256
|
+
class of question tidy tables alone don't: `overlap`, `subtract`,
|
|
257
|
+
`merge`, `around`, `count_in`, `rate_in`, all plain functions over
|
|
258
|
+
`(t0, t1)` second-pairs.
|
|
259
|
+
|
|
260
|
+
| Question | One-liner |
|
|
261
|
+
|---|---|
|
|
262
|
+
| Windows where dome-open overlapped presence | `overlap(dome_iv, presence_iv)` |
|
|
263
|
+
| Takes within 30s after a fault started | `count_in(take_times, around(fault_starts, (0, 30)))` |
|
|
264
|
+
| Pellet-take rate during a time window | `rate_in(take_times, [(t0, t1)])` (takes/second) |
|
|
265
|
+
| Time NOT covered by any dome-open bout | `subtract([(0, run.duration_s)], dome_iv)` |
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
from sfm_analysis.analysis import intervals as iv
|
|
269
|
+
|
|
270
|
+
run = s.run()
|
|
271
|
+
m = s.metrics_for()
|
|
272
|
+
presence_iv = iv.as_intervals(m.presence[0], run_end=run.duration_s)
|
|
273
|
+
dome_iv = iv.as_intervals(m.dome[0], run_end=run.duration_s)
|
|
274
|
+
len(iv.overlap(dome_iv, presence_iv)) # dome-open bouts that overlapped presence
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`as_intervals` is the one deliberately strict step: a bout still open
|
|
278
|
+
when the run ended (`censored=True`) has `t1=None`, and this refuses to
|
|
279
|
+
guess whether that means zero-length or unbounded — pass `run_end=` to
|
|
280
|
+
resolve it explicitly, or filter censored bouts out yourself first.
|
|
281
|
+
|
|
282
|
+
### Lower-level: `sfm_analysis.report`
|
|
283
|
+
|
|
284
|
+
`sfm_analysis.analysis` is built entirely on public functions in
|
|
285
|
+
`sfm_analysis.report` — nothing is hidden from you, and dropping down is
|
|
286
|
+
one import away if you need something the curated API doesn't expose yet
|
|
287
|
+
(a report design's own analysis module, e.g. `report.analyses.bandit`,
|
|
288
|
+
for instance). This is also how the report itself gets built
|
|
289
|
+
programmatically:
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
from pathlib import Path
|
|
293
|
+
from sfm_analysis.report import build_session_report, build_combined_report, load_runs
|
|
294
|
+
|
|
295
|
+
build_session_report(Path("EXP-Test-02.csv"), out_path=Path("report.html"))
|
|
296
|
+
build_combined_report([Path("A.csv"), Path("B.csv")], design="two_armed_bandit")
|
|
297
|
+
|
|
298
|
+
runs = load_runs(Path("EXP-Test-02.csv")) # what load_session uses internally
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### Five things to know before trusting a number
|
|
302
|
+
|
|
303
|
+
Each of these is enforced or worked around somewhere in this codebase,
|
|
304
|
+
but none of it is obvious from the CSV alone — `sfm_analysis.analysis`
|
|
305
|
+
handles #1, #3, and #4 for you (via `Session`/`load_runs`,
|
|
306
|
+
`bouts_table`/`dome_bouts`, and `as_intervals`'s required `run_end`,
|
|
307
|
+
respectively), but they're worth understanding regardless of which layer
|
|
308
|
+
you work at:
|
|
309
|
+
|
|
310
|
+
1. **Scope everything to `(session, run_id)`.** A single CSV can hold
|
|
311
|
+
several runs — reopening a session name appends to the same file and
|
|
312
|
+
increments `run_id`. A cumulative count or an inter-trial interval that
|
|
313
|
+
crosses a run boundary is meaningless. `split_runs` is the one place
|
|
314
|
+
that groups rows this way; always work from its output, not the raw
|
|
315
|
+
`LogRow` list.
|
|
316
|
+
2. **Never read `elapsed_s` directly.** It's *run-relative* and resets to
|
|
317
|
+
0 every time a session is reopened. Use `RunData` rows' `.t`
|
|
318
|
+
(run-relative seconds recomputed from `timestamp_ms` by `split_runs`)
|
|
319
|
+
instead.
|
|
320
|
+
3. **`"Dome Opened"` is two different wire events.** The
|
|
321
|
+
`CanEvent.DomeOpened` milestone (raw byte 0 = `0x03`, carries a pellet
|
|
322
|
+
count) and a renamed `InputChanged` dome edge (raw byte 0 = `0x06`)
|
|
323
|
+
both render as the same `event_name`. Pairing opens/closes by name
|
|
324
|
+
alone double-counts every opening. Use `LogRow.can_event` to
|
|
325
|
+
disambiguate (see `metrics.dome_bouts`, which already does this dedup —
|
|
326
|
+
prefer it over rolling your own).
|
|
327
|
+
4. **A bout still open when the run ends is `censored`, not zero-length.**
|
|
328
|
+
`Bout.dur` is `None` for a censored bout on purpose. Filter deliberately
|
|
329
|
+
rather than letting a `None` silently become a `0` in an average.
|
|
330
|
+
5. **Two different "pellets presented" counts exist, on purpose.**
|
|
331
|
+
`PelletAccounting.presented` counts rows actually seen in the log;
|
|
332
|
+
`.presented_total` (what you almost always want) uses the base-station
|
|
333
|
+
ledger's gap-corrected total when available, which also counts pellets
|
|
334
|
+
whose own event frame never arrived. `.presented_total` /
|
|
335
|
+
`.taken_total` are the ledger-corrected numbers; the bare `.presented`
|
|
336
|
+
/ `.taken` fields are what a naive `by_event("Loaded")` count gets you.
|
|
337
|
+
|
|
338
|
+
## Recipes
|
|
339
|
+
|
|
340
|
+
`examples/analysis/` has runnable, commented scripts for each cookbook
|
|
341
|
+
question above — copy one as a starting point rather than writing from
|
|
342
|
+
scratch:
|
|
343
|
+
|
|
344
|
+
| Script | Question |
|
|
345
|
+
|---|---|
|
|
346
|
+
| [`dome_openings_during_presence.py`](examples/analysis/dome_openings_during_presence.py) | Windows where the dome was open while the mouse was present |
|
|
347
|
+
| [`retrieval_latency_by_node.py`](examples/analysis/retrieval_latency_by_node.py) | Per-node summary stats, stdlib-only and pandas paths side by side |
|
|
348
|
+
| [`takes_after_fault.py`](examples/analysis/takes_after_fault.py) | Pellet takes within a window after each fault interval started |
|
|
349
|
+
| [`exp_events_by_name.py`](examples/analysis/exp_events_by_name.py) | Inventory a session's experiment-engine (`source=EXP`) events — the starting point for analysing a custom template's own log rows |
|
|
350
|
+
|
|
351
|
+
Every one of them runs standalone against the bundled demo session, no
|
|
352
|
+
rig or `--log-dir` needed:
|
|
353
|
+
|
|
354
|
+
```bash
|
|
355
|
+
python examples/analysis/dome_openings_during_presence.py
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
They're also executed in CI (`tests/test_examples.py`), with their
|
|
359
|
+
output pinned against the demo session's known ground truth — a change
|
|
360
|
+
that breaks a documented recipe fails the build instead of being found
|
|
361
|
+
by the next person who copies one.
|
|
362
|
+
|
|
363
|
+
To ship a **custom report design** for your own experiment (JSON design +
|
|
364
|
+
Python analysis + HTML section), copy the three-file starter under
|
|
365
|
+
[`examples/report_design/`](examples/report_design/) into
|
|
366
|
+
`src/sfm_analysis/report/{designs,analyses,sections}/`. File layout and a
|
|
367
|
+
worked walkthrough: [docs/ANALYSIS_GUIDE.md](docs/ANALYSIS_GUIDE.md#9-turning-an-analysis-into-a-report-section).
|
|
368
|
+
|
|
369
|
+
## Testing your own analysis code
|
|
370
|
+
|
|
371
|
+
The test suite's own fixture builders are the fastest way to get a
|
|
372
|
+
synthetic session without a real rig — `tests/report_fixtures.py`:
|
|
373
|
+
|
|
374
|
+
```python
|
|
375
|
+
from report_fixtures import bandit_run, write_session
|
|
376
|
+
from sfm_analysis.report.loader import load_rows
|
|
377
|
+
from sfm_analysis.report.session import split_runs
|
|
378
|
+
|
|
379
|
+
def test_my_analysis(tmp_path):
|
|
380
|
+
rows = bandit_run(n_trials=5, session="synthetic")
|
|
381
|
+
csv_path = write_session(tmp_path, rows, session="synthetic")
|
|
382
|
+
loaded, _, _ = load_rows(csv_path)
|
|
383
|
+
runs = split_runs(loaded, [], csv_path)
|
|
384
|
+
# ... assert against my_analysis_fn(runs[0])
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
There's also a real field-recorded fixture bundled with the package
|
|
388
|
+
itself, `sfm_analysis.report.demo` (the same session `--demo` renders) —
|
|
389
|
+
a genuine two_armed_bandit session with a fault interval, a dome
|
|
390
|
+
milestone/edge dedup case, and post-`session_end` rows, independently
|
|
391
|
+
verified against the raw CSV (see `test_report_smoke.py`'s docstring for
|
|
392
|
+
the exact numbers):
|
|
393
|
+
|
|
394
|
+
```python
|
|
395
|
+
from sfm_analysis.analysis import load_session
|
|
396
|
+
from sfm_analysis.report.demo import DEMO_SESSION_PATH
|
|
397
|
+
|
|
398
|
+
s = load_session(str(DEMO_SESSION_PATH))
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
Point the smoke test at a different real file with
|
|
402
|
+
`SFM_SMOKE_CSV=/path/to/session.csv`.
|
|
403
|
+
|
|
404
|
+
```bash
|
|
405
|
+
cd packages/sfm-analysis
|
|
406
|
+
pip install -e ".[dev]"
|
|
407
|
+
pytest
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
## Design notes worth knowing
|
|
411
|
+
|
|
412
|
+
- **Self-contained is a hard invariant.** No `http(s)://` reference, no
|
|
413
|
+
`<script src=>`, nothing that needs the network to render or print
|
|
414
|
+
(`report/render.py`'s module docstring; enforced by
|
|
415
|
+
`tests/test_report_render.py`). The interactive timeline is the one
|
|
416
|
+
exception to "no script": at most one inline `<script>` in the whole
|
|
417
|
+
document. `--no-explorer` drops it for a guaranteed script-free file.
|
|
418
|
+
- **Every chart binds color + dash pattern + marker glyph + hatch texture
|
|
419
|
+
to the same ordinal "key"** (`report/style.py`), so a series never
|
|
420
|
+
depends on hue alone — this matters most exactly when a report is
|
|
421
|
+
printed in grayscale.
|
|
422
|
+
- **A section function must not touch the filesystem.** `SectionContext`
|
|
423
|
+
is built once and handed to every section read-only; all the data a
|
|
424
|
+
section could need is already on `ctx.runs` / `ctx.metrics`.
|
|
425
|
+
|
|
426
|
+
## Firmware/SDK version skew
|
|
427
|
+
|
|
428
|
+
`sfm_analysis.protocol` is a Python mirror of the VFM firmware's
|
|
429
|
+
`firmware/src/services/ServiceTypes.h` and `firmware/src/services/CanService.h` (parent
|
|
430
|
+
repo). Because this package now versions and ships independently of the
|
|
431
|
+
firmware, it's possible for a base station running old firmware to be
|
|
432
|
+
paired with a newer `sfm-analysis`, or vice versa. There's no version
|
|
433
|
+
check yet — if you're seeing CAN events fail to decode, check that the
|
|
434
|
+
firmware and this package were built from compatible commits.
|