sfm-analysis 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. sfm_analysis/__init__.py +18 -0
  2. sfm_analysis/analysis/__init__.py +50 -0
  3. sfm_analysis/analysis/intervals.py +149 -0
  4. sfm_analysis/analysis/session.py +127 -0
  5. sfm_analysis/analysis/tables.py +203 -0
  6. sfm_analysis/cli/__init__.py +1 -0
  7. sfm_analysis/cli/report.py +309 -0
  8. sfm_analysis/logs.py +53 -0
  9. sfm_analysis/protocol.py +564 -0
  10. sfm_analysis/py.typed +0 -0
  11. sfm_analysis/report/__init__.py +137 -0
  12. sfm_analysis/report/align.py +49 -0
  13. sfm_analysis/report/analyses/__init__.py +6 -0
  14. sfm_analysis/report/analyses/bandit.py +354 -0
  15. sfm_analysis/report/analyses/fixed_random.py +79 -0
  16. sfm_analysis/report/analyses/free_feeding.py +86 -0
  17. sfm_analysis/report/analyses/probability.py +62 -0
  18. sfm_analysis/report/charts.py +694 -0
  19. sfm_analysis/report/demo/Bandit_test_01.csv +522 -0
  20. sfm_analysis/report/demo/Bandit_test_01_heartbeats.csv +45 -0
  21. sfm_analysis/report/demo/__init__.py +40 -0
  22. sfm_analysis/report/designs/__init__.py +12 -0
  23. sfm_analysis/report/designs/default.json +30 -0
  24. sfm_analysis/report/designs/fixed_and_random.json +29 -0
  25. sfm_analysis/report/designs/free_feeding.json +30 -0
  26. sfm_analysis/report/designs/probability_delivery.json +29 -0
  27. sfm_analysis/report/designs/two_armed_bandit.json +39 -0
  28. sfm_analysis/report/explorer.py +101 -0
  29. sfm_analysis/report/explorer_render.py +576 -0
  30. sfm_analysis/report/loader.py +311 -0
  31. sfm_analysis/report/metrics.py +647 -0
  32. sfm_analysis/report/naming.py +137 -0
  33. sfm_analysis/report/render.py +169 -0
  34. sfm_analysis/report/schema.py +236 -0
  35. sfm_analysis/report/sections/__init__.py +8 -0
  36. sfm_analysis/report/sections/bandit.py +224 -0
  37. sfm_analysis/report/sections/compare.py +278 -0
  38. sfm_analysis/report/sections/fixed_random.py +90 -0
  39. sfm_analysis/report/sections/free_feeding.py +107 -0
  40. sfm_analysis/report/sections/generic.py +384 -0
  41. sfm_analysis/report/sections/probability.py +100 -0
  42. sfm_analysis/report/sections/timeline.py +294 -0
  43. sfm_analysis/report/session.py +195 -0
  44. sfm_analysis/report/stats.py +158 -0
  45. sfm_analysis/report/style.py +237 -0
  46. sfm_analysis/report/timeline_data.py +143 -0
  47. sfm_analysis/report/timezones.py +85 -0
  48. sfm_analysis/storage.py +81 -0
  49. sfm_analysis/targeting.py +54 -0
  50. sfm_analysis-0.1.0.dist-info/METADATA +434 -0
  51. sfm_analysis-0.1.0.dist-info/RECORD +55 -0
  52. sfm_analysis-0.1.0.dist-info/WHEEL +5 -0
  53. sfm_analysis-0.1.0.dist-info/entry_points.txt +2 -0
  54. sfm_analysis-0.1.0.dist-info/licenses/LICENSE +21 -0
  55. sfm_analysis-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,18 @@
1
+ """sfm_analysis — cross-platform analysis and reporting for VFM/SFM feeder logs.
2
+
3
+ Reads the session CSVs written by the VFM base station (``packages/dev_gui`` in
4
+ the parent repo, https://github.com/Neurotech-Hub/VFM) and turns them into
5
+ printable HTML behavior reports, with no dependency on the Raspberry Pi
6
+ base-station GUI or any hardware library. Install with ``pip install
7
+ sfm-analysis`` on Windows, macOS, or Linux and run ``sfm-report`` — see
8
+ README.md for the full CLI and the Python analysis API.
9
+
10
+ This package is stdlib-only by design: no jinja2, no pandas, no matplotlib.
11
+ Reports are self-contained HTML with inline SVG, printable via Ctrl+P.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ __version__ = "0.1.0"
17
+
18
+ __all__ = ["__version__"]
@@ -0,0 +1,50 @@
1
+ """analysis — the curated Python API for custom session analysis.
2
+
3
+ Everything below is stable, documented, and safe to build on. It sits on
4
+ top of ``sfm_analysis.report`` (loading, run-splitting, metrics) without
5
+ requiring you to know that package's internals — see this package's
6
+ functions' docstrings for worked examples, and
7
+ ``packages/sfm-analysis/README.md``'s "five things to know" section for
8
+ the pitfalls a session log has that aren't obvious from the CSV alone.
9
+
10
+ from sfm_analysis.analysis import load_session
11
+
12
+ s = load_session("EXP-Test-02")
13
+ cycles = s.cycles_table() # list[dict], one row per dispense cycle
14
+ bouts = s.bouts_table() # presence / dome / fault intervals
15
+
16
+ Everything here returns plain ``list[dict]`` / dataclasses / floats — no
17
+ pandas dependency. Call ``to_dataframe(rows)`` if you have pandas
18
+ installed and want one.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from . import intervals
24
+ from .intervals import around, count_in, merge, overlap, rate_in, subtract
25
+ from .session import AmbiguousSessionError, Session, SessionNotFoundError, load_session
26
+ from .tables import bouts_table, cycles_table, events_table, to_dataframe, trials_table
27
+ from ..report.timezones import local_date, time_of_day, wall_clock, zeitgeber_time
28
+
29
+ __all__ = [
30
+ "load_session",
31
+ "Session",
32
+ "SessionNotFoundError",
33
+ "AmbiguousSessionError",
34
+ "cycles_table",
35
+ "bouts_table",
36
+ "events_table",
37
+ "trials_table",
38
+ "to_dataframe",
39
+ "intervals",
40
+ "overlap",
41
+ "subtract",
42
+ "merge",
43
+ "around",
44
+ "count_in",
45
+ "rate_in",
46
+ "time_of_day",
47
+ "local_date",
48
+ "zeitgeber_time",
49
+ "wall_clock",
50
+ ]
@@ -0,0 +1,149 @@
1
+ """intervals.py — generic interval algebra for behavioral analysis.
2
+
3
+ Every "was X happening while Y" question a session log gets asked reduces
4
+ to one of these six functions. An interval is a plain ``(t0, t1)`` tuple
5
+ of run-relative seconds (the same ``t`` every RunData row and Bout/
6
+ FaultInterval/Cycle already carries) with ``t0 <= t1``; a set of intervals
7
+ is any ``Sequence`` of them, not necessarily sorted or merged.
8
+
9
+ Deliberately *not* method calls on Bout/FaultInterval: those objects can
10
+ be ``censored`` (``t1=None``, still open when the run ended), and this
11
+ module refuses to guess what a censored interval's end time should be —
12
+ see ``as_intervals`` below. Resolve that explicitly (usually to
13
+ ``run.duration_s``) before handing data here, the same way
14
+ ``metrics.bouts()`` already does internally.
15
+
16
+ Example::
17
+
18
+ from sfm_analysis.analysis import intervals as iv
19
+
20
+ presence, _ = m.presence # List[Bout]
21
+ dome, _ = m.dome # List[Bout]
22
+ presence_iv = iv.as_intervals(presence, run_end=run.duration_s)
23
+ dome_iv = iv.as_intervals(dome, run_end=run.duration_s)
24
+
25
+ # Dome opened while the mouse was present:
26
+ len(iv.overlap(dome_iv, presence_iv))
27
+
28
+ # Takes within 30s after a fault started:
29
+ fault_iv = iv.as_intervals(m.faults, run_end=run.duration_s)
30
+ take_times = [r.t for r in run.by_event("Pellet Taken")]
31
+ iv.count_in(take_times, iv.around([f[0] for f in fault_iv], (0, 30)))
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from typing import Any, List, Optional, Sequence, Tuple
37
+
38
+ Interval = Tuple[float, float]
39
+
40
+
41
+ def as_intervals(objs: Sequence[Any], *, run_end: Optional[float] = None) -> List[Interval]:
42
+ """
43
+ Convert a sequence of Bout/FaultInterval-like objects (anything with
44
+ ``.t0`` and ``.t1`` attributes) into plain ``(t0, t1)`` tuples.
45
+
46
+ Raises ValueError on a censored object (``t1 is None``) unless
47
+ ``run_end`` is given, in which case its ``t1`` becomes ``run_end`` —
48
+ matching how the report's own ``metrics.bouts()`` resolves an
49
+ unclosed bout. This is a required, explicit choice: silently treating
50
+ a censored interval as zero-length or infinite would both be wrong,
51
+ and differently wrong depending on the question being asked.
52
+ """
53
+ out: List[Interval] = []
54
+ for obj in objs:
55
+ t0 = float(obj.t0)
56
+ t1 = obj.t1
57
+ if t1 is None:
58
+ if run_end is None:
59
+ raise ValueError(
60
+ "censored interval (t1=None) with no run_end given — "
61
+ "pass run_end=run.duration_s to resolve it, or filter "
62
+ "out censored objects first if that's what you want"
63
+ )
64
+ t1 = run_end
65
+ out.append((t0, float(t1)))
66
+ return out
67
+
68
+
69
+ def _clip(a: Interval, b: Interval) -> Optional[Interval]:
70
+ lo = max(a[0], b[0])
71
+ hi = min(a[1], b[1])
72
+ return (lo, hi) if lo < hi else None
73
+
74
+
75
+ def overlap(a: Sequence[Interval], b: Sequence[Interval]) -> List[Interval]:
76
+ """Every sub-interval present in both ``a`` and ``b``."""
77
+ out: List[Interval] = []
78
+ for ia in a:
79
+ for ib in b:
80
+ clipped = _clip(ia, ib)
81
+ if clipped is not None:
82
+ out.append(clipped)
83
+ return merge(out)
84
+
85
+
86
+ def subtract(a: Sequence[Interval], b: Sequence[Interval]) -> List[Interval]:
87
+ """The parts of ``a`` not covered by any interval in ``b``."""
88
+ b_merged = merge(b)
89
+ out: List[Interval] = []
90
+ for t0, t1 in a:
91
+ cursor = t0
92
+ for b0, b1 in b_merged:
93
+ if b1 <= cursor or b0 >= t1:
94
+ continue
95
+ if b0 > cursor:
96
+ out.append((cursor, min(b0, t1)))
97
+ cursor = max(cursor, b1)
98
+ if cursor >= t1:
99
+ break
100
+ if cursor < t1:
101
+ out.append((cursor, t1))
102
+ return out
103
+
104
+
105
+ def merge(intervals: Sequence[Interval], *, gap: float = 0.0) -> List[Interval]:
106
+ """
107
+ Merge overlapping (or, with ``gap > 0``, near-adjacent) intervals into
108
+ the smallest equivalent set, sorted by start time.
109
+ """
110
+ if not intervals:
111
+ return []
112
+ ordered = sorted(intervals, key=lambda iv: iv[0])
113
+ out: List[Interval] = [ordered[0]]
114
+ for t0, t1 in ordered[1:]:
115
+ last0, last1 = out[-1]
116
+ if t0 <= last1 + gap:
117
+ out[-1] = (last0, max(last1, t1))
118
+ else:
119
+ out.append((t0, t1))
120
+ return out
121
+
122
+
123
+ def around(events: Sequence[float], window: Interval) -> List[Interval]:
124
+ """
125
+ A peri-event interval ``(t + window[0], t + window[1])`` around each
126
+ event timestamp in ``events``. ``window`` is an offset pair, e.g.
127
+ ``(-5, 30)`` for "5s before to 30s after".
128
+ """
129
+ lo, hi = window
130
+ return [(t + lo, t + hi) for t in events]
131
+
132
+
133
+ def count_in(events: Sequence[float], intervals: Sequence[Interval]) -> int:
134
+ """How many of ``events`` fall inside any of ``intervals`` (inclusive)."""
135
+ merged = merge(intervals)
136
+ return sum(1 for t in events if any(t0 <= t <= t1 for t0, t1 in merged))
137
+
138
+
139
+ def rate_in(events: Sequence[float], intervals: Sequence[Interval]) -> Optional[float]:
140
+ """Events per second, over the total (merged) duration of ``intervals``.
141
+
142
+ None if the total duration is zero, rather than raising or returning
143
+ an infinite rate.
144
+ """
145
+ merged = merge(intervals)
146
+ total = sum(t1 - t0 for t0, t1 in merged)
147
+ if total <= 0:
148
+ return None
149
+ return count_in(events, merged) / total
@@ -0,0 +1,127 @@
1
+ """analysis/session.py — the one-line entry point: name/glob/path -> Session.
2
+
3
+ ``load_session`` is what makes analysis start with one import and one call
4
+ instead of five imports across four modules (loader, session, metrics,
5
+ logs). See the module docstring of ``sfm_analysis.analysis`` for a worked
6
+ example.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, List, Optional
14
+
15
+ from ..report import load_runs
16
+ from ..report.metrics import RunMetrics, compute_run_metrics
17
+ from ..storage import default_log_dir
18
+ from ..targeting import resolve_targets
19
+
20
+ if TYPE_CHECKING:
21
+ from ..report.session import RunData
22
+
23
+
24
+ class SessionNotFoundError(LookupError):
25
+ """Raised by load_session when a target matches no session CSV."""
26
+
27
+
28
+ class AmbiguousSessionError(LookupError):
29
+ """Raised by load_session when a target matches more than one session
30
+ CSV — pass an exact session name or an explicit path to disambiguate,
31
+ or use sfm_analysis.report.loader.discover_sessions directly if you
32
+ actually want every match."""
33
+
34
+
35
+ @dataclass
36
+ class Session:
37
+ """One session CSV, loaded and split into runs, with metrics for every
38
+ run precomputed once. Returned by ``load_session`` — see that
39
+ function's docstring for how to get one.
40
+
41
+ A CSV can hold more than one *run* (reopening a session name appends
42
+ to the file and starts a new run_id — see ``.runs``); most of what
43
+ you want is on the run that actually happened, not an aborted
44
+ restart, which is what ``.run()`` picks by default.
45
+ """
46
+
47
+ name: str
48
+ path: Path
49
+ runs: "List[RunData]" = field(default_factory=list)
50
+ metrics: List[RunMetrics] = field(default_factory=list)
51
+
52
+ def run(self, run_id: Optional[int] = None) -> "RunData":
53
+ """
54
+ The run to use when exactly one is needed.
55
+
56
+ With ``run_id`` given, returns that run or raises LookupError.
57
+ Otherwise: the only run if there's just one, else the run with
58
+ the most rows -- a session file with a 9-row aborted restart
59
+ followed by the real 500-row session should hand you the real
60
+ one by default, not silently the first (usually abortive) run_id.
61
+ Use ``.runs`` directly if you want every run, or pass
62
+ ``run_id=...`` to every table method to be explicit.
63
+ """
64
+ if run_id is not None:
65
+ for r in self.runs:
66
+ if r.run_id == run_id:
67
+ return r
68
+ available = sorted(r.run_id for r in self.runs)
69
+ raise LookupError(f"No run_id={run_id} in {self.name!r} (available: {available})")
70
+ if len(self.runs) == 1:
71
+ return self.runs[0]
72
+ return max(self.runs, key=lambda r: len(r.rows))
73
+
74
+ def metrics_for(self, run_id: Optional[int] = None) -> RunMetrics:
75
+ """RunMetrics for the same run ``.run(run_id)`` would return."""
76
+ target = self.run(run_id)
77
+ idx = self.runs.index(target)
78
+ return self.metrics[idx]
79
+
80
+ def cycles_table(self, run_id: Optional[int] = None) -> List[dict]:
81
+ from .tables import cycles_table
82
+ return cycles_table(self, run_id=run_id)
83
+
84
+ def bouts_table(self, run_id: Optional[int] = None) -> List[dict]:
85
+ from .tables import bouts_table
86
+ return bouts_table(self, run_id=run_id)
87
+
88
+ def events_table(self, run_id: Optional[int] = None, **filters) -> List[dict]:
89
+ from .tables import events_table
90
+ return events_table(self, run_id=run_id, **filters)
91
+
92
+ def trials_table(self, run_id: Optional[int] = None) -> List[dict]:
93
+ from .tables import trials_table
94
+ return trials_table(self, run_id=run_id)
95
+
96
+
97
+ def load_session(target: str, *, log_dir: Optional[Path] = None) -> Session:
98
+ """
99
+ Resolve ``target`` — a session name, shell glob, or explicit path to a
100
+ ``.csv`` — against ``log_dir`` (default: the same auto-detection
101
+ ``sfm-report`` uses: ``$SFM_LOG_DIR``, else an external drive's
102
+ ``sfm_logs/``, else ``~/sfm_logs``) and load it.
103
+
104
+ Raises ``SessionNotFoundError`` if nothing matches, or
105
+ ``AmbiguousSessionError`` if a glob matches more than one session —
106
+ pass an exact name or path instead.
107
+
108
+ Example::
109
+
110
+ from sfm_analysis.analysis import load_session
111
+
112
+ s = load_session("EXP-Test-02")
113
+ s = load_session("cohortA_M014_d3.csv", log_dir="/data/sfm_logs")
114
+ """
115
+ resolved_log_dir = Path(log_dir) if log_dir is not None else Path(default_log_dir())
116
+ matches = resolve_targets([target], resolved_log_dir)
117
+ if not matches:
118
+ raise SessionNotFoundError(f"No session matched {target!r} in {resolved_log_dir}")
119
+ if len(matches) > 1:
120
+ names = ", ".join(p.stem for p in matches)
121
+ raise AmbiguousSessionError(
122
+ f"{target!r} matched {len(matches)} sessions in {resolved_log_dir}: {names}"
123
+ )
124
+ path = matches[0]
125
+ runs = load_runs(path)
126
+ metrics = [compute_run_metrics(r) for r in runs]
127
+ return Session(name=path.stem, path=path, runs=runs, metrics=metrics)
@@ -0,0 +1,203 @@
1
+ """tables.py — tidy list[dict] tables for custom analysis.
2
+
3
+ Every function here flattens one kind of already-computed data (cycles,
4
+ bouts, raw events, trial markers) into a list of plain dicts, one dict
5
+ per row, every row carrying the same keys. Feed the result straight to
6
+ ``pd.DataFrame(...)`` if you have pandas, or iterate it directly.
7
+
8
+ Deliberately not a query DSL: filter/group/join in pandas (or plain
9
+ Python) once you have the table, not through a bespoke API here. See
10
+ ``to_dataframe()`` for the pandas handoff.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple
16
+
17
+ if TYPE_CHECKING:
18
+ from ..report.session import RunData
19
+ from .session import Session
20
+
21
+
22
+ def _runs_and_metrics(session: "Session", run_id: Optional[int]) -> List[Tuple["RunData", Any]]:
23
+ if run_id is not None:
24
+ return [(session.run(run_id), session.metrics_for(run_id))]
25
+ return list(zip(session.runs, session.metrics))
26
+
27
+
28
+ def cycles_table(session: "Session", run_id: Optional[int] = None) -> List[Dict[str, Any]]:
29
+ """
30
+ One row per reconstructed dispense cycle (``metrics.Cycle``), across
31
+ every run in ``session`` unless ``run_id`` is given.
32
+
33
+ Example::
34
+
35
+ rows = s.cycles_table()
36
+ latencies = [r["retrieval_latency"] for r in rows if r["retrieval_latency"] is not None]
37
+ """
38
+ out: List[Dict[str, Any]] = []
39
+ for run, m in _runs_and_metrics(session, run_id):
40
+ for c in m.cycles:
41
+ out.append({
42
+ "session": session.name,
43
+ "run_id": run.run_id,
44
+ "node": c.node,
45
+ "trial": c.trial,
46
+ "index": c.index,
47
+ "fed": c.fed,
48
+ "cmd_t": c.cmd_t,
49
+ "lowering_t": c.lowering_t,
50
+ "loading_t": c.loading_t,
51
+ "on_plate_t": c.on_plate_t,
52
+ "raising_t": c.raising_t,
53
+ "seeking_t": c.seeking_t,
54
+ "dwelling_t": c.dwelling_t,
55
+ "ready_t": c.ready_t,
56
+ "first_presence_t": c.first_presence_t,
57
+ "first_dome_t": c.first_dome_t,
58
+ "taken_t": c.taken_t,
59
+ "end_t": c.end_t,
60
+ "censored": c.censored,
61
+ "cycle_duration": c.cycle_duration,
62
+ "approach_latency": c.approach_latency,
63
+ "dome_latency": c.dome_latency,
64
+ "handling_time": c.handling_time,
65
+ "retrieval_latency": c.retrieval_latency,
66
+ "approached_without_dome": c.approached_without_dome,
67
+ "dome_without_take": c.dome_without_take,
68
+ })
69
+ return out
70
+
71
+
72
+ def bouts_table(session: "Session", run_id: Optional[int] = None) -> List[Dict[str, Any]]:
73
+ """
74
+ One row per presence bout, dome-open bout, and fault interval,
75
+ unified under a ``kind`` column (``"presence"`` | ``"dome"`` |
76
+ ``"fault"``). A ``censored`` row was still open when the run ended —
77
+ its ``t1``/``dur`` are ``None``, not zero; filter deliberately (see
78
+ the "five things to know" section of the README) rather than letting
79
+ a censored bout silently become a zero-duration one in an average.
80
+ """
81
+ out: List[Dict[str, Any]] = []
82
+ for run, m in _runs_and_metrics(session, run_id):
83
+ presence_bouts, _ = m.presence
84
+ dome_bouts, _ = m.dome
85
+ for kind, bouts in (("presence", presence_bouts), ("dome", dome_bouts)):
86
+ for b in bouts:
87
+ out.append({
88
+ "session": session.name,
89
+ "run_id": run.run_id,
90
+ "kind": kind,
91
+ "node": b.node,
92
+ "t0": b.t0,
93
+ "t1": b.t1,
94
+ "dur": b.dur,
95
+ "censored": b.censored,
96
+ "code": None,
97
+ "inferred_close": None,
98
+ })
99
+ for f in m.faults:
100
+ out.append({
101
+ "session": session.name,
102
+ "run_id": run.run_id,
103
+ "kind": "fault",
104
+ "node": f.node,
105
+ "t0": f.t0,
106
+ "t1": f.t1,
107
+ "dur": f.dur,
108
+ "censored": f.censored,
109
+ "code": f.code.name,
110
+ "inferred_close": f.inferred_close,
111
+ })
112
+ return out
113
+
114
+
115
+ def events_table(
116
+ session: "Session",
117
+ run_id: Optional[int] = None,
118
+ *,
119
+ source: Optional[str] = None,
120
+ frame_type: Optional[str] = None,
121
+ event_name: Optional[str] = None,
122
+ ) -> List[Dict[str, Any]]:
123
+ """
124
+ One row per raw log row (``LogRow``), optionally filtered by
125
+ ``source`` (``"CAN"``/``"EXP"``/``"BNC"``/``"SYS"``), ``frame_type``,
126
+ or ``event_name``.
127
+
128
+ This is the escape hatch: every row sfm_analysis parses is here, with
129
+ its ``fields_json`` already decoded into a ``fields`` dict. Prefer
130
+ ``cycles_table()`` / ``bouts_table()`` for anything they already
131
+ cover — this is for the question those don't answer.
132
+
133
+ Watch the "Dome Opened" trap if you filter on it directly:
134
+ ``event_name="Dome Opened"`` matches two different wire events (a
135
+ milestone and a renamed edge — see the README) and double-counts
136
+ every physical opening. ``bouts_table(kind="dome")`` already does
137
+ the dedup; use that instead of filtering this table by name.
138
+ """
139
+ out: List[Dict[str, Any]] = []
140
+ for run, _m in _runs_and_metrics(session, run_id):
141
+ for row in run.rows:
142
+ if source is not None and row.source != source:
143
+ continue
144
+ if frame_type is not None and row.frame_type != frame_type:
145
+ continue
146
+ if event_name is not None and row.event_name != event_name:
147
+ continue
148
+ out.append({
149
+ "session": session.name,
150
+ "run_id": run.run_id,
151
+ "t": row.t,
152
+ "iso": row.iso,
153
+ "node_id": row.node_id,
154
+ "trial": row.trial,
155
+ "source": row.source,
156
+ "direction": row.direction,
157
+ "frame_type": row.frame_type,
158
+ "event_name": row.event_name,
159
+ "fields": row.fields,
160
+ "details": row.details,
161
+ "post_session": row.post_session,
162
+ })
163
+ return out
164
+
165
+
166
+ def trials_table(session: "Session", run_id: Optional[int] = None) -> List[Dict[str, Any]]:
167
+ """
168
+ One row per generic ``trial`` marker row (EXPERIMENT source,
169
+ ``event_name="trial"``) — the trial-boundary event every experiment
170
+ template logs, regardless of what happens inside the trial. For
171
+ template-specific trial structure (choice, reward, block), see the
172
+ corresponding ``sfm_analysis.report.analyses`` module instead (e.g.
173
+ ``analyses.bandit.build_trials`` for ``two_armed_bandit``).
174
+ """
175
+ out: List[Dict[str, Any]] = []
176
+ for run, _m in _runs_and_metrics(session, run_id):
177
+ for row in run.exp("trial"):
178
+ out.append({
179
+ "session": session.name,
180
+ "run_id": run.run_id,
181
+ "t": row.t,
182
+ "trial": row.trial,
183
+ "fields": row.fields,
184
+ })
185
+ return out
186
+
187
+
188
+ def to_dataframe(rows: Sequence[Dict[str, Any]]):
189
+ """
190
+ Convert a tidy table (a ``list[dict]``, e.g. from ``cycles_table()``)
191
+ into a pandas ``DataFrame``. Requires pandas to be installed
192
+ (``pip install sfm-analysis[pandas]``); raises a clear ``ImportError``
193
+ naming that extra if it isn't, rather than a bare
194
+ ``ModuleNotFoundError`` from deep inside pandas.
195
+ """
196
+ try:
197
+ import pandas as pd
198
+ except ImportError as exc:
199
+ raise ImportError(
200
+ "to_dataframe() needs pandas -- install it with "
201
+ "`pip install sfm-analysis[pandas]`"
202
+ ) from exc
203
+ return pd.DataFrame(list(rows))
@@ -0,0 +1 @@
1
+ """cli — command-line entry points for sfm_analysis."""