diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index ea030bf..19a9e35 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -65,7 +65,7 @@ jobs: - run: pip install build twine - run: python -m build - run: twine check dist/* - - name: the 5 design JSONs and the demo CSVs must actually be inside the wheel + - name: the design JSONs and the demo CSVs must actually be inside the wheel run: | python - <<'PY' import glob, zipfile @@ -73,7 +73,8 @@ jobs: names = zipfile.ZipFile(whl).namelist() jsons = [n for n in names if n.startswith("sfm_analysis/report/designs/") and n.endswith(".json")] - assert len(jsons) == 5, (whl, jsons) + assert len(jsons) == 6, (whl, jsons) + assert any(n.endswith("actogram_takes.json") for n in jsons), jsons demo_csvs = [n for n in names if n.startswith("sfm_analysis/report/demo/") and n.endswith(".csv")] assert len(demo_csvs) == 2, (whl, demo_csvs) diff --git a/README.md b/README.md index ff5d48d..c6dac6b 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Report generation itself is cross-platform — it doesn't need a Raspberry Pi or ## About -VFM is a firmware library for the Spatial Foraging Platform node. It provides non-blocking, service-oriented stepper-driven pellet dispensing, CAN bus communication with a base-station command/event/heartbeat protocol. +VFM is a firmware library for the Spatial Foraging Platform node. It provides non-blocking, service-oriented stepper-driven pellet dispensing, and talks to the base station over **CAN** (Controller Area Network) — the shared communication bus every node is wired onto. A **CAN event** is a message a node posted on that bus (Loaded, Pellet Taken, Fault, …). The Arduino library is the `firmware/` folder (not the repo root). Copy or symlink `firmware/` into `Arduino/libraries/VFM`, or zip that folder and add it via *Sketch → Include Library → Add .ZIP Library…*. diff --git a/firmware/docs/DISPENSE_CYCLE.md b/firmware/docs/DISPENSE_CYCLE.md index 48ccd1b..0f930c2 100644 --- a/firmware/docs/DISPENSE_CYCLE.md +++ b/firmware/docs/DISPENSE_CYCLE.md @@ -193,7 +193,9 @@ still applies, since it is about motion, not the pellet. ## Events -Node → base on CAN ID `0x300 + nodeId`. Byte 0 is the event code. +A **CAN event** is a message the node posts on the CAN bus (the shared +communication wire every node is connected to). Node → base on CAN ID +`0x300 + nodeId`. Byte 0 is the event code. | Code | Event | Extra payload | Meaning | diff --git a/packages/dev_gui/README.md b/packages/dev_gui/README.md index aee036f..53de0c2 100644 --- a/packages/dev_gui/README.md +++ b/packages/dev_gui/README.md @@ -1,7 +1,11 @@ # SFM Developer GUI Python desktop application (DearPyGui) for the **Spatial Foraging Module (SFM)** — -a base station plus multiple **VFM** nodes — over the CAN bus on a Raspberry Pi 5. +a base station plus multiple **VFM** nodes on a Raspberry Pi 5. They share one +**CAN** bus (Controller Area Network): the communication wire all nodes are +connected to. A **CAN event** is a frame a node posted on that bus (Loaded, +Pellet Taken, Fault, sensor edge, …), as opposed to an experiment-engine +(`EXP`) row the GUI invented. ![SFM Developer GUI](docs/GUI.png) @@ -377,6 +381,11 @@ The base station keeps a dictionary of discovered modules in ## CAN frame reference +**CAN** is the shared communication bus every feeder node is connected to. +Commands go base → node; a **CAN event** is the opposite direction — a node +telling the bus (and therefore the base station) that something just happened +on that module. + | Direction | CAN ID | Content | |---------------|------------------|-------------------------------| | base → node | `0x100 + nodeId` | Command (Dispense, Recover, …) | diff --git a/packages/dev_gui/base_station/experiment/README.md b/packages/dev_gui/base_station/experiment/README.md index a83b441..22aab15 100644 --- a/packages/dev_gui/base_station/experiment/README.md +++ b/packages/dev_gui/base_station/experiment/README.md @@ -1,9 +1,11 @@ # Writing Custom Experiment Templates (Python API) This is the guide for authoring your own experiments for the SFM dev GUI. An -**experiment** automates the pellet-dispensing nodes over CAN: it decides *when* -to dispense, *which* node(s), and *how* to react to what the nodes and the BNC -sync inputs do. +**experiment** automates the pellet-dispensing nodes over **CAN** (Controller +Area Network) — the shared communication bus every node is connected to. It +decides *when* to dispense, *which* node(s), and *how* to react to **CAN +events** (messages a node posted on that bus: Loaded, Pellet Taken, Fault, …) +and to the BNC sync inputs. You write experiments in Python. There is **no base class to inherit** — a template is just a module with a `build(...)` factory that returns a configured diff --git a/packages/dev_gui/docs/BASE_STATION_HARDCODED_VALUES.md b/packages/dev_gui/docs/BASE_STATION_HARDCODED_VALUES.md index 281afe3..aa1d328 100644 --- a/packages/dev_gui/docs/BASE_STATION_HARDCODED_VALUES.md +++ b/packages/dev_gui/docs/BASE_STATION_HARDCODED_VALUES.md @@ -191,7 +191,6 @@ The report generator lives in a separate package, | 0.25 s | `dedup_window_s` | `report/metrics.py` `dome_bouts` | Collapse `DomeOpened` milestone + InputChanged edge for the same physical open | | 600 s floor, adaptive above | `window_s` / `min_window_s` | `report/sections/timeline.py` `session_raster_section` | Raster detail-panel width. Adaptive by default: `max(min_window_s, duration/12)`, so a run caps at ~12 panels instead of growing without bound; `min_window_s` raises the short-run floor (free_feeding: 900 s), `window_s` pins a fixed width regardless of duration | | 2 (days) | (threshold, not a named constant) | `report/sections/timeline.py` `actogram_section` | Minimum distinct calendar days of activity before the actogram renders at all | -| 6:00 / 18:00 | `lights_on` / `lights_off` | `report/designs/*.json` `timeline.actogram` options | Default 12:12 light cycle assumed for actogram night-phase shading | | 5 / 15 | `pre` / `post` | `report/designs/two_armed_bandit.json` | Reversal-curve trials before/after a block flip | | 5 | `rolling_window` | `report/designs/two_armed_bandit.json` | Block-curve smoothing window (trials) | | 8 | `_MIN_TRIALS_FOR_CURVE` | `report/sections/bandit.py` | Skip choice/reversal charts below this many analyzed trials | diff --git a/packages/sfm-analysis/README.md b/packages/sfm-analysis/README.md index 4fa35d5..1da3892 100644 --- a/packages/sfm-analysis/README.md +++ b/packages/sfm-analysis/README.md @@ -37,6 +37,9 @@ sfm-report --list # One session, opened in your browser when done: sfm-report EXP-Test-02 --open +# Same report, but the actogram ticks pellet takes instead of presence: +sfm-report EXP-Test-02 --design actogram_takes --open + # Every session for a cohort, combined into one comparative report: sfm-report "cohortA_*" --combine -o /tmp/cohortA.html ``` @@ -60,7 +63,7 @@ sfm-report --help --demo Render the bundled demo session (no rig or log dir needed) --since, --until Filter by date (YYYY-MM-DD) --run Only this run_id (a file can hold several) - --design Force a report design instead of resolving by experiment + --design Force a report design: bundled name, or path to a .json file --align Combined-report time alignment: relative | wall | trial | event: --out, -o Output file (single report) or directory (multiple) --no-explorer Skip the embedded interactive timeline (guaranteed script-free, leaner output) @@ -172,11 +175,91 @@ panels go print-only: hidden on screen since the embedded explorer covers the same ground with real zoom, still present in the printed PDF. `timeline.actogram` — one row per calendar day, time-of-day on the -x-axis, night-phase shaded (`lights_on`/`lights_off` options, default -06:00/18:00) — renders automatically whenever a run spans 2 or more -distinct days; it's the figure that matters for a multi-day experiment, -where session_raster's panels (even adaptively sized) stop being the -right tool. Absent entirely for shorter runs. +x-axis — renders automatically whenever a run spans 2 or more distinct +days; it's the figure that matters for a multi-day experiment, where +session_raster's panels (even adaptively sized) stop being the right +tool. Absent entirely for shorter runs. + +It deliberately draws **no light/dark shading**. The rig doesn't record +the facility's light schedule, so any shading would be a fixed +clock-time assumption rendered as though it were measured data. Time of +day is on the axis; apply your own light cycle to it. (For zeitgeber +time in your own analysis, `report.timezones.zeitgeber_time(row, +lights_on=...)` takes the schedule explicitly, where it's your stated +input rather than a silent report-wide default.) + +The section heading and figure caption both name the plotted event +(`Actogram — MousePresence Detected` by default) so a printed page is +unambiguous about what each tick is. Ticks default to presence onsets. +You can remap them **without editing the installed package** — see +[Customize the actogram](#customize-the-actogram) below. + +## Customize the actogram + +After `pip install sfm-analysis` on a laptop, two knobs change which CAN +events become actogram ticks. Names must match `event_name` on +`frame_type == "EVENT"` rows — the same strings as the GUI log +(`Pellet Taken`, `Dome Opened`, `Loaded`, `Fault: Jam`, …). Several +names overlay as **one** series (union of timestamps), not separate +colours. Nothing in `site-packages` needs to be edited. + +### HTML report (no file copy) + +`actogram_takes` ships in the wheel — presence stays the default; this +name is opt-in only (`sfm-report --list-designs`): + +```bash +sfm-report MySession --design actogram_takes --open +``` + +### Python (any session CSV) + +```python +from sfm_analysis.analysis import load_session +from sfm_analysis.report.metrics import activity_by_day + +s = load_session("MySession") # name, glob, or path to the CSV +days = activity_by_day(s.run(), event_names=("Pellet Taken",)) +for day in days: + print(day.date, len(day.times), day.times[:3]) +``` + +Compare presence vs takes vs dome+takes against the bundled demo, or +pass your own CSV. This is the same recipe after a pip install (no git +checkout): + +```bash +python -m sfm_analysis.examples.actogram_by_event +python -m sfm_analysis.examples.actogram_by_event /path/to/MySession.csv +``` + +### A different event, or several + +Copy the shipped design next to your logs and edit `event_names`, then +pass the **path** (so you still do not patch the install): + +```json +{ "ref": "timeline.actogram", + "options": { "event_names": ["Dome Opened", "Pellet Taken"] } } +``` + +```bash +sfm-report MySession --design ./my_actogram.json --open +``` + +A starting file lives at +[`examples/report_design/designs/actogram_takes.json`](examples/report_design/designs/actogram_takes.json) +(identical to the bundled design). Or from Python: + +```python +from pathlib import Path +from sfm_analysis.report import build_session_report + +build_session_report( + Path("MySession.csv"), + design="actogram_takes", # bundled name, or a path to your .json +) +``` ## Python API @@ -315,6 +398,7 @@ scratch: | [`retrieval_latency_by_node.py`](examples/analysis/retrieval_latency_by_node.py) | Per-node summary stats, stdlib-only and pandas paths side by side | | [`takes_after_fault.py`](examples/analysis/takes_after_fault.py) | Pellet takes within a window after each fault interval started | | [`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 | +| [`actogram_by_event.py`](examples/analysis/actogram_by_event.py) | Remap actogram ticks (presence vs pellet takes vs dome+takes). After pip install: `python -m sfm_analysis.examples.actogram_by_event` | Every one of them runs standalone against the bundled demo session, no rig or `--log-dir` needed: diff --git a/packages/sfm-analysis/docs/ANALYSIS_GUIDE.md b/packages/sfm-analysis/docs/ANALYSIS_GUIDE.md index ab207d7..5baa77a 100644 --- a/packages/sfm-analysis/docs/ANALYSIS_GUIDE.md +++ b/packages/sfm-analysis/docs/ANALYSIS_GUIDE.md @@ -65,7 +65,7 @@ doesn't bloat the main log. | `session` | the session name | Groups rows into files; combined with `run_id` for run-scoping | | `run_id` | increments each time a session is reopened | A single CSV can hold several runs — see trap #1 | | `trial` | current trial number, `0` outside a trial | Convenience column; the authoritative trial boundary is the `trial` EXPERIMENT event | -| `source` | `CAN` \| `EXP` \| `BNC` \| `SYS` | `CAN` = node hardware events, `EXP` = experiment-engine events, `BNC` = photogate/beam-break, `SYS` = base-station lifecycle | +| `source` | `CAN` \| `EXP` \| `BNC` \| `SYS` | `CAN` = a **CAN event**: a message on the communication bus all nodes share (node hardware). `EXP` = experiment-engine. `BNC` = base-station photogate/beam-break. `SYS` = base-station lifecycle | | `direction` | `TX` \| `RX` \| `SYS` \| `LOCAL` | Bus direction for CAN frames; not meaningful for EXP rows | | `node_id` | which node (`0` = broadcast / session-scope, not a real node) | | | `frame_type` | `EVENT` \| `COMMAND` \| `HEARTBEAT` \| `PELLET_AUDIT` \| ... | What kind of frame this is, independent of `event_name` | @@ -77,9 +77,14 @@ doesn't bloat the main log. ## 3. Event vocabulary +**CAN** (Controller Area Network) is the shared communication bus every feeder +node is wired onto. A **CAN event** is a frame a node posted on that bus — +`Loaded`, `Pellet Taken`, `Fault`, a sensor edge — as opposed to an +experiment-engine (`EXP`) row the base station invented. + ### CAN events (`source == "CAN"`, `protocol.CanEvent`) -One node-hardware event per row. `event_name` is the *display* name +One node-hardware event per row, received on the CAN bus. `event_name` is the *display* name (`protocol.CAN_EVENT_DISPLAY_NAME`), not the enum member name — use `LogRow.can_event` when you need the underlying enum back (see the dome trap below). @@ -283,6 +288,35 @@ event, in order. Feed straight to a step chart. midnight, sorted) — see [§7](#7-time-timezone-and-time-of-day) for why this needs no UTC offset. +`activity_by_day(run, event_names=...)` selects which CAN EVENT display +names count as ticks (default: `("MousePresence Detected",)`). The +printed actogram (`timeline.actogram`) passes the same knob through from +design JSON `options.event_names`. Multiple names are pooled into one +series. Days with zero matching events are omitted, not drawn as a blank +row. The section title names those events (`Actogram — Pellet Taken`), +as does the figure's own SVG ``. + +The actogram draws no light/dark shading: the rig doesn't record the +facility's light schedule, so shading it would render a fixed +clock-time assumption as though it were measured data. Time of day is +on the axis — apply your own light cycle to it, or use +`zeitgeber_time(row, lights_on=...)` ([§7](#7-time-timezone-and-time-of-day)), +where the schedule is an explicit input you supply. + +A pip-installed user does not edit the package: + +```bash +python -m sfm_analysis.examples.actogram_by_event +python -m sfm_analysis.examples.actogram_by_event /path/to/MySession.csv +sfm-report MySession --design actogram_takes --open +``` + +`actogram_takes` ships in the wheel and is opt-in only (it is not +auto-selected). To plot a different event, copy +[`examples/report_design/designs/actogram_takes.json`](../examples/report_design/designs/actogram_takes.json) +next to your logs, edit `event_names`, and pass the path to +`--design`. See the README section *Customize the actogram*. + ## 6. Tidy-table columns `sfm_analysis.analysis.tables` — every function returns `list[dict]`, one diff --git a/packages/sfm-analysis/examples/analysis/actogram_by_event.py b/packages/sfm-analysis/examples/analysis/actogram_by_event.py new file mode 100644 index 0000000..b91b973 --- /dev/null +++ b/packages/sfm-analysis/examples/analysis/actogram_by_event.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""actogram_by_event.py — map which CAN events count as actogram ticks. + +After ``pip install sfm-analysis`` (no source tree):: + + python -m sfm_analysis.examples.actogram_by_event + python -m sfm_analysis.examples.actogram_by_event /path/to/MySession.csv + +From a git checkout this file is the same recipe:: + + python examples/analysis/actogram_by_event.py +""" + +from sfm_analysis.examples.actogram_by_event import main + +if __name__ == "__main__": + main() diff --git a/packages/sfm-analysis/examples/report_design/README.md b/packages/sfm-analysis/examples/report_design/README.md index a8f3088..4eb211d 100644 --- a/packages/sfm-analysis/examples/report_design/README.md +++ b/packages/sfm-analysis/examples/report_design/README.md @@ -29,6 +29,22 @@ cp examples/report_design/sections/alternation.py src/sfm_analysis/report/sectio `experiment` field is `"alternation"` (the `name` of the experiment template). Force it with `--design alternation`. +### Actogram event mapping (no package edit) + +After `pip install sfm-analysis`, pellet-take ticks are a bundled +design — no file copy: + +```bash +sfm-report MySession --design actogram_takes --open +python -m sfm_analysis.examples.actogram_by_event +python -m sfm_analysis.examples.actogram_by_event /path/to/MySession.csv +``` + +[`designs/actogram_takes.json`](designs/actogram_takes.json) is the same +file as the one in the wheel. Copy it next to your logs only when you +want a *different* event (or several): edit `options.event_names` and +pass the path. Nothing goes into `site-packages`. + Without a matching design, the generic `default.json` still renders — you do not have to ship a design on day one. diff --git a/packages/sfm-analysis/examples/report_design/designs/actogram_takes.json b/packages/sfm-analysis/examples/report_design/designs/actogram_takes.json new file mode 100644 index 0000000..5c19032 --- /dev/null +++ b/packages/sfm-analysis/examples/report_design/designs/actogram_takes.json @@ -0,0 +1,34 @@ +{ + "name": "actogram_takes", + "label": "Generic Behavior Report — actogram from Pellet Taken", + "description": "Same sections as default.json, but the actogram ticks pellet takes instead of presence onsets. Opt-in only: pass --design actogram_takes (ships with pip install sfm-analysis). To plot a different CAN EVENT, copy this file next to your logs, edit event_names, and pass the path. Several names become one tick series.", + "matches": [], + "sections": [ + { "ref": "timeline.explorer" }, + { "ref": "timeline.session_raster" }, + { "ref": "timeline.actogram", + "options": { + "event_names": ["Pellet Taken"] + } + }, + { "ref": "generic.provenance" }, + { "ref": "generic.data_quality" }, + { "ref": "generic.pellet_accounting" }, + { "ref": "generic.retrieval_latency" }, + { "ref": "generic.presence" }, + { "ref": "generic.interaction_funnel" }, + { "ref": "generic.throughput" }, + { "ref": "generic.faults" }, + { "ref": "generic.apparatus_health" } + ], + "combined_sections": [ + { "ref": "compare.cohort_table" }, + { "ref": "compare.learning_curve", "options": { "metric": "take_rate" } }, + { "ref": "compare.subject_spread", "options": { "metric": "take_rate" } }, + { "ref": "compare.node_preference" }, + { "ref": "compare.cumulative_overlay" }, + { "ref": "compare.quality_matrix" }, + { "ref": "generic.retrieval_latency" }, + { "ref": "generic.faults" } + ] +} diff --git a/packages/sfm-analysis/pyproject.toml b/packages/sfm-analysis/pyproject.toml index f5f4187..6211f8a 100644 --- a/packages/sfm-analysis/pyproject.toml +++ b/packages/sfm-analysis/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sfm-analysis" -version = "0.1.0" +version = "0.1.1" description = "Analysis and printable HTML behavior reports for SFM/VFM feeder session logs" readme = "README.md" requires-python = ">=3.9" diff --git a/packages/sfm-analysis/src/sfm_analysis/__init__.py b/packages/sfm-analysis/src/sfm_analysis/__init__.py index 29610f8..d2769d5 100644 --- a/packages/sfm-analysis/src/sfm_analysis/__init__.py +++ b/packages/sfm-analysis/src/sfm_analysis/__init__.py @@ -13,6 +13,6 @@ from __future__ import annotations -__version__ = "0.1.0" +__version__ = "0.1.1" __all__ = ["__version__"] diff --git a/packages/sfm-analysis/src/sfm_analysis/cli/report.py b/packages/sfm-analysis/src/sfm_analysis/cli/report.py index 7aad65e..67029aa 100644 --- a/packages/sfm-analysis/src/sfm_analysis/cli/report.py +++ b/packages/sfm-analysis/src/sfm_analysis/cli/report.py @@ -22,6 +22,9 @@ # One session, opened in the default browser when done: sfm-report EXP-Test-02 --open + # Plot pellet takes on the actogram (ships with the package; no file copy): + sfm-report EXP-Test-02 --design actogram_takes --open + # Every session matching a glob, combined into one report: sfm-report "cohortA_*" --combine -o /tmp/cohort.html @@ -65,7 +68,12 @@ def _print_designs() -> None: return print("Available report designs:") for d in defs: - matches = ", ".join(d.matches) if d.matches else "(fallback for any experiment)" + if d.matches: + matches = ", ".join(d.matches) + elif d.name == "default": + matches = "(fallback for any experiment)" + else: + matches = "(opt-in only; pass --design)" print(f" {d.name:22s} {d.label}") print(f" {'':22s} matches: {matches}") @@ -170,7 +178,10 @@ def main(argv: Optional[List[str]] = None, *, default_log_dir: Optional[Callable help="Only sessions modified on/before this date") parser.add_argument("--run", type=int, default=None, dest="run_id", help="Only this run_id (default: all runs in the file)") - parser.add_argument("--design", default=None, help="Force a report design by name (default: auto by experiment)") + parser.add_argument( + "--design", default=None, + help="Force a report design: bundled name (see --list-designs) or path to a .json file", + ) parser.add_argument("--align", default="relative", help="Combined-report time alignment: relative | wall | trial | event: " "(default: relative)") diff --git a/packages/sfm-analysis/src/sfm_analysis/examples/__init__.py b/packages/sfm-analysis/src/sfm_analysis/examples/__init__.py new file mode 100644 index 0000000..427e73a --- /dev/null +++ b/packages/sfm-analysis/src/sfm_analysis/examples/__init__.py @@ -0,0 +1,9 @@ +"""Runnable recipes that ship in the wheel. + +Cookbook copies also live in the source tree under ``examples/analysis/``; +those files are not installed. This package exists so a laptop with only +``pip install sfm-analysis`` can still run:: + + python -m sfm_analysis.examples.actogram_by_event + python -m sfm_analysis.examples.actogram_by_event /path/to/MySession.csv +""" diff --git a/packages/sfm-analysis/src/sfm_analysis/examples/actogram_by_event.py b/packages/sfm-analysis/src/sfm_analysis/examples/actogram_by_event.py new file mode 100644 index 0000000..7aa75b3 --- /dev/null +++ b/packages/sfm-analysis/src/sfm_analysis/examples/actogram_by_event.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Compare which CAN events count as actogram ticks. + +The printed report defaults to presence onsets (``MousePresence Detected``). +The same helper the report uses — ``activity_by_day`` — accepts any GUI-log +event name. After ``pip install sfm-analysis`` this runs with no source +tree and no Pi:: + + python -m sfm_analysis.examples.actogram_by_event + python -m sfm_analysis.examples.actogram_by_event /path/to/MySession.csv + +From a git checkout the same script is also +``examples/analysis/actogram_by_event.py``. +""" + +from __future__ import annotations + +import sys + +from sfm_analysis.analysis import load_session +from sfm_analysis.report.demo import DEMO_SESSION_PATH +from sfm_analysis.report.metrics import activity_by_day + +# Same strings as event_name on frame_type == "EVENT" rows in the GUI log. +PROXIES = ( + ("MousePresence Detected",), + ("Pellet Taken",), + ("Dome Opened", "Pellet Taken"), +) + + +def _summarize(run, names) -> None: + days = activity_by_day(run, event_names=names) + n_ticks = sum(len(d.times) for d in days) + label = ", ".join(names) + print(f" {label}: {n_ticks} tick(s) across {len(days)} day(s)") + for day in days: + print(f" {day.date.isoformat()} n={len(day.times)}") + + +def main(argv: list[str] | None = None) -> None: + args = sys.argv[1:] if argv is None else argv + target = args[0] if args else str(DEMO_SESSION_PATH) + s = load_session(target) + run = s.run() + print(f"{run.run_label}: actogram event mapping") + for names in PROXIES: + _summarize(run, names) + print( + "To plot takes on the HTML actogram instead of presence " + "(no file copy; ships with the package):\n" + " sfm-report MySession --design actogram_takes --open\n" + "To plot a different event, copy that design next to your logs, " + "edit event_names, and pass the path." + ) + + +if __name__ == "__main__": + main() diff --git a/packages/sfm-analysis/src/sfm_analysis/report/__init__.py b/packages/sfm-analysis/src/sfm_analysis/report/__init__.py index fbd0672..a13237e 100644 --- a/packages/sfm-analysis/src/sfm_analysis/report/__init__.py +++ b/packages/sfm-analysis/src/sfm_analysis/report/__init__.py @@ -31,7 +31,7 @@ from ..logs import heartbeat_path_for from .loader import load_heartbeats, load_rows from .render import render_report_html -from .schema import load_report_def, resolve_design +from .schema import load_design, resolve_design from .session import RunData, split_runs __all__ = [ @@ -67,11 +67,7 @@ def load_runs(csv_path: Path, run_id: Optional[int] = None) -> List[RunData]: def _resolve_design_for(runs: List[RunData], design: Optional[str]): if design: - # An explicit --design name is looked up directly by filename stem. - from .schema import DEFAULT_REPORTS_DIR - path = DEFAULT_REPORTS_DIR / f"{design}.json" - if path.exists(): - return load_report_def(path) + return load_design(design) experiments = {r.experiment for r in runs} experiment = next(iter(experiments)) if len(experiments) == 1 else "unknown" return resolve_design(experiment) diff --git a/packages/sfm-analysis/src/sfm_analysis/report/designs/actogram_takes.json b/packages/sfm-analysis/src/sfm_analysis/report/designs/actogram_takes.json new file mode 100644 index 0000000..5c19032 --- /dev/null +++ b/packages/sfm-analysis/src/sfm_analysis/report/designs/actogram_takes.json @@ -0,0 +1,34 @@ +{ + "name": "actogram_takes", + "label": "Generic Behavior Report — actogram from Pellet Taken", + "description": "Same sections as default.json, but the actogram ticks pellet takes instead of presence onsets. Opt-in only: pass --design actogram_takes (ships with pip install sfm-analysis). To plot a different CAN EVENT, copy this file next to your logs, edit event_names, and pass the path. Several names become one tick series.", + "matches": [], + "sections": [ + { "ref": "timeline.explorer" }, + { "ref": "timeline.session_raster" }, + { "ref": "timeline.actogram", + "options": { + "event_names": ["Pellet Taken"] + } + }, + { "ref": "generic.provenance" }, + { "ref": "generic.data_quality" }, + { "ref": "generic.pellet_accounting" }, + { "ref": "generic.retrieval_latency" }, + { "ref": "generic.presence" }, + { "ref": "generic.interaction_funnel" }, + { "ref": "generic.throughput" }, + { "ref": "generic.faults" }, + { "ref": "generic.apparatus_health" } + ], + "combined_sections": [ + { "ref": "compare.cohort_table" }, + { "ref": "compare.learning_curve", "options": { "metric": "take_rate" } }, + { "ref": "compare.subject_spread", "options": { "metric": "take_rate" } }, + { "ref": "compare.node_preference" }, + { "ref": "compare.cumulative_overlay" }, + { "ref": "compare.quality_matrix" }, + { "ref": "generic.retrieval_latency" }, + { "ref": "generic.faults" } + ] +} diff --git a/packages/sfm-analysis/src/sfm_analysis/report/schema.py b/packages/sfm-analysis/src/sfm_analysis/report/schema.py index ad37b2e..14bcdf1 100644 --- a/packages/sfm-analysis/src/sfm_analysis/report/schema.py +++ b/packages/sfm-analysis/src/sfm_analysis/report/schema.py @@ -27,7 +27,7 @@ import traceback from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional, Union def _packaged_designs_dir() -> Path: @@ -147,6 +147,35 @@ def load_report_defs(directory: Optional[Path] = None) -> List[ReportDef]: return [load_report_def(p) for p in sorted(root.glob("*.json"))] +def load_design(spec: Union[str, Path]) -> ReportDef: + """ + Resolve a design from a bundled name *or* a JSON file on disk. + + ``spec`` may be: + + - a bundled design name (``"two_armed_bandit"``, ``"default"``, + ``"actogram_takes"``, …) + - a path to a ``.json`` file the user wrote (so a pip install does + not have to be patched to change actogram ``event_names``) + - ``"name.json"`` as a filename, which loads the bundled design of + that stem if no local file exists + + Raises ``ValueError`` if neither a file nor a bundled name matches, + rather than silently falling through to auto-resolution. + """ + p = Path(spec).expanduser() + if p.is_file(): + return load_report_def(p) + name = p.stem if p.suffix.lower() == ".json" else str(spec) + bundled = DEFAULT_REPORTS_DIR / f"{name}.json" + if bundled.is_file(): + return load_report_def(bundled) + raise ValueError( + f"Unknown report design {spec!r}. Use a bundled name " + f"(sfm-report --list-designs) or a path to a .json design file." + ) + + def resolve_section(ref: str) -> SectionFn: """ Resolve "module.func" to a section callable. diff --git a/packages/sfm-analysis/src/sfm_analysis/report/sections/timeline.py b/packages/sfm-analysis/src/sfm_analysis/report/sections/timeline.py index dbac259..9501fe5 100644 --- a/packages/sfm-analysis/src/sfm_analysis/report/sections/timeline.py +++ b/packages/sfm-analysis/src/sfm_analysis/report/sections/timeline.py @@ -17,12 +17,13 @@ from __future__ import annotations -from typing import List, Optional +from typing import List, Optional, Sequence, Tuple from .. import charts from ..charts import Frame, Mark, escape_text from ..explorer import build_explorer_payload from ..explorer_render import EXPLORER_CSS, EXPLORER_JS, explorer_bootstrap_js, explorer_widget_html +from ..metrics import DEFAULT_ACTIVITY_EVENTS, activity_by_day from ..timeline_data import ( EVENT_GLYPH as _EVENT_GLYPH, SESSION_MARK_STYLES as _SESSION_MARK_STYLES, @@ -148,7 +149,11 @@ def _fmt_duration(seconds: float) -> str: return f"{s}s" -def _actogram_panel(days, *, lights_on: float, lights_off: float) -> str: +def _actogram_panel( + days, + *, + event_label: str, +) -> str: lanes = [d.date.strftime("%b %d") for d in days] frame = Frame(h=max(140, 40 + len(lanes) * 16)) x = charts.linear(0.0, 24.0, frame.px0, frame.px1) @@ -156,17 +161,10 @@ def _actogram_panel(days, *, lights_on: float, lights_off: float) -> str: lane_h = min(14, lane_gap * 0.8) body = [] - # Night shading first, so activity ticks draw on top of it. Two spans - # per lane unless the whole day (or none of it) is dark, since the - # dark period may wrap around both edges of the [0, 24) axis. - for li in range(len(lanes)): - ly = frame.py0 + li * lane_gap + (lane_gap - lane_h) / 2 - for a, b in ((0.0, lights_on), (lights_off, 24.0)): - if b <= a: - continue - x0, x1 = x(a), x(b) - body.append(f'') + # No light/dark shading: the rig does not record the facility's light + # schedule, so any shading here would be a fixed clock-time assumption + # drawn as if it were measured data. Time of day is on the axis; a + # reader who knows their own light cycle can apply it themselves. body.append(f'') @@ -176,12 +174,37 @@ def _actogram_panel(days, *, lights_on: float, lights_off: float) -> str: body.append(f'{hour}:00') marks = [ - Mark(lane=li, t=t, glyph="tick", key=0, title=f"{lanes[li]} {int(t):02d}:{int((t % 1) * 60):02d}") + Mark( + lane=li, t=t, glyph="tick", key=0, + title=f"{event_label} · {lanes[li]} {int(t):02d}:{int((t % 1) * 60):02d}", + ) for li, day in enumerate(days) for t in day.times ] body.append(charts.raster(frame, x, lanes, marks, lane_h=lane_h)) - return charts.svg(frame, "".join(body), title="actogram") + return charts.svg( + frame, "".join(body), + title=f"actogram — {event_label}", + desc=( + f"Each tick is a {event_label} event from the session log. " + "One row per calendar day; x-axis is rig-local time of day (0–24 h)." + ), + ) + + +def _actogram_event_names(opts: dict) -> Tuple[str, ...]: + """CAN EVENT display names that count as actogram ticks. + + Default is presence onsets (``DEFAULT_ACTIVITY_EVENTS``). Design JSON + may pass ``event_names`` as a string or a list — several names are + pooled into one series (union of ticks), not separate colours. + """ + raw = opts.get("event_names", DEFAULT_ACTIVITY_EVENTS) + if isinstance(raw, str): + names: Sequence[str] = (raw,) + else: + names = tuple(str(n) for n in raw if str(n).strip()) + return tuple(names) if names else DEFAULT_ACTIVITY_EVENTS def actogram_section(ctx: SectionContext) -> Optional[SectionResult]: @@ -194,39 +217,38 @@ def actogram_section(ctx: SectionContext) -> Optional[SectionResult]: Empty (nothing rendered) for any run with fewer than 2 distinct days of activity — a single-day run gets nothing an actogram would add over the session raster above it. + + ``event_names`` (design-JSON option) selects which CAN EVENT rows are + plotted; omit it to keep the presence-onset default. Multiple names + become one tick series. The section heading names those events so a + printed page is unambiguous about what the ticks are. """ - lights_on = float(ctx.opts.get("lights_on", 6.0)) - lights_off = float(ctx.opts.get("lights_off", 18.0)) + event_names = _actogram_event_names(ctx.opts) + event_label = ", ".join(event_names) + title = f"Actogram — {event_label}" figs = [] - for run, m in zip(ctx.runs, ctx.metrics): - days = m.activity_by_day + for run in ctx.runs: + days = activity_by_day(run, event_names=event_names) if len(days) < 2: continue heading = f"

{escape_text(run.run_label)}

" if len(ctx.runs) > 1 else "" - panel = _actogram_panel(days, lights_on=lights_on, lights_off=lights_off) + panel = _actogram_panel(days, event_label=event_label) figs.append( - f'{heading}
Activity by day ' - f'({len(days)} days; shaded = lights off, {_fmt_clock(lights_off)}–{_fmt_clock(lights_on)}).' + f'{heading}
{len(days)} days.' f'
{panel}
' ) if not figs: - return SectionResult(section_id="timeline.actogram", title="Actogram", html="", empty=True) + return SectionResult(section_id="timeline.actogram", title=title, html="", empty=True) return SectionResult( section_id="timeline.actogram", - title="Actogram", + title=title, html="".join(figs), ) -def _fmt_clock(hour: float) -> str: - h = int(hour) % 24 - m = int(round((hour % 1) * 60)) - return f"{h:02d}:{m:02d}" - - def explorer_section(ctx: SectionContext) -> Optional[SectionResult]: """ The interactive timeline: one real pan/zoom/brush-zoom widget per run, diff --git a/packages/sfm-analysis/src/sfm_analysis/report/style.py b/packages/sfm-analysis/src/sfm_analysis/report/style.py index 7c61877..c9ec8ec 100644 --- a/packages/sfm-analysis/src/sfm_analysis/report/style.py +++ b/packages/sfm-analysis/src/sfm_analysis/report/style.py @@ -201,7 +201,6 @@ def hatch_defs() -> str: .chart .gridline {{ stroke: var(--gridline); stroke-width: 1; }} .chart .value-label {{ fill: var(--ink-primary); font-size: 10px; }} .chart .node-divider {{ stroke: var(--axis); stroke-width: 1; stroke-dasharray: 2,2; }} -.chart .actogram-night {{ fill: var(--gridline); }} .legend {{ display: flex; flex-wrap: wrap; align-items: center; gap: 12px; font-size: 11px; color: var(--ink-secondary); margin: 0 0 8px; }} .legend-item {{ display: inline-flex; align-items: center; gap: 5px; }} diff --git a/packages/sfm-analysis/tests/test_cli_report.py b/packages/sfm-analysis/tests/test_cli_report.py index 6a335be..1d24935 100644 --- a/packages/sfm-analysis/tests/test_cli_report.py +++ b/packages/sfm-analysis/tests/test_cli_report.py @@ -5,6 +5,7 @@ import subprocess import sys +from pathlib import Path from report_fixtures import bandit_run, legacy9_file, write_session @@ -34,6 +35,8 @@ def test_list_designs(self): result = _run(["--list-designs"]) assert result.returncode == 0 assert "default" in result.stdout + assert "actogram_takes" in result.stdout + assert "opt-in only" in result.stdout def test_check_names(self, tmp_path): write_session(tmp_path, bandit_run(n_trials=1, session="cohortA_M014_d3"), session="cohortA_M014_d3") @@ -175,11 +178,37 @@ def test_align_event_syntax_accepted(self, tmp_path): result = _run(["A", "--align", "event:trial", "--log-dir", str(tmp_path), "--out", str(out)]) assert result.returncode == 0, result.stderr - def test_legacy_schema_only_exits_2(self, tmp_path): - legacy9_file(tmp_path, name="legacy") - result = _run(["legacy", "--log-dir", str(tmp_path)]) + def test_unknown_design_exits_2(self, tmp_path): + write_session(tmp_path, bandit_run(n_trials=1, session="A"), session="A") + result = _run(["A", "--design", "not_a_real_design", "--log-dir", str(tmp_path)]) assert result.returncode == 2 - assert "unsupported schema" in result.stderr + assert "Unknown report design" in result.stderr + + def test_design_bundled_actogram_takes(self, tmp_path): + write_session(tmp_path, bandit_run(n_trials=1, session="A"), session="A") + out = tmp_path / "out.html" + result = _run( + ["A", "--design", "actogram_takes", "--log-dir", str(tmp_path), "--out", str(out)], + ) + assert result.returncode == 0, result.stderr + content = out.read_text(encoding="utf-8") + assert "actogram from Pellet Taken" in content + assert "Win-Stay / Lose-Shift" not in content + + def test_design_json_path(self, tmp_path): + write_session(tmp_path, bandit_run(n_trials=1, session="A"), session="A") + design = ( + Path(__file__).resolve().parent.parent + / "examples" / "report_design" / "designs" / "actogram_takes.json" + ) + out = tmp_path / "out.html" + result = _run( + ["A", "--design", str(design), "--log-dir", str(tmp_path), "--out", str(out)], + ) + assert result.returncode == 0, result.stderr + content = out.read_text(encoding="utf-8") + assert "actogram from Pellet Taken" in content + assert "Win-Stay / Lose-Shift" not in content def test_legacy_mixed_with_good_file_still_succeeds(self, tmp_path): legacy9_file(tmp_path, name="legacy") diff --git a/packages/sfm-analysis/tests/test_examples.py b/packages/sfm-analysis/tests/test_examples.py index fb040d3..c73d0cf 100644 --- a/packages/sfm-analysis/tests/test_examples.py +++ b/packages/sfm-analysis/tests/test_examples.py @@ -51,3 +51,24 @@ def test_exp_events_by_name(): assert "141 EXP row(s), 20 distinct name(s)" in result.stdout assert "19 bandit_trial" in result.stdout assert "bandit_trial_end: 17 (valid=17)" in result.stdout + + +def test_actogram_by_event(): + result = _run("actogram_by_event.py") + assert result.returncode == 0, result.stderr + assert "actogram event mapping" in result.stdout + assert "MousePresence Detected:" in result.stdout + assert "Pellet Taken:" in result.stdout + assert "Dome Opened, Pellet Taken:" in result.stdout + assert "sfm-report MySession --design actogram_takes --open" in result.stdout + + +def test_actogram_by_event_as_installed_module(): + """The pip-install path: no source-tree examples/ directory required.""" + result = subprocess.run( + [sys.executable, "-m", "sfm_analysis.examples.actogram_by_event"], + capture_output=True, text=True, timeout=30, + ) + assert result.returncode == 0, result.stderr + assert "actogram event mapping" in result.stdout + assert "sfm-report MySession --design actogram_takes --open" in result.stdout diff --git a/packages/sfm-analysis/tests/test_report_render.py b/packages/sfm-analysis/tests/test_report_render.py index ceca0d8..d0c8ec7 100644 --- a/packages/sfm-analysis/tests/test_report_render.py +++ b/packages/sfm-analysis/tests/test_report_render.py @@ -1,6 +1,7 @@ """Tests for sfm_analysis.report.render and the report/__init__.py public API.""" import re +from pathlib import Path from report_fixtures import bandit_run, write_csv, write_session @@ -146,3 +147,27 @@ def test_explicit_design_name_selects_that_design(self, tmp_path): content = out.read_text(encoding="utf-8") assert "Meal-Bout Analysis" in content assert "Two-Armed Bandit" not in content + + def test_bundled_actogram_takes_overrides_auto_resolution(self, tmp_path): + rows = bandit_run(n_trials=2, session="BundledTakesTest") + csv_path = write_session(tmp_path, rows, session="BundledTakesTest") + out = build_session_report( + csv_path, out_path=tmp_path / "takes.html", design="actogram_takes", + ) + content = out.read_text(encoding="utf-8") + assert "actogram from Pellet Taken" in content + assert "Win-Stay / Lose-Shift" not in content + + def test_design_json_path_overrides_auto_resolution(self, tmp_path): + rows = bandit_run(n_trials=2, session="PathDesignTest") + csv_path = write_session(tmp_path, rows, session="PathDesignTest") + design = ( + Path(__file__).resolve().parent.parent + / "examples" / "report_design" / "designs" / "actogram_takes.json" + ) + out = build_session_report( + csv_path, out_path=tmp_path / "takes.html", design=str(design), + ) + content = out.read_text(encoding="utf-8") + assert "actogram from Pellet Taken" in content + assert "Win-Stay / Lose-Shift" not in content diff --git a/packages/sfm-analysis/tests/test_report_schema.py b/packages/sfm-analysis/tests/test_report_schema.py index df46954..692a316 100644 --- a/packages/sfm-analysis/tests/test_report_schema.py +++ b/packages/sfm-analysis/tests/test_report_schema.py @@ -1,9 +1,10 @@ """Tests for sfm_analysis.report.schema.""" +from pathlib import Path from sfm_analysis.report.schema import ( - DEFAULT_REPORTS_DIR, SectionContext, SectionSpec, load_report_defs, - resolve_design, resolve_section, run_section, + DEFAULT_REPORTS_DIR, SectionContext, SectionSpec, load_design, + load_report_defs, resolve_design, resolve_section, run_section, ) @@ -18,11 +19,12 @@ class TestPackagedDesigns: def test_packaged_designs_dir_is_a_real_directory(self): assert DEFAULT_REPORTS_DIR.is_dir() - def test_all_five_shipped_designs_are_present(self): + def test_all_shipped_designs_are_present(self): stems = {p.stem for p in DEFAULT_REPORTS_DIR.glob("*.json")} assert stems == { "default", "free_feeding", "fixed_and_random", "probability_delivery", "two_armed_bandit", + "actogram_takes", } @@ -55,6 +57,65 @@ def test_missing_reports_dir_does_not_raise(self, tmp_path): assert d.name == "default" +class TestLoadDesign: + def test_bundled_name(self): + d = load_design("default") + assert d.name == "default" + + def test_bundled_name_with_json_suffix(self): + d = load_design("two_armed_bandit.json") + assert d.name == "two_armed_bandit" + + def test_bundled_actogram_takes(self): + """pip users remap the actogram without a local JSON file.""" + d = load_design("actogram_takes") + assert d.name == "actogram_takes" + actogram = next(s for s in d.sections if s.ref == "timeline.actogram") + assert actogram.options["event_names"] == ["Pellet Taken"] + + def test_actogram_takes_is_default_with_take_ticks(self): + default = load_design("default") + takes = load_design("actogram_takes") + assert [s.ref for s in default.sections] == [s.ref for s in takes.sections] + assert [s.ref for s in default.combined_sections] == [ + s.ref for s in takes.combined_sections + ] + + def test_actogram_takes_is_not_auto_selected(self): + d = resolve_design("two_armed_bandit") + assert d.name == "two_armed_bandit" + d = resolve_design("unknown_experiment_xyz") + assert d.name == "default" + + def test_example_json_matches_bundled(self): + bundled = (DEFAULT_REPORTS_DIR / "actogram_takes.json").read_text( + encoding="utf-8", + ) + example = ( + Path(__file__).resolve().parent.parent + / "examples" / "report_design" / "designs" / "actogram_takes.json" + ) + assert example.read_text(encoding="utf-8") == bundled + + def test_local_json_path(self, tmp_path): + src = ( + Path(__file__).resolve().parent.parent + / "examples" / "report_design" / "designs" / "actogram_takes.json" + ) + assert src.is_file(), "example design missing from source tree" + copied = tmp_path / "actogram_takes.json" + copied.write_text(src.read_text(encoding="utf-8"), encoding="utf-8") + d = load_design(copied) + assert d.name == "actogram_takes" + actogram = next(s for s in d.sections if s.ref == "timeline.actogram") + assert actogram.options["event_names"] == ["Pellet Taken"] + + def test_unknown_name_raises(self): + import pytest + with pytest.raises(ValueError, match="Unknown report design"): + load_design("not_a_real_design") + + class TestRunSection: def test_failing_section_renders_error_block_not_exception(self): spec = SectionSpec(ref="generic.nonexistent_section_xyz") diff --git a/packages/sfm-analysis/tests/test_report_timeline.py b/packages/sfm-analysis/tests/test_report_timeline.py index 1226828..851a244 100644 --- a/packages/sfm-analysis/tests/test_report_timeline.py +++ b/packages/sfm-analysis/tests/test_report_timeline.py @@ -3,7 +3,8 @@ import re import xml.etree.ElementTree as ET -from report_fixtures import bandit_run, input_changed_row, write_session +from report_fixtures import bandit_run, can_event_row, input_changed_row, write_session +from sfm_analysis.protocol import CanEvent from sfm_analysis.report.loader import load_rows from sfm_analysis.report.metrics import compute_run_metrics @@ -132,6 +133,17 @@ def test_multi_day_run_renders_one_row_per_day(self, tmp_path): assert result.empty is False # 4 lane labels rendered by charts.raster's own lane-label pass. assert result.html.count('text-anchor="end"') == 4 + assert result.title == "Actogram — MousePresence Detected" + + def test_heading_names_the_plotted_event(self, tmp_path): + """A printed page must say which CAN EVENT the ticks are, not + just 'Actogram'. The section heading carries it, and the SVG's + own restates it for anyone reading the figure alone.""" + ctx = _multi_day_ctx(tmp_path, n_days=3) + result = actogram_section(ctx) + assert result.title.startswith("Actogram — ") + assert "MousePresence Detected" in result.title + assert "Each tick is a MousePresence Detected event" in result.html def test_all_svgs_parse(self, tmp_path): ctx = _multi_day_ctx(tmp_path, n_days=3) @@ -141,11 +153,26 @@ def test_all_svgs_parse(self, tmp_path): for s in svgs: ET.fromstring(s) - def test_lights_on_off_options_change_the_caption(self, tmp_path): + def test_no_light_dark_shading(self, tmp_path): + """The rig never records the facility's light schedule, so the + actogram must not draw a light/dark cycle: shading from a fixed + clock-time assumption would read as measured data. Time of day is + on the axis; a reader applies their own light cycle to it.""" + ctx = _multi_day_ctx(tmp_path, n_days=3) + result = actogram_section(ctx) + assert "actogram-night" not in result.html + assert "lights off" not in result.html.lower() + assert " (charts.svg's title= argument). assert result.html.count("") == 4 * 2 + 1 + assert "MousePresence Detected" in result.html + + def test_event_names_option_plots_a_different_proxy(self, tmp_path): + day_ms = 24 * 3600 * 1000 + t0 = 1_700_000_000_000 + rows = [ + input_changed_row(t0 + day * day_ms, 1, 4, True, "MousePresence Detected") + for day in range(4) + ] + [ + can_event_row(t0 + day * day_ms + 3600 * 1000, 1, CanEvent.PelletTaken, bytes([1, 0, 1])) + for day in range(4) + ] + path = write_session(tmp_path, rows, session="MD") + loaded, _, _ = load_rows(path) + runs = split_runs(loaded, [], path) + metrics = [compute_run_metrics(r) for r in runs] + presence = SectionContext(runs=runs, metrics=metrics, combined=False, + align="relative", opts={}) + takes = SectionContext(runs=runs, metrics=metrics, combined=False, + align="relative", + opts={"event_names": ["Pellet Taken"]}) + presence_html = actogram_section(presence).html + takes_result = actogram_section(takes) + takes_html = takes_result.html + assert takes_result.title == "Actogram — Pellet Taken" + assert "MousePresence Detected" in presence_html + assert "Pellet Taken" in takes_html + assert "MousePresence Detected" not in takes_html + # 4 days × 1 take each, plus the SVG title — not the 4 presence ticks. + assert takes_html.count("<title>") == 4 + 1 + assert presence_html.count("<title>") == 4 + 1 + + def test_event_names_union_pools_several_events_as_one_series(self, tmp_path): + day_ms = 24 * 3600 * 1000 + t0 = 1_700_000_000_000 + rows = [ + can_event_row(t0 + day * day_ms, 1, CanEvent.PelletTaken, bytes([1, 0, 1])) + for day in range(3) + ] + [ + can_event_row(t0 + day * day_ms + 1800 * 1000, 1, CanEvent.DomeOpened, bytes([1, 0, 1])) + for day in range(3) + ] + path = write_session(tmp_path, rows, session="MD") + loaded, _, _ = load_rows(path) + runs = split_runs(loaded, [], path) + metrics = [compute_run_metrics(r) for r in runs] + ctx = SectionContext( + runs=runs, metrics=metrics, combined=False, align="relative", + opts={"event_names": ["Pellet Taken", "Dome Opened"]}, + ) + result = actogram_section(ctx) + assert result.empty is False + assert result.title == "Actogram — Pellet Taken, Dome Opened" + assert "Pellet Taken, Dome Opened" in result.html + # Both event types pooled into one tick series, not two. + assert result.html.count("<title>") == 3 * 2 + 1 + + def test_event_names_with_no_matching_events_is_empty(self, tmp_path): + ctx = _multi_day_ctx(tmp_path, n_days=4, opts={"event_names": ["Pellet Taken"]}) + result = actogram_section(ctx) + assert result.empty is True