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.
Files changed (80) hide show
  1. sfm_analysis-0.1.0/LICENSE +21 -0
  2. sfm_analysis-0.1.0/PKG-INFO +434 -0
  3. sfm_analysis-0.1.0/README.md +402 -0
  4. sfm_analysis-0.1.0/pyproject.toml +68 -0
  5. sfm_analysis-0.1.0/setup.cfg +4 -0
  6. sfm_analysis-0.1.0/src/sfm_analysis/__init__.py +18 -0
  7. sfm_analysis-0.1.0/src/sfm_analysis/analysis/__init__.py +50 -0
  8. sfm_analysis-0.1.0/src/sfm_analysis/analysis/intervals.py +149 -0
  9. sfm_analysis-0.1.0/src/sfm_analysis/analysis/session.py +127 -0
  10. sfm_analysis-0.1.0/src/sfm_analysis/analysis/tables.py +203 -0
  11. sfm_analysis-0.1.0/src/sfm_analysis/cli/__init__.py +1 -0
  12. sfm_analysis-0.1.0/src/sfm_analysis/cli/report.py +309 -0
  13. sfm_analysis-0.1.0/src/sfm_analysis/logs.py +53 -0
  14. sfm_analysis-0.1.0/src/sfm_analysis/protocol.py +564 -0
  15. sfm_analysis-0.1.0/src/sfm_analysis/py.typed +0 -0
  16. sfm_analysis-0.1.0/src/sfm_analysis/report/__init__.py +137 -0
  17. sfm_analysis-0.1.0/src/sfm_analysis/report/align.py +49 -0
  18. sfm_analysis-0.1.0/src/sfm_analysis/report/analyses/__init__.py +6 -0
  19. sfm_analysis-0.1.0/src/sfm_analysis/report/analyses/bandit.py +354 -0
  20. sfm_analysis-0.1.0/src/sfm_analysis/report/analyses/fixed_random.py +79 -0
  21. sfm_analysis-0.1.0/src/sfm_analysis/report/analyses/free_feeding.py +86 -0
  22. sfm_analysis-0.1.0/src/sfm_analysis/report/analyses/probability.py +62 -0
  23. sfm_analysis-0.1.0/src/sfm_analysis/report/charts.py +694 -0
  24. sfm_analysis-0.1.0/src/sfm_analysis/report/demo/Bandit_test_01.csv +522 -0
  25. sfm_analysis-0.1.0/src/sfm_analysis/report/demo/Bandit_test_01_heartbeats.csv +45 -0
  26. sfm_analysis-0.1.0/src/sfm_analysis/report/demo/__init__.py +40 -0
  27. sfm_analysis-0.1.0/src/sfm_analysis/report/designs/__init__.py +12 -0
  28. sfm_analysis-0.1.0/src/sfm_analysis/report/designs/default.json +30 -0
  29. sfm_analysis-0.1.0/src/sfm_analysis/report/designs/fixed_and_random.json +29 -0
  30. sfm_analysis-0.1.0/src/sfm_analysis/report/designs/free_feeding.json +30 -0
  31. sfm_analysis-0.1.0/src/sfm_analysis/report/designs/probability_delivery.json +29 -0
  32. sfm_analysis-0.1.0/src/sfm_analysis/report/designs/two_armed_bandit.json +39 -0
  33. sfm_analysis-0.1.0/src/sfm_analysis/report/explorer.py +101 -0
  34. sfm_analysis-0.1.0/src/sfm_analysis/report/explorer_render.py +576 -0
  35. sfm_analysis-0.1.0/src/sfm_analysis/report/loader.py +311 -0
  36. sfm_analysis-0.1.0/src/sfm_analysis/report/metrics.py +647 -0
  37. sfm_analysis-0.1.0/src/sfm_analysis/report/naming.py +137 -0
  38. sfm_analysis-0.1.0/src/sfm_analysis/report/render.py +169 -0
  39. sfm_analysis-0.1.0/src/sfm_analysis/report/schema.py +236 -0
  40. sfm_analysis-0.1.0/src/sfm_analysis/report/sections/__init__.py +8 -0
  41. sfm_analysis-0.1.0/src/sfm_analysis/report/sections/bandit.py +224 -0
  42. sfm_analysis-0.1.0/src/sfm_analysis/report/sections/compare.py +278 -0
  43. sfm_analysis-0.1.0/src/sfm_analysis/report/sections/fixed_random.py +90 -0
  44. sfm_analysis-0.1.0/src/sfm_analysis/report/sections/free_feeding.py +107 -0
  45. sfm_analysis-0.1.0/src/sfm_analysis/report/sections/generic.py +384 -0
  46. sfm_analysis-0.1.0/src/sfm_analysis/report/sections/probability.py +100 -0
  47. sfm_analysis-0.1.0/src/sfm_analysis/report/sections/timeline.py +294 -0
  48. sfm_analysis-0.1.0/src/sfm_analysis/report/session.py +195 -0
  49. sfm_analysis-0.1.0/src/sfm_analysis/report/stats.py +158 -0
  50. sfm_analysis-0.1.0/src/sfm_analysis/report/style.py +237 -0
  51. sfm_analysis-0.1.0/src/sfm_analysis/report/timeline_data.py +143 -0
  52. sfm_analysis-0.1.0/src/sfm_analysis/report/timezones.py +85 -0
  53. sfm_analysis-0.1.0/src/sfm_analysis/storage.py +81 -0
  54. sfm_analysis-0.1.0/src/sfm_analysis/targeting.py +54 -0
  55. sfm_analysis-0.1.0/src/sfm_analysis.egg-info/PKG-INFO +434 -0
  56. sfm_analysis-0.1.0/src/sfm_analysis.egg-info/SOURCES.txt +78 -0
  57. sfm_analysis-0.1.0/src/sfm_analysis.egg-info/dependency_links.txt +1 -0
  58. sfm_analysis-0.1.0/src/sfm_analysis.egg-info/entry_points.txt +2 -0
  59. sfm_analysis-0.1.0/src/sfm_analysis.egg-info/requires.txt +9 -0
  60. sfm_analysis-0.1.0/src/sfm_analysis.egg-info/top_level.txt +1 -0
  61. sfm_analysis-0.1.0/tests/test_analysis_intervals.py +134 -0
  62. sfm_analysis-0.1.0/tests/test_analysis_session.py +68 -0
  63. sfm_analysis-0.1.0/tests/test_analysis_tables.py +103 -0
  64. sfm_analysis-0.1.0/tests/test_cli_report.py +191 -0
  65. sfm_analysis-0.1.0/tests/test_examples.py +53 -0
  66. sfm_analysis-0.1.0/tests/test_report_bandit.py +160 -0
  67. sfm_analysis-0.1.0/tests/test_report_charts.py +131 -0
  68. sfm_analysis-0.1.0/tests/test_report_compare.py +137 -0
  69. sfm_analysis-0.1.0/tests/test_report_explorer.py +317 -0
  70. sfm_analysis-0.1.0/tests/test_report_loader.py +228 -0
  71. sfm_analysis-0.1.0/tests/test_report_metrics.py +304 -0
  72. sfm_analysis-0.1.0/tests/test_report_naming.py +84 -0
  73. sfm_analysis-0.1.0/tests/test_report_other_experiments.py +143 -0
  74. sfm_analysis-0.1.0/tests/test_report_render.py +145 -0
  75. sfm_analysis-0.1.0/tests/test_report_schema.py +113 -0
  76. sfm_analysis-0.1.0/tests/test_report_smoke.py +148 -0
  77. sfm_analysis-0.1.0/tests/test_report_timeline.py +161 -0
  78. sfm_analysis-0.1.0/tests/test_report_timezones.py +106 -0
  79. sfm_analysis-0.1.0/tests/test_targeting.py +37 -0
  80. 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.