tpattern 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.
tpattern/guided.py ADDED
@@ -0,0 +1,372 @@
1
+ """
2
+ A guided, transparent analysis workflow.
3
+ ========================================
4
+
5
+ Wraps the library's pieces — read the data, inspect it, take the tool's
6
+ recommendation, run the calibrated analysis, and write the outputs — into a
7
+ step-by-step flow a non-programmer can follow. Every choice is shown and
8
+ overridable, so the analysis stays transparent and repeatable.
9
+
10
+ Two entry points:
11
+
12
+ run_analysis(...) pure function: load -> advise -> calibrate -> report ->
13
+ Methods text. No UI; used by scripts, tests and the widget.
14
+ launch() an interactive wizard (file upload, radio buttons, run,
15
+ download) for Google Colab / Jupyter. Needs ipywidgets.
16
+
17
+ The logic lives in run_analysis so it can be tested headlessly; launch() is a
18
+ thin UI over it.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import csv
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+
27
+ from .io import read_table
28
+ from .detect import Config
29
+ from .significance import calibrate
30
+ from .report import report, patterns_table
31
+ from .methods import methods_text
32
+ from .advisor import recommend
33
+
34
+ #: the most recent CalibrationResult (set by launch()); use it to redraw figures or
35
+ #: rebuild tables, e.g. patterns_overview([c.pattern for c in tpattern.guided.last.kept("fdr")], ...)
36
+ last = None
37
+
38
+
39
+ @dataclass
40
+ class GuidedResult:
41
+ outdir: str
42
+ files: dict = field(default_factory=dict) # label -> path
43
+ n_detected: int = 0
44
+ n_survivors: int = 0
45
+ methods: str = ""
46
+ recommendation: str = ""
47
+ survivors: list = field(default_factory=list) # [{pattern, N, q}] surviving FDR
48
+ calibration: object = None # the CalibrationResult (for re-plotting)
49
+ ci_unit: str = ""
50
+
51
+
52
+ def run_analysis(path, *, observation="observation", event="event", start="start",
53
+ obs_start=None, obs_end=None, time_unit="s",
54
+ null="profile", min_lag=1, B=200, q_target=0.05, alpha=0.005,
55
+ seed=20260714, outdir="tpattern_output", title="T-pattern analysis",
56
+ build_event_from=None):
57
+ """Run the whole pipeline and write the outputs. Returns a GuidedResult.
58
+
59
+ This is the engine behind the wizard: it reads the canonical table, records
60
+ what the tool would recommend (for transparency), calibrates with the chosen
61
+ settings, writes the report (table, dendrograms, summary) plus an Excel copy
62
+ of the results table, and generates the paste-ready Methods paragraph.
63
+ """
64
+ obs = read_table(path, observation=observation, event=event, start=start,
65
+ obs_start=obs_start, obs_end=obs_end, time_unit=time_unit,
66
+ build_event_from=build_event_from)
67
+ if not obs:
68
+ raise ValueError("No observations were read — check the column names and "
69
+ "that the file has one row per event (see SCHEMA.md).")
70
+
71
+ rec = str(recommend(obs))
72
+ cfg = Config(min_lag=min_lag)
73
+ result = calibrate(obs, cfg, null=null, B=B, alpha=alpha, q_target=q_target,
74
+ seed=seed)
75
+
76
+ Path(outdir).mkdir(parents=True, exist_ok=True)
77
+ written = report(result, outdir, title=title,
78
+ ci_unit=" ms" if time_unit == "ms" else " s")
79
+
80
+ # an Excel-friendly copy of the surviving patterns (falls back to CSV-only)
81
+ xlsx = _write_excel(result, q_target, Path(outdir) / "results.xlsx")
82
+ if xlsx:
83
+ written["excel"] = xlsx
84
+
85
+ methods = methods_text(cfg, observations=obs, calibration=result)
86
+ Path(outdir, "METHODS.txt").write_text(methods)
87
+ written["methods"] = str(Path(outdir, "METHODS.txt"))
88
+
89
+ survivors = [{"pattern": str(c.pattern), "N": c.N, "q": round(c.fdr_q, 4)}
90
+ for c in sorted(result.kept("fdr"), key=lambda c: c.p_emp)]
91
+
92
+ return GuidedResult(
93
+ outdir=outdir, files=written,
94
+ n_detected=len(result.real), n_survivors=len(result.kept("fdr")),
95
+ methods=methods, recommendation=rec, survivors=survivors,
96
+ calibration=result, ci_unit=" ms" if time_unit == "ms" else " s")
97
+
98
+
99
+ def _write_excel(result, q_target, path):
100
+ """Write the results table to .xlsx if pandas+openpyxl are available; else
101
+ a plain CSV next to it. Returns the path written, or None."""
102
+ rows = []
103
+ for c in sorted(result.real, key=lambda c: c.p_emp):
104
+ rows.append({
105
+ "pattern": str(c.pattern), "N": c.N, "level": c.level,
106
+ "critical_interval": f"[{c.ci[0]},{c.ci[1]}]" if c.ci else "",
107
+ "p_empirical": round(c.p_emp, 4), "q_FDR": round(c.fdr_q, 4),
108
+ "survives_FDR": int(c.fdr_q <= q_target), "survives_FWER": int(c.fwer_keep),
109
+ })
110
+ try:
111
+ import pandas as pd
112
+ pd.DataFrame(rows).to_excel(path, index=False)
113
+ return str(path)
114
+ except Exception:
115
+ csv_path = path.with_suffix(".csv")
116
+ if rows:
117
+ with open(csv_path, "w", newline="") as fh:
118
+ w = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
119
+ w.writeheader(); w.writerows(rows)
120
+ return str(csv_path)
121
+ return None
122
+
123
+
124
+ # ------------------------------------------------------------------- the wizard
125
+ def launch():
126
+ """Interactive wizard for Google Colab / Jupyter.
127
+
128
+ Guides the user through: upload a CSV -> map the columns -> see the tool's
129
+ inspection and recommendation -> confirm or change the settings (radio
130
+ buttons, pre-set to the recommendation) -> run -> download the outputs and
131
+ copy the Methods sentence. Requires ipywidgets (pip install 'tpattern[gui]').
132
+ """
133
+ try:
134
+ import ipywidgets as W
135
+ from IPython.display import display, clear_output
136
+ except ImportError as e: # pragma: no cover - UI only
137
+ raise ImportError(
138
+ "launch() needs ipywidgets — install with `pip install ipywidgets` "
139
+ "(or `pip install 'tpattern[gui]'`) and run in Colab/Jupyter. For a "
140
+ "non-interactive run use run_analysis(...).") from e
141
+
142
+ import csv as _csv
143
+ from collections import Counter
144
+ state = {"path": None}
145
+ HELP = "color:#5b6270;font-size:90%"
146
+ STYLE = {"description_width": "initial"}
147
+ WIDE = W.Layout(width="480px")
148
+
149
+ # ---------- Step 1: file + columns ----------
150
+ upload = W.FileUpload(accept=".csv", multiple=False, description="1. Upload CSV")
151
+ cols_info = W.HTML("")
152
+ obs_c = W.Text(value="observation", description="observation", style=STYLE)
153
+ ev_c = W.Text(value="event", description="event", style=STYLE)
154
+ st_c = W.Text(value="start", description="start", style=STYLE)
155
+ unit = W.RadioButtons(options=["s", "ms"], value="s", description="time unit",
156
+ style=STYLE)
157
+ unit_help = W.HTML(f"<span style='{HELP}'>seconds by default; choose <b>ms</b> if "
158
+ "your timestamps are whole numbers of milliseconds.</span>")
159
+ advise_btn = W.Button(description="2. Inspect & advise", button_style="info",
160
+ layout=W.Layout(width="200px"))
161
+ advice_out = W.Output()
162
+
163
+ # ---------- Step 3: method settings (revealed after inspect) ----------
164
+ null_w = W.RadioButtons(options=["profile", "rotation"], value="profile",
165
+ description="null", style=STYLE)
166
+ null_help = W.HTML(f"<span style='{HELP}'><b>profile</b> (recommended): checks a "
167
+ "pattern is a real link between actions, not just their usual "
168
+ "timing. <b>rotation</b>: the simpler THEME-style shuffle.</span>")
169
+ lag_w = W.RadioButtons(
170
+ options=[("require a genuine lag (min_lag = 1)", 1),
171
+ ("allow co-timing (min_lag = 0)", 0)],
172
+ value=1, description="lag", style=STYLE, layout=WIDE)
173
+ lag_help = W.HTML(f"<span style='{HELP}'><b>genuine lag</b> (recommended for "
174
+ "video/frame data): events at the same timestamp are treated as "
175
+ "happening together, not as a sequence. <b>co-timing</b>: keeps "
176
+ "them as ordered (THEME's default).</span>")
177
+ B_w = W.IntSlider(value=200, min=100, max=2000, step=100, description="surrogates B",
178
+ style=STYLE)
179
+ B_help = W.HTML(f"<span style='{HELP}'>more surrogates = finer p-values; "
180
+ "family-wise (FWER) claims need B in the thousands.</span>")
181
+ run_btn = W.Button(description="3. Run analysis", button_style="success",
182
+ layout=W.Layout(width="200px"))
183
+ settings_box = W.VBox(
184
+ [W.HTML("<hr><b>Recommended method settings</b> — pre-set from your data; "
185
+ "each shows why. Override if you wish, then Run."),
186
+ null_w, null_help, lag_w, lag_help, B_w, B_help, run_btn],
187
+ layout=W.Layout(display="none")) # hidden until Inspect & advise
188
+ results_out = W.Output()
189
+
190
+ def _save_upload():
191
+ if not upload.value:
192
+ return None
193
+ item = list(upload.value.values())[0] if isinstance(upload.value, dict) \
194
+ else upload.value[0]
195
+ content = item["content"]
196
+ p = Path("uploaded_events.csv")
197
+ p.write_bytes(content if isinstance(content, (bytes, bytearray))
198
+ else bytes(content))
199
+ return str(p)
200
+
201
+ def _on_upload(_):
202
+ path = _save_upload()
203
+ if not path:
204
+ return
205
+ state["path"] = path
206
+ with open(path, newline="") as fh:
207
+ reader = _csv.reader(fh)
208
+ header = next(reader, [])
209
+ row2 = next(reader, [])
210
+ info = ("<b>Columns in your file:</b> "
211
+ + ", ".join(f"<code>{c}</code>" for c in header)
212
+ + f"<br><span style='{HELP}'>Set the three boxes below to match these "
213
+ "(defaults: observation / event / start).</span>")
214
+ try:
215
+ si = header.index(st_c.value) if st_c.value in header else 2
216
+ v = float(row2[si])
217
+ if v.is_integer() and abs(v) >= 1000:
218
+ info += ("<br><b style='color:#b5342a'>Tip:</b> your start times look "
219
+ "like <b>milliseconds</b> — select <b>ms</b> above.")
220
+ except Exception:
221
+ pass
222
+ cols_info.value = info
223
+
224
+ upload.observe(_on_upload, names="value")
225
+
226
+ def _data_quality(obs):
227
+ """(exact-duplicate rows, fraction of consecutive events that are co-timed)."""
228
+ dup = same = total = 0
229
+ for o in obs:
230
+ counts = Counter((t, e) for t, e in o.events)
231
+ dup += sum(c - 1 for c in counts.values() if c > 1)
232
+ ts = [t for t, _ in o.events]
233
+ for i in range(1, len(ts)):
234
+ total += 1
235
+ same += (ts[i] == ts[i - 1])
236
+ return dup, (same / total if total else 0.0)
237
+
238
+ def _on_advise(_):
239
+ with advice_out:
240
+ clear_output()
241
+ if not state.get("path"):
242
+ state["path"] = _save_upload()
243
+ if not state.get("path"):
244
+ print("Upload a CSV first."); return
245
+ obs = read_table(state["path"], observation=obs_c.value, event=ev_c.value,
246
+ start=st_c.value, time_unit=unit.value)
247
+ rec = recommend(obs)
248
+ for c in rec.choices: # pre-set the widgets from the data
249
+ if c.option == "Null":
250
+ null_w.value = "profile" if "profile" in c.recommended.lower() else "rotation"
251
+ if c.option == "Minimum lag":
252
+ lag_w.value = 1 if "min_lag = 1" in c.recommended else 0
253
+ from IPython.display import display as _d, HTML
254
+ _d(HTML(f"<b>Your data:</b> {len(obs)} observations, "
255
+ f"{sum(len(o.events) for o in obs)} events."))
256
+ _d(HTML("<b>What the tool recommends for your data</b>, and why (in plain "
257
+ "terms):<ul>"
258
+ + "".join(f"<li style='margin-bottom:6px'>{c.plain}</li>"
259
+ for c in rec.choices) + "</ul>"))
260
+ _d(HTML("<details><summary>Show the technical rationale (for your methods "
261
+ "section)</summary><pre style='white-space:pre-wrap;font-size:88%'>"
262
+ + "\n\n".join(f"[{c.option}] -> {c.recommended}\n{c.rationale}"
263
+ for c in rec.choices) + "</pre></details>"))
264
+ dup, cofrac = _data_quality(obs)
265
+ msg = (f"<hr><b>Data-quality check</b><br>{cofrac:.0%} of consecutive events "
266
+ "share a timestamp. This is <b>normal for video/frame-coded data</b> — "
267
+ "different actions coded at the same video frame (e.g. an interception "
268
+ "and the challenge that caused it) — and is <b>not an error</b>. The "
269
+ "recommended 'genuine lag' setting handles it correctly.")
270
+ if dup:
271
+ msg += (f"<br><b style='color:#b5342a'>Worth checking:</b> {dup} exact "
272
+ "duplicate row(s) (identical observation, event and time) — these "
273
+ "may be accidental double-entries.")
274
+ else:
275
+ msg += ("<br>No exact duplicate rows found — the shared timestamps are all "
276
+ "genuine co-occurrences.")
277
+ _d(HTML(msg))
278
+ settings_box.layout.display = "" # reveal step 3
279
+
280
+ def _colored_table(rows):
281
+ bg = {"r": "#e7f5ea", "b": "#fdf3e0", "c": "#f5f5f6"}
282
+ head = ["pattern", "times seen", "timing", "reliability (q, lower = stronger)",
283
+ "what it means"]
284
+ h = ["<table style='border-collapse:collapse;font-size:90%;width:100%'>",
285
+ "<tr>" + "".join(f"<th style='text-align:left;padding:5px 9px;"
286
+ f"border-bottom:2px solid #ccc'>{c}</th>" for c in head)
287
+ + "</tr>"]
288
+ for r in rows:
289
+ q = r["fdr_q"]
290
+ v = "r" if q <= 0.05 else "b" if q <= 0.10 else "c"
291
+ cells = [r["pattern"], r["N"], r["critical_interval"], f"{q:.3g}",
292
+ r["interpretation"]]
293
+ h.append(f"<tr style='background:{bg[v]}'>"
294
+ + "".join(f"<td style='padding:5px 9px;border-bottom:1px solid #eee'>"
295
+ f"{c}</td>" for c in cells) + "</tr>")
296
+ h.append("</table>")
297
+ return "".join(h)
298
+
299
+ def _on_run(_):
300
+ with results_out:
301
+ clear_output()
302
+ if not state.get("path"):
303
+ print("Upload a CSV and click Inspect & advise first."); return
304
+ from IPython.display import display as _d, HTML, Image
305
+ _d(HTML("<p>Running… calibration with many surrogates can take a minute.</p>"))
306
+ res = run_analysis(state["path"], observation=obs_c.value,
307
+ event=ev_c.value, start=st_c.value, time_unit=unit.value,
308
+ null=null_w.value, min_lag=lag_w.value, B=B_w.value)
309
+ globals()["last"] = res.calibration # tpattern.guided.last, for re-plotting
310
+ clear_output()
311
+
312
+ rows = patterns_table(res.calibration, ci_unit=res.ci_unit)
313
+ robust = sum(1 for r in rows if r["fdr_q"] <= 0.05)
314
+ border = sum(1 for r in rows if 0.05 < r["fdr_q"] <= 0.10)
315
+ chance = len(rows) - robust - border
316
+ _d(HTML(
317
+ f"<h3>Results</h3><p>The tool found <b>{res.n_detected}</b> candidate "
318
+ f"patterns and tested each against chance. <b>All {res.n_detected} are "
319
+ "listed below — nothing is removed</b>; the verdict tells you which to "
320
+ "trust:</p><ul>"
321
+ f"<li>🟢 <b>{robust} robust</b> — statistically reliable; safe to report.</li>"
322
+ f"<li>🟡 <b>{border} borderline</b> — near the threshold; treat with caution.</li>"
323
+ f"<li>⚪ <b>{chance} likely chance</b> — not distinguishable from random.</li>"
324
+ "</ul>"))
325
+ if robust == 0 and B_w.value < 2000:
326
+ _d(HTML(f"<p style='color:#b5342a'>Nothing reached 'robust' at "
327
+ f"B = {B_w.value} — borderline patterns need more surrogates. "
328
+ "Raise <b>surrogates B</b> toward 2000 and re-run before "
329
+ "concluding there is no signal.</p>"))
330
+
331
+ _d(HTML("<p style='color:#5b6270;font-size:90%'>How to read the table: each "
332
+ "row is a pattern. <b>q</b> is the risk it is a fluke — the "
333
+ "false-discovery rate (FDR); lower is stronger, and below 0.05 "
334
+ "counts as robust. Rows are coloured 🟢&nbsp;reliable · "
335
+ "🟡&nbsp;borderline · ⚪&nbsp;likely chance.</p>"))
336
+ _d(HTML(_colored_table(rows))) # colour-coded results table
337
+
338
+ dpath = res.files.get("dendrograms")
339
+ if dpath and Path(dpath).exists():
340
+ _d(HTML("<b>Pattern dendrograms</b> (top patterns):"))
341
+ _d(Image(dpath))
342
+
343
+ _d(HTML("<details><summary><b>Customise the figures &amp; table</b> "
344
+ "(click to expand)</summary><pre>"
345
+ "from tpattern import patterns_overview, pattern_dendrogram, patterns_table\n"
346
+ "import tpattern.guided as g\n"
347
+ "keep = [c.pattern for c in g.last.kept('fdr')] # surviving patterns\n"
348
+ f"patterns_overview(keep, 'dendrograms.png', ci_unit='{res.ci_unit}', max_rows=8)\n"
349
+ f"pattern_dendrogram(keep[0], title='my title', ci_unit='{res.ci_unit}', outfile='one.png')\n"
350
+ f"patterns_table(g.last, 'table.csv', ci_unit='{res.ci_unit}')</pre></details>"))
351
+
352
+ _d(HTML("<b>Methods</b> (paste into your write-up):"))
353
+ print(res.methods)
354
+
355
+ import shutil
356
+ zpath = shutil.make_archive(res.outdir, "zip", res.outdir)
357
+ try:
358
+ from google.colab import files # pragma: no cover
359
+ files.download(zpath)
360
+ except Exception:
361
+ print(f"\nAll outputs saved to {res.outdir}/ (download: {zpath})")
362
+
363
+ advise_btn.on_click(_on_advise)
364
+ run_btn.on_click(_on_run)
365
+ display(W.VBox([
366
+ W.HTML("<h3>tpattern — guided analysis</h3>"
367
+ "<p>Four steps: <b>upload</b> your events → <b>inspect</b> (see the "
368
+ "recommendation) → the recommended <b>settings</b> appear → <b>run</b>.</p>"),
369
+ upload, cols_info,
370
+ W.HTML("<b>Column names</b> (change if your export differs):"),
371
+ obs_c, ev_c, st_c, unit, unit_help,
372
+ advise_btn, advice_out, settings_box, results_out]))
tpattern/io.py ADDED
@@ -0,0 +1,214 @@
1
+ """
2
+ Reading event data into a sample of observations.
3
+ ==================================================
4
+
5
+ The primary entry point is :func:`read_table`, which reads the canonical flat CSV
6
+ that any coding tool can export (one row per event: ``observation, event, start``;
7
+ see SCHEMA.md). This is what almost all users want::
8
+
9
+ from tpattern import read_table
10
+ obs = read_table("events.csv")
11
+
12
+ A T-pattern analysis runs over a *collection* of observations (a "sample"). Each
13
+ observation is one independent time-line (a possession, rally, bout, match);
14
+ patterns are counted across all observations, but a single pattern occurrence can
15
+ never straddle two of them. Several events may share a timestamp — the detection
16
+ engine treats co-timed events as an unordered set at that instant (see
17
+ ``Config.min_lag`` to require a genuine lag instead).
18
+
19
+ :func:`read_sample` / :func:`read_observation` are a legacy reader for THEME's
20
+ tab-separated observation files, kept for compatibility: one file per observation,
21
+ ``time<TAB>event`` rows, with ``:`` and ``&`` marking the observation window.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import csv
27
+ from collections import defaultdict
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+
31
+
32
+ # The two reserved symbols that delimit the observation window rather than
33
+ # denoting a real event.
34
+ START_MARKER = ":"
35
+ END_MARKER = "&"
36
+
37
+
38
+ @dataclass
39
+ class Observation:
40
+ """One time-line: the events of a single observation file.
41
+
42
+ Attributes
43
+ ----------
44
+ name : str
45
+ File stem, used to trace patterns back to a specific shot/sequence.
46
+ start, end : int
47
+ The observation window [start, end] in milliseconds, taken from the
48
+ `:` and `&` markers. `T = end - start` is the window length used as the
49
+ denominator of the NX/T baseline probability.
50
+ events : list[tuple[int, str]]
51
+ (time, event_type) pairs, sorted by time, markers removed.
52
+ """
53
+
54
+ name: str
55
+ start: int
56
+ end: int
57
+ events: list[tuple[int, str]] = field(default_factory=list)
58
+
59
+ @property
60
+ def T(self) -> int:
61
+ """Length of the observation window in time units (ms)."""
62
+ return self.end - self.start
63
+
64
+
65
+ def read_observation(path: str | Path) -> Observation:
66
+ """Parse a single THEME `.txt` file into an :class:`Observation`."""
67
+ path = Path(path)
68
+ start: int | None = None
69
+ end: int | None = None
70
+ events: list[tuple[int, str]] = []
71
+
72
+ for raw in path.read_text().splitlines():
73
+ raw = raw.strip()
74
+ if not raw:
75
+ continue
76
+ parts = raw.split("\t")
77
+ if len(parts) != 2:
78
+ # Tolerate whitespace-separated files as well.
79
+ parts = raw.split()
80
+ if len(parts) != 2:
81
+ continue
82
+ tok_time, tok_event = parts[0].strip(), parts[1].strip()
83
+ if tok_time.lower() == "time": # header row
84
+ continue
85
+ try:
86
+ t = int(tok_time)
87
+ except ValueError:
88
+ continue
89
+
90
+ if tok_event == START_MARKER:
91
+ start = t
92
+ elif tok_event == END_MARKER:
93
+ end = t
94
+ else:
95
+ events.append((t, tok_event))
96
+
97
+ events.sort(key=lambda e: e[0])
98
+
99
+ # Fall back gracefully if a marker is missing: use the event span.
100
+ if start is None:
101
+ start = events[0][0] if events else 0
102
+ if end is None:
103
+ end = events[-1][0] if events else start
104
+
105
+ return Observation(name=path.stem, start=start, end=end, events=events)
106
+
107
+
108
+ def read_sample(folder: str | Path, pattern: str = "*.txt") -> list[Observation]:
109
+ """Read every observation file in a folder into a list (a "sample")."""
110
+ folder = Path(folder)
111
+ files = sorted(folder.glob(pattern))
112
+ if not files:
113
+ raise FileNotFoundError(f"No files matching {pattern!r} in {folder}")
114
+ return [read_observation(f) for f in files]
115
+
116
+
117
+ def read_table(path: str | Path, *, observation: str = "observation",
118
+ event: str = "event", start: str = "start",
119
+ end: str | None = None, obs_start: str | None = None,
120
+ obs_end: str | None = None, build_event_from: list[str] | None = None,
121
+ time_unit: str = "s", sep: str = ",") -> list[Observation]:
122
+ """Read the canonical flat event table (see SCHEMA.md) into a sample.
123
+
124
+ One row per event; rows are grouped by the `observation` column into one
125
+ :class:`Observation` each. This is the software-agnostic entry point — any
126
+ tool (OpenTag, Sportscode, a spreadsheet) exports to these columns and the
127
+ rest of the library is unchanged.
128
+
129
+ Parameters
130
+ ----------
131
+ observation, event, start : str
132
+ Column names for the required fields.
133
+ end : str, optional
134
+ Column giving each event's end time; if absent the observation window is
135
+ taken from the min/max event `start`.
136
+ obs_start, obs_end : str, optional
137
+ Columns giving the *observation window* (constant within an observation).
138
+ Supply these when the unit has real bounds that extend beyond its first and
139
+ last event — a possession, rally or bout usually does. The window length
140
+ T = obs_end - obs_start is the denominator of the NX/T baseline, so deriving
141
+ it from the events instead will shift every baseline probability. If absent,
142
+ the window falls back to the first/last event time.
143
+ build_event_from : list[str], optional
144
+ If given, the event code is built by joining these columns with '_'
145
+ (for coders who keep the code split across descriptor columns) instead of
146
+ reading `event` directly.
147
+ time_unit : {'s', 'ms'}
148
+ Unit of the time columns. Internally times are integer milliseconds, so
149
+ seconds are scaled by 1000 (the engine's baseline maths is unit-agnostic;
150
+ this only fixes the reported interval unit).
151
+ """
152
+ if time_unit not in ("s", "ms"):
153
+ raise ValueError(f"time_unit must be 's' or 'ms', got {time_unit!r}")
154
+ path = Path(path)
155
+ scale = 1000 if time_unit == "s" else 1
156
+ rows_by_obs: dict[str, list] = defaultdict(list)
157
+ ends_by_obs: dict[str, list] = defaultdict(list)
158
+ window_by_obs: dict[str, tuple] = {}
159
+
160
+ with open(path, newline="") as fh:
161
+ reader = csv.DictReader(fh, delimiter=sep)
162
+ cols = set(reader.fieldnames or [])
163
+ # the event code comes from `event`, unless it is built from other columns
164
+ code_cols = set(build_event_from) if build_event_from else {event}
165
+ required = {observation, start} | code_cols
166
+ missing = required - cols
167
+ if missing:
168
+ raise ValueError(
169
+ f"table {path.name} is missing required column(s) {sorted(missing)}. "
170
+ f"Found columns: {sorted(cols)}. Expected one row per event with at "
171
+ f"least '{observation}', '{event}' and '{start}' (rename via the "
172
+ f"read_table arguments if your export uses different names; see SCHEMA.md).")
173
+ for r in reader:
174
+ obs_id = (r.get(observation) or "").strip()
175
+ if not obs_id:
176
+ continue
177
+ if build_event_from:
178
+ # join only the non-empty parts, so an event with no value for one
179
+ # column (e.g. a Challenge with no outcome) yields "Challenge_Pressure",
180
+ # not "Challenge__Pressure", and one with none yields just its type.
181
+ parts = [(r.get(c) or "").strip() for c in build_event_from]
182
+ code = "_".join(p for p in parts if p)
183
+ else:
184
+ code = (r.get(event) or "").strip()
185
+ if not code:
186
+ continue
187
+ try:
188
+ t = int(round(float(r[start]) * scale))
189
+ except (ValueError, TypeError, KeyError):
190
+ continue
191
+ rows_by_obs[obs_id].append((t, code))
192
+ if end and r.get(end) not in (None, ""):
193
+ try:
194
+ ends_by_obs[obs_id].append(int(round(float(r[end]) * scale)))
195
+ except ValueError:
196
+ pass
197
+ if obs_start and obs_end and obs_id not in window_by_obs:
198
+ try:
199
+ window_by_obs[obs_id] = (
200
+ int(round(float(r[obs_start]) * scale)),
201
+ int(round(float(r[obs_end]) * scale)))
202
+ except (ValueError, TypeError, KeyError):
203
+ pass
204
+
205
+ sample: list[Observation] = []
206
+ for obs_id, evs in rows_by_obs.items():
207
+ evs.sort(key=lambda e: e[0])
208
+ if obs_id in window_by_obs:
209
+ o_start, o_end = window_by_obs[obs_id]
210
+ else:
211
+ o_start = evs[0][0]
212
+ o_end = max(ends_by_obs[obs_id]) if ends_by_obs.get(obs_id) else evs[-1][0]
213
+ sample.append(Observation(name=obs_id, start=o_start, end=o_end, events=evs))
214
+ return sample