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.
- sfm_analysis/__init__.py +18 -0
- sfm_analysis/analysis/__init__.py +50 -0
- sfm_analysis/analysis/intervals.py +149 -0
- sfm_analysis/analysis/session.py +127 -0
- sfm_analysis/analysis/tables.py +203 -0
- sfm_analysis/cli/__init__.py +1 -0
- sfm_analysis/cli/report.py +309 -0
- sfm_analysis/logs.py +53 -0
- sfm_analysis/protocol.py +564 -0
- sfm_analysis/py.typed +0 -0
- sfm_analysis/report/__init__.py +137 -0
- sfm_analysis/report/align.py +49 -0
- sfm_analysis/report/analyses/__init__.py +6 -0
- sfm_analysis/report/analyses/bandit.py +354 -0
- sfm_analysis/report/analyses/fixed_random.py +79 -0
- sfm_analysis/report/analyses/free_feeding.py +86 -0
- sfm_analysis/report/analyses/probability.py +62 -0
- sfm_analysis/report/charts.py +694 -0
- sfm_analysis/report/demo/Bandit_test_01.csv +522 -0
- sfm_analysis/report/demo/Bandit_test_01_heartbeats.csv +45 -0
- sfm_analysis/report/demo/__init__.py +40 -0
- sfm_analysis/report/designs/__init__.py +12 -0
- sfm_analysis/report/designs/default.json +30 -0
- sfm_analysis/report/designs/fixed_and_random.json +29 -0
- sfm_analysis/report/designs/free_feeding.json +30 -0
- sfm_analysis/report/designs/probability_delivery.json +29 -0
- sfm_analysis/report/designs/two_armed_bandit.json +39 -0
- sfm_analysis/report/explorer.py +101 -0
- sfm_analysis/report/explorer_render.py +576 -0
- sfm_analysis/report/loader.py +311 -0
- sfm_analysis/report/metrics.py +647 -0
- sfm_analysis/report/naming.py +137 -0
- sfm_analysis/report/render.py +169 -0
- sfm_analysis/report/schema.py +236 -0
- sfm_analysis/report/sections/__init__.py +8 -0
- sfm_analysis/report/sections/bandit.py +224 -0
- sfm_analysis/report/sections/compare.py +278 -0
- sfm_analysis/report/sections/fixed_random.py +90 -0
- sfm_analysis/report/sections/free_feeding.py +107 -0
- sfm_analysis/report/sections/generic.py +384 -0
- sfm_analysis/report/sections/probability.py +100 -0
- sfm_analysis/report/sections/timeline.py +294 -0
- sfm_analysis/report/session.py +195 -0
- sfm_analysis/report/stats.py +158 -0
- sfm_analysis/report/style.py +237 -0
- sfm_analysis/report/timeline_data.py +143 -0
- sfm_analysis/report/timezones.py +85 -0
- sfm_analysis/storage.py +81 -0
- sfm_analysis/targeting.py +54 -0
- sfm_analysis-0.1.0.dist-info/METADATA +434 -0
- sfm_analysis-0.1.0.dist-info/RECORD +55 -0
- sfm_analysis-0.1.0.dist-info/WHEEL +5 -0
- sfm_analysis-0.1.0.dist-info/entry_points.txt +2 -0
- sfm_analysis-0.1.0.dist-info/licenses/LICENSE +21 -0
- sfm_analysis-0.1.0.dist-info/top_level.txt +1 -0
sfm_analysis/__init__.py
ADDED
|
@@ -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."""
|