tpattern 0.1.9__tar.gz → 0.1.10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {tpattern-0.1.9/tpattern.egg-info → tpattern-0.1.10}/PKG-INFO +1 -1
  2. {tpattern-0.1.9 → tpattern-0.1.10}/pyproject.toml +1 -1
  3. tpattern-0.1.10/tests/test_read_table_validation.py +121 -0
  4. {tpattern-0.1.9 → tpattern-0.1.10}/tpattern/__init__.py +4 -2
  5. tpattern-0.1.10/tpattern/io.py +679 -0
  6. {tpattern-0.1.9 → tpattern-0.1.10}/tpattern/methods.py +10 -0
  7. {tpattern-0.1.9 → tpattern-0.1.10/tpattern.egg-info}/PKG-INFO +1 -1
  8. {tpattern-0.1.9 → tpattern-0.1.10}/tpattern.egg-info/SOURCES.txt +1 -0
  9. tpattern-0.1.9/tpattern/io.py +0 -303
  10. {tpattern-0.1.9 → tpattern-0.1.10}/LICENSE +0 -0
  11. {tpattern-0.1.9 → tpattern-0.1.10}/README.md +0 -0
  12. {tpattern-0.1.9 → tpattern-0.1.10}/setup.cfg +0 -0
  13. {tpattern-0.1.9 → tpattern-0.1.10}/tests/test_calibrated_pins.py +0 -0
  14. {tpattern-0.1.9 → tpattern-0.1.10}/tests/test_contrast.py +0 -0
  15. {tpattern-0.1.9 → tpattern-0.1.10}/tests/test_dedup.py +0 -0
  16. {tpattern-0.1.9 → tpattern-0.1.10}/tests/test_guided.py +0 -0
  17. {tpattern-0.1.9 → tpattern-0.1.10}/tests/test_null_compare.py +0 -0
  18. {tpattern-0.1.9 → tpattern-0.1.10}/tests/test_report_dendrogram.py +0 -0
  19. {tpattern-0.1.9 → tpattern-0.1.10}/tests/test_reproduction.py +0 -0
  20. {tpattern-0.1.9 → tpattern-0.1.10}/tests/test_sofcoder.py +0 -0
  21. {tpattern-0.1.9 → tpattern-0.1.10}/tests/test_support_reporting.py +0 -0
  22. {tpattern-0.1.9 → tpattern-0.1.10}/tests/test_synthetic.py +0 -0
  23. {tpattern-0.1.9 → tpattern-0.1.10}/tpattern/advisor.py +0 -0
  24. {tpattern-0.1.9 → tpattern-0.1.10}/tpattern/ci.py +0 -0
  25. {tpattern-0.1.9 → tpattern-0.1.10}/tpattern/contrast.py +0 -0
  26. {tpattern-0.1.9 → tpattern-0.1.10}/tpattern/detect.py +0 -0
  27. {tpattern-0.1.9 → tpattern-0.1.10}/tpattern/diagnostics.py +0 -0
  28. {tpattern-0.1.9 → tpattern-0.1.10}/tpattern/guided.py +0 -0
  29. {tpattern-0.1.9 → tpattern-0.1.10}/tpattern/pattern.py +0 -0
  30. {tpattern-0.1.9 → tpattern-0.1.10}/tpattern/randomise.py +0 -0
  31. {tpattern-0.1.9 → tpattern-0.1.10}/tpattern/report.py +0 -0
  32. {tpattern-0.1.9 → tpattern-0.1.10}/tpattern/significance.py +0 -0
  33. {tpattern-0.1.9 → tpattern-0.1.10}/tpattern/synthetic.py +0 -0
  34. {tpattern-0.1.9 → tpattern-0.1.10}/tpattern/viz.py +0 -0
  35. {tpattern-0.1.9 → tpattern-0.1.10}/tpattern.egg-info/dependency_links.txt +0 -0
  36. {tpattern-0.1.9 → tpattern-0.1.10}/tpattern.egg-info/requires.txt +0 -0
  37. {tpattern-0.1.9 → tpattern-0.1.10}/tpattern.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tpattern
3
- Version: 0.1.9
3
+ Version: 0.1.10
4
4
  Summary: Open, auditable temporal & sequential pattern analysis (T-patterns) for sport and behaviour
5
5
  Author-email: Andrew Callaway <acallaway@bournemouth.ac.uk>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tpattern"
7
- version = "0.1.9"
7
+ version = "0.1.10"
8
8
  description = "Open, auditable temporal & sequential pattern analysis (T-patterns) for sport and behaviour"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -0,0 +1,121 @@
1
+ """Data-check tests for read_table: the E/W/I codes, and that the clean path
2
+ stays silent and unchanged. See tpattern/io.py CHECKS."""
3
+ import pytest
4
+ from tpattern import read_table, DataFormatError, CHECKS, check_reference
5
+
6
+
7
+ def write(tmp_path, name, text):
8
+ p = tmp_path / name
9
+ p.write_text(text)
10
+ return str(p)
11
+
12
+
13
+ # ---- E codes: raise, nothing loaded ----
14
+
15
+ def test_E01_missing_column(tmp_path):
16
+ f = write(tmp_path, "m.csv", "observation,event,time\np1,pass,0\np1,shot,3\n")
17
+ with pytest.raises(DataFormatError) as e:
18
+ read_table(f)
19
+ assert "E01" in str(e.value) and "start" in str(e.value)
20
+
21
+
22
+ def test_E01_suggests_close_column(tmp_path):
23
+ f = write(tmp_path, "m.csv", "observation,event,Start\np1,pass,0\np1,shot,3\n")
24
+ with pytest.raises(DataFormatError) as e:
25
+ read_table(f)
26
+ assert "Start" in str(e.value) # suggested the near-match
27
+
28
+
29
+ def test_E02_wrong_separator(tmp_path):
30
+ f = write(tmp_path, "s.csv", "observation;event;start\np1;pass;0\np1;shot;3\n")
31
+ with pytest.raises(DataFormatError) as e:
32
+ read_table(f)
33
+ assert "E02" in str(e.value)
34
+
35
+
36
+ def test_E03_header_only(tmp_path):
37
+ f = write(tmp_path, "h.csv", "observation,event,start\n")
38
+ with pytest.raises(DataFormatError) as e:
39
+ read_table(f)
40
+ assert "E03" in str(e.value)
41
+
42
+
43
+ def test_E04_all_rows_blank(tmp_path):
44
+ f = write(tmp_path, "b.csv", "observation,event,start\n,pass,0\n,shot,3\n")
45
+ with pytest.raises(DataFormatError) as e:
46
+ read_table(f)
47
+ assert "E04" in str(e.value)
48
+
49
+
50
+ def test_E05_no_pattern_capable_observation(tmp_path):
51
+ # every observation has exactly one event
52
+ f = write(tmp_path, "one.csv",
53
+ "observation,event,start\np1,pass,0\np2,shot,0\np3,cross,0\n")
54
+ with pytest.raises(DataFormatError) as e:
55
+ read_table(f)
56
+ assert "E05" in str(e.value)
57
+
58
+
59
+ # ---- W codes: load anyway, reported ----
60
+
61
+ def test_W10_non_numeric_times_dropped(tmp_path, capsys):
62
+ f = write(tmp_path, "c.csv",
63
+ "observation,event,start\np1,pass,0\np1,cross,01:03\np1,shot,5\n"
64
+ "p2,pass,1\np2,shot,4\n")
65
+ obs = read_table(f) # default report: a W fired, so it prints
66
+ out = capsys.readouterr().out
67
+ assert "W10" in out and "left out" in out
68
+ # the one good p1 row + p2 both loaded; the mm:ss row dropped
69
+ assert sum(len(o.events) for o in obs) == 4
70
+
71
+
72
+ def test_W13_near_duplicate_labels(tmp_path, capsys):
73
+ f = write(tmp_path, "d.csv",
74
+ "observation,event,start\np1,Pass,0\np1,pass,1\np1,shot,2\n"
75
+ "p2,Pass,0\np2,shot,2\n")
76
+ read_table(f)
77
+ assert "W13" in capsys.readouterr().out
78
+
79
+
80
+ def test_clean_file_is_silent(tmp_path, capsys):
81
+ f = write(tmp_path, "ok.csv",
82
+ "observation,event,start\np1,pass,0\np1,shot,2\np2,pass,0\np2,shot,3\n")
83
+ obs = read_table(f)
84
+ assert capsys.readouterr().out == "" # nothing to warn about
85
+ assert len(obs) == 2
86
+
87
+
88
+ # ---- I codes: only shown with report="all" ----
89
+
90
+ def test_I20_effective_sample_only_with_all(tmp_path, capsys):
91
+ f = write(tmp_path, "e.csv",
92
+ "observation,event,start\np1,pass,0\np1,shot,2\np2,pass,0\n") # p2 has 1 event
93
+ read_table(f) # default: I-only, stays silent
94
+ assert capsys.readouterr().out == ""
95
+ read_table(f, report="all") # report='all' surfaces the info note
96
+ assert "I20" in capsys.readouterr().out
97
+
98
+
99
+ def test_report_false_silences_warnings(tmp_path, capsys):
100
+ f = write(tmp_path, "c.csv",
101
+ "observation,event,start\np1,pass,0\np1,x,bad\np1,shot,5\np2,a,0\np2,b,1\n")
102
+ read_table(f, report=False)
103
+ assert capsys.readouterr().out == ""
104
+
105
+
106
+ # ---- BOM (Excel) handled ----
107
+
108
+ def test_utf8_bom_is_stripped(tmp_path):
109
+ p = tmp_path / "bom.csv"
110
+ p.write_bytes("observation,event,start\np1,pass,0\np1,shot,2\n".encode("utf-8"))
111
+ obs = read_table(str(p)) # must not see the BOM as part of the column name
112
+ assert len(obs) == 1 and len(obs[0].events) == 2
113
+
114
+
115
+ # ---- registry integrity (Appendix A generates from this) ----
116
+
117
+ def test_check_reference_covers_every_code():
118
+ rows = check_reference()
119
+ assert {r["code"] for r in rows} == set(CHECKS)
120
+ for r in rows:
121
+ assert r["condition"] and r["fix"] and r["severity"]
@@ -6,9 +6,10 @@ behavior: T-patterns and their detection. Behavior Research Methods,
6
6
  Instruments, & Computers, 32(1), 93-110.
7
7
  """
8
8
 
9
- __version__ = "0.1.9" # defined before submodule imports (methods.py reads it)
9
+ __version__ = "0.1.10" # defined before submodule imports (methods.py reads it)
10
10
 
11
- from .io import Observation, read_observation, read_sample, read_table, read_sofcoder
11
+ from .io import (Observation, read_observation, read_sample, read_table,
12
+ read_sofcoder, DataFormatError, check_reference, CHECKS)
12
13
  from .pattern import Instance, Pattern
13
14
  from .ci import find_critical_interval, CIResult
14
15
  from .detect import Config, Engine
@@ -24,6 +25,7 @@ from .contrast import group_contrast, contrast_items
24
25
 
25
26
  __all__ = [
26
27
  "Observation", "read_observation", "read_sample", "read_table", "read_sofcoder",
28
+ "DataFormatError", "check_reference", "CHECKS",
27
29
  "Instance", "Pattern",
28
30
  "find_critical_interval", "CIResult",
29
31
  "Config", "Engine",
@@ -0,0 +1,679 @@
1
+ """
2
+ Reading your event data in.
3
+ ===========================
4
+
5
+ Almost everyone wants one function: :func:`read_table`. Give it a plain table with
6
+ one row per event, and it returns the list of *observations* the rest of the
7
+ library analyses::
8
+
9
+ from tpattern import read_table
10
+ observations = read_table("my_events.csv")
11
+
12
+ Your table needs three columns — an **observation** id (which session/rally/bout/
13
+ possession the event belongs to), an **event** label (what happened), and a
14
+ **start** time (when). Anything else is optional (see SCHEMA.md).
15
+
16
+ An *observation* is one self-contained time-line: a single possession, rally, bout
17
+ or match. Patterns are counted across all of your observations, but one occurrence
18
+ of a pattern can never span two of them — so getting the observation column right
19
+ matters. Several events may share the same time; the engine treats same-time events
20
+ as an unordered set at that instant (use ``Config.min_lag`` to require a real gap
21
+ instead).
22
+
23
+ **Reading your own, un-cleaned data.** Real exports are messy. Rather than fail on
24
+ the first bad row or, worse, drop rows silently, :func:`read_table` runs a set of
25
+ checks and tells you — in plain language — what it found, how much of your file was
26
+ affected, and what to do about it. Each check has a short code (``E01``, ``W05`` …)
27
+ explained in the tutorial's Appendix A and in :data:`CHECKS`. A code beginning
28
+ ``E`` stops the read (something must be fixed first); ``W`` and ``I`` never stop it
29
+ — the data loads and the notes are there if you want to tidy up.
30
+
31
+ :func:`read_sample` / :func:`read_observation` are an older reader for THEME's
32
+ tab-separated files (one file per observation, ``time<TAB>event`` rows, with ``:``
33
+ and ``&`` marking the window). :func:`read_sofcoder` reads SOF-CODER/THEME
34
+ ``DATANAME, T, Events`` files.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import csv
40
+ import difflib
41
+ from collections import defaultdict, Counter
42
+ from dataclasses import dataclass, field
43
+ from pathlib import Path
44
+
45
+
46
+ # The two reserved symbols that mark the observation window rather than a real
47
+ # event, in the older THEME/SOF-CODER file formats.
48
+ START_MARKER = ":"
49
+ END_MARKER = "&"
50
+
51
+
52
+ @dataclass
53
+ class Observation:
54
+ """One time-line: all the events of a single observation.
55
+
56
+ Attributes
57
+ ----------
58
+ name : str
59
+ Which observation this is (its id), so a pattern can be traced back to a
60
+ specific session/rally/bout.
61
+ start, end : int
62
+ The observation window [start, end] in milliseconds. ``T = end - start`` is
63
+ the length of the window, used as the denominator of the NX/T baseline (how
64
+ likely an event is to fall in a given slice of time by chance).
65
+ events : list[tuple[int, str]]
66
+ (time, event label) pairs, sorted by time, with any window markers removed.
67
+ """
68
+
69
+ name: str
70
+ start: int
71
+ end: int
72
+ events: list[tuple[int, str]] = field(default_factory=list)
73
+
74
+ @property
75
+ def T(self) -> int:
76
+ """Length of the observation window, in time units (ms)."""
77
+ return self.end - self.start
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Data checks: the codes, and the human-readable report.
82
+ # ---------------------------------------------------------------------------
83
+
84
+ class DataFormatError(ValueError):
85
+ """Raised when a file cannot be read as it stands (an ``E`` check).
86
+
87
+ The message *is* the plain-language report: what is wrong, and how to fix it.
88
+ ``W`` and ``I`` checks never raise — they are reported and the data still loads.
89
+ """
90
+
91
+
92
+ # The single source of truth for every check. The runtime report and the tutorial's
93
+ # Appendix A are both built from this, so they can never drift apart. `severity`:
94
+ # 'E' stops the read (must fix first), 'W' is a warning (loads anyway, worth tidying),
95
+ # 'I' is information (loads anyway, nothing to fix). `condition` says when it fires;
96
+ # `fix` says what to do; `example` shows it concretely (a code snippet, or a data
97
+ # before -> after). Runtime adds the specifics (counts, percentages, example rows).
98
+ CHECKS: dict[str, dict[str, str]] = {
99
+ "E01": {"severity": "E", "condition": "A column the analysis needs is not in the file.",
100
+ "fix": "Rename the column, or tell read_table its name.",
101
+ "example": "read_table(\"events.csv\", start=\"Start Time\")"},
102
+ "E02": {"severity": "E", "condition": "The file read as a single column, so it is probably not comma-separated.",
103
+ "fix": "Re-save it as a comma-separated CSV, or pass the separator.",
104
+ "example": "read_table(\"events.csv\", sep=\";\")"},
105
+ "E03": {"severity": "E", "condition": "The file has a header row but no data under it (or is empty).",
106
+ "fix": "Check the export actually contains events, then re-run.",
107
+ "example": "a data row looks like: p1,pass,0"},
108
+ "E04": {"severity": "E", "condition": "Every row was unusable, so there was nothing to load.",
109
+ "fix": "Open the file and check the observation, event and start columns are filled in.",
110
+ "example": "p1,pass,0 (a value in observation, event and start)"},
111
+ "E05": {"severity": "E", "condition": "No observation has at least two events, so no pattern can form.",
112
+ "fix": "Check the observation column groups events correctly; a pattern needs two or more events in the same observation.",
113
+ "example": "two rows sharing an id: p1,pass,0 and p1,shot,3"},
114
+ "W10": {"severity": "W", "condition": "Some start times are not numbers, so those rows were left out.",
115
+ "fix": "Times must be numbers. If yours is a mm:ss clock, convert it to seconds first.",
116
+ "example": "01:23 -> 83"},
117
+ "W11": {"severity": "W", "condition": "Some rows have a blank observation id and were left out.",
118
+ "fix": "Fill in the observation column, or remove the empty rows.",
119
+ "example": ",pass,0 -> p1,pass,0"},
120
+ "W12": {"severity": "W", "condition": "Some rows have a blank event label and were left out.",
121
+ "fix": "Fill in the event column, or remove the empty rows.",
122
+ "example": "p1,,0 -> p1,pass,0"},
123
+ "W13": {"severity": "W", "condition": "Two or more event labels differ only by capitalisation or spacing.",
124
+ "fix": "They are counted as different events. Merge them if they mean the same thing.",
125
+ "example": "Pass and pass -> both Pass"},
126
+ "W14": {"severity": "W", "condition": "Some events fall outside their observation's window, or a window has end at or before start.",
127
+ "fix": "Check the obs_start / obs_end columns; the window should contain all of the observation's events.",
128
+ "example": "event at 620 with window 0-600 -> set obs_end >= 620"},
129
+ "W15": {"severity": "W", "condition": "The time values look like they might be in the other unit.",
130
+ "fix": "Check time_unit: pass \"s\" for seconds or \"ms\" for milliseconds.",
131
+ "example": "read_table(\"events.csv\", time_unit=\"s\")"},
132
+ "I20": {"severity": "I", "condition": "Some observations have fewer than two events.",
133
+ "fix": "None needed. They set the baseline event rates but cannot themselves contain a pattern; the Methods text reports both counts.",
134
+ "example": "Methods reports \"163 observations, 131 with >=2 events\""},
135
+ "I21": {"severity": "I", "condition": "The file contains only one observation.",
136
+ "fix": "None needed, but check it is intended: patterns are counted across observations, so a single time-line will detect little.",
137
+ "example": "is the observation column accidentally a constant?"},
138
+ "I22": {"severity": "I", "condition": "One event type makes up a large share of all events.",
139
+ "fix": "None needed. A very common event can link to almost everything; the advisor may suggest setting it aside from pattern-building.",
140
+ "example": "exclude_events=[\"Interception\"]"},
141
+ "I23": {"severity": "I", "condition": "A high fraction of neighbouring events share an identical time.",
142
+ "fix": "None needed. The advisor will likely suggest requiring a genuine gap so co-timed events are read as co-occurrence, not sequence.",
143
+ "example": "Config(min_lag=1)"},
144
+ "I24": {"severity": "I", "condition": "Some rows are exact duplicates (same observation, event and time).",
145
+ "fix": "None needed by default: identical same-time events are counted once. If they are double entries and you turned that collapsing off, remove the copies.",
146
+ "example": "keep collapse_duplicates on (the default)"},
147
+ }
148
+
149
+
150
+ @dataclass
151
+ class _Finding:
152
+ """One thing a check turned up, with the specifics filled in at runtime."""
153
+ code: str
154
+ detail: str # the specifics: counts, percentages, an example
155
+
156
+ @property
157
+ def severity(self) -> str:
158
+ return CHECKS[self.code]["severity"]
159
+
160
+ @property
161
+ def fix(self) -> str:
162
+ return CHECKS[self.code]["fix"]
163
+
164
+ @property
165
+ def example(self) -> str:
166
+ return CHECKS[self.code].get("example", "")
167
+
168
+
169
+ class CheckReport:
170
+ """Collects findings for one file and renders the plain-language report.
171
+
172
+ Access ``.findings`` for the raw list, ``.errors`` for the blocking ones, and
173
+ ``str(report)`` for the human-readable text. ``read_table`` attaches the report
174
+ to what it prints; a blocking finding is raised as :class:`DataFormatError`.
175
+ """
176
+
177
+ def __init__(self, filename: str, rows_read: int):
178
+ self.filename = filename
179
+ self.rows_read = rows_read # data rows (header excluded)
180
+ self.rows_used = rows_read
181
+ self.n_observations = 0
182
+ self.findings: list[_Finding] = []
183
+
184
+ def add(self, code: str, detail: str) -> None:
185
+ self.findings.append(_Finding(code, detail))
186
+
187
+ @property
188
+ def errors(self) -> list[_Finding]:
189
+ return [f for f in self.findings if f.severity == "E"]
190
+
191
+ def _pct(self, n: int) -> str:
192
+ base = self.rows_read or 1
193
+ return f"{n} ({n / base:.1%} of {self.rows_read})"
194
+
195
+ def render(self) -> str:
196
+ lines: list[str] = []
197
+ if self.errors:
198
+ lines.append(f"Data check — {self.filename} ✗ couldn't load the file")
199
+ lines.append("Nothing was loaded yet. Fix and re-run:")
200
+ for f in self.errors:
201
+ lines.append(f" • {f.detail} ({f.code})")
202
+ eg = f" e.g. {f.example}" if f.example else ""
203
+ lines.append(f" → {f.fix}{eg}")
204
+ return "\n".join(lines)
205
+
206
+ # Loaded successfully; W/I notes are optional.
207
+ lines.append(f"Data check — {self.filename} ✓ loaded")
208
+ if self.rows_used < self.rows_read:
209
+ left = self.rows_read - self.rows_used
210
+ lines.append(f" Loaded {self.rows_used} of {self.rows_read} rows "
211
+ f"({left} left out, {left / (self.rows_read or 1):.1%}) "
212
+ f"across {self.n_observations} observations.")
213
+ else:
214
+ lines.append(f" Loaded all {self.rows_used} rows across "
215
+ f"{self.n_observations} observations.")
216
+ notes = [f for f in self.findings if f.severity in ("W", "I")]
217
+ if notes:
218
+ lines.append(" Your data is ready. The notes below are optional — "
219
+ "nothing here stopped the load:")
220
+ for f in notes:
221
+ lines.append(f" • {f.detail} ({f.code})")
222
+ if f.severity == "W":
223
+ eg = f" e.g. {f.example}" if f.example else ""
224
+ lines.append(f" → {f.fix}{eg}")
225
+ else:
226
+ lines.append(" Your data is ready. No issues found.")
227
+ return "\n".join(lines)
228
+
229
+ def __str__(self) -> str: # so print(report) just works
230
+ return self.render()
231
+
232
+
233
+ def _closest_column(wanted: str, available) -> str | None:
234
+ """Best guess at which real column a missing required one was meant to be."""
235
+ hit = difflib.get_close_matches(wanted, list(available), n=1, cutoff=0.6)
236
+ if hit:
237
+ return hit[0]
238
+ low = {c.lower(): c for c in available}
239
+ return low.get(wanted.lower())
240
+
241
+
242
+ def _norm_label(code: str) -> str:
243
+ """Fold a label to compare look-alikes: lower-case, single-spaced, trimmed."""
244
+ return " ".join(code.lower().split())
245
+
246
+
247
+ def _example_rows(rows: list[int]) -> str:
248
+ """Render a couple of example row numbers for a message."""
249
+ shown = ", ".join(f"row {r}" for r in rows[:2])
250
+ return shown
251
+
252
+
253
+ def read_table(path: str | Path, *, observation: str = "observation",
254
+ event: str = "event", start: str = "start",
255
+ end: str | None = "end", obs_start: str | None = "obs_start",
256
+ obs_end: str | None = "obs_end", build_event_from: list[str] | None = None,
257
+ time_unit: str = "s", sep: str = ",",
258
+ check: bool = True, report: bool = True) -> list[Observation]:
259
+ """Read a plain event table (one row per event) into a list of observations.
260
+
261
+ This is the software-agnostic way in: any tool — OpenTag, SportsCode, a
262
+ spreadsheet — can export these columns, and the rest of the library is the same
263
+ whatever produced them. Rows are grouped by the ``observation`` column into one
264
+ :class:`Observation` each.
265
+
266
+ Parameters
267
+ ----------
268
+ observation, event, start : str
269
+ The names of the three required columns in *your* file. If your export uses
270
+ different names, pass them here (e.g. ``start="Start Time"``).
271
+ end : str, optional
272
+ A column giving each event's end time. If it is absent the observation
273
+ window is taken from the earliest and latest event.
274
+ obs_start, obs_end : str, optional
275
+ Columns giving the observation *window* (the same value for every row of an
276
+ observation). These default to the usual names, so a file that includes them
277
+ is read correctly with no extra arguments, and a file without them simply
278
+ falls back to the first and last event. Give a window when the observation
279
+ has real bounds beyond its first and last event — a possession or rally
280
+ usually does. The window length ``T = obs_end - obs_start`` is the
281
+ denominator of the NX/T baseline, so taking it from the events instead shifts
282
+ every baseline probability.
283
+ build_event_from : list[str], optional
284
+ Build the event label by joining these columns with '_', for data that keeps
285
+ the label split across descriptor columns, instead of reading ``event``.
286
+ time_unit : {'s', 'ms'}
287
+ The unit of your time columns. Times are held internally as whole
288
+ milliseconds, so seconds are multiplied by 1000.
289
+ check : bool
290
+ Run the data checks (default on). See :data:`CHECKS` and Appendix A.
291
+ report : bool
292
+ Print the plain-language report when a check finds something (default on).
293
+ A clean file prints nothing. Blocking (``E``) problems are always raised as
294
+ :class:`DataFormatError` whatever this is set to.
295
+
296
+ Raises
297
+ ------
298
+ DataFormatError
299
+ If the file cannot be read as it stands (any ``E`` check).
300
+ """
301
+ if time_unit not in ("s", "ms"):
302
+ raise ValueError(f"time_unit must be 's' or 'ms', got {time_unit!r}")
303
+ path = Path(path)
304
+ scale = 1000 if time_unit == "s" else 1
305
+
306
+ # utf-8-sig quietly strips the byte-order mark Excel puts on the first column
307
+ # name, which would otherwise make the observation column look "missing".
308
+ with open(path, newline="", encoding="utf-8-sig") as fh:
309
+ reader = csv.DictReader(fh, delimiter=sep)
310
+ fieldnames = list(reader.fieldnames or [])
311
+ rows = list(reader)
312
+
313
+ rep = CheckReport(path.name, rows_read=len(rows))
314
+ cols = set(fieldnames)
315
+
316
+ def finish_or_raise():
317
+ """Emit the report and, if anything blocks, raise it.
318
+
319
+ A blocking (E) finding is always raised. Otherwise the plain-language report
320
+ is printed only when there is something the user should act on — any warning
321
+ (W) — so a clean file, or one with information-only notes, stays quiet.
322
+ Pass ``report="all"`` to print the report whenever there is any note at all
323
+ (what the guided notebook does, to surface the information notes too).
324
+ """
325
+ if check and rep.errors:
326
+ raise DataFormatError("\n" + rep.render())
327
+ if not (check and report) or not rep.findings:
328
+ return
329
+ if report == "all" or any(f.severity == "W" for f in rep.findings):
330
+ print(rep.render())
331
+
332
+ # --- E02: the whole file read as one column => wrong separator ---
333
+ if check and len(fieldnames) == 1 and (";" in fieldnames[0] or "\t" in fieldnames[0]):
334
+ guess = ";" if ";" in fieldnames[0] else "a tab"
335
+ rep.add("E02", f"the file read as a single column ({fieldnames[0]!r}); it "
336
+ f"looks separated by {guess}, not commas")
337
+ finish_or_raise()
338
+
339
+ # --- E01: required columns present? (with a best-guess suggestion) ---
340
+ code_cols = set(build_event_from) if build_event_from else {event}
341
+ required = {observation: observation, start: start}
342
+ required.update({c: c for c in code_cols})
343
+ missing = [c for c in required if c not in cols]
344
+ if check and missing:
345
+ for c in missing:
346
+ guess = _closest_column(c, cols)
347
+ hint = f"; your file has {guess!r} — did you mean that?" if guess else ""
348
+ rep.add("E01", f"no column called {c!r}. Columns found: "
349
+ f"{sorted(cols)}{hint}")
350
+ finish_or_raise()
351
+
352
+ # --- E03: header but no data ---
353
+ if check and not rows:
354
+ rep.add("E03", "the file has a header row but no events under it")
355
+ finish_or_raise()
356
+
357
+ # --- parse rows, recording (not hiding) anything we cannot use ---
358
+ rows_by_obs: dict[str, list] = defaultdict(list)
359
+ ends_by_obs: dict[str, list] = defaultdict(list)
360
+ window_by_obs: dict[str, tuple] = {}
361
+ seen_rows: set = set()
362
+
363
+ blank_obs: list[int] = []
364
+ blank_event: list[int] = []
365
+ bad_time: list[int] = []
366
+ bad_time_examples: list[str] = []
367
+ dup_rows: list[int] = []
368
+ used = 0
369
+
370
+ for i, r in enumerate(rows, start=2): # row 1 is the header, so data starts at 2
371
+ obs_id = (r.get(observation) or "").strip()
372
+ if not obs_id:
373
+ blank_obs.append(i)
374
+ continue
375
+ if build_event_from:
376
+ parts = [(r.get(c) or "").strip() for c in build_event_from]
377
+ code = "_".join(p for p in parts if p)
378
+ else:
379
+ code = (r.get(event) or "").strip()
380
+ if not code:
381
+ blank_event.append(i)
382
+ continue
383
+ try:
384
+ t = int(round(float(r[start]) * scale))
385
+ except (ValueError, TypeError, KeyError):
386
+ bad_time.append(i)
387
+ if len(bad_time_examples) < 2:
388
+ bad_time_examples.append(f"row {i} '{str(r.get(start))[:20]}'")
389
+ continue
390
+
391
+ key = (obs_id, code, t)
392
+ if key in seen_rows:
393
+ dup_rows.append(i)
394
+ seen_rows.add(key)
395
+
396
+ rows_by_obs[obs_id].append((t, code))
397
+ used += 1
398
+ if end and r.get(end) not in (None, ""):
399
+ try:
400
+ ends_by_obs[obs_id].append(int(round(float(r[end]) * scale)))
401
+ except ValueError:
402
+ pass
403
+ if obs_start and obs_end and obs_id not in window_by_obs:
404
+ try:
405
+ window_by_obs[obs_id] = (int(round(float(r[obs_start]) * scale)),
406
+ int(round(float(r[obs_end]) * scale)))
407
+ except (ValueError, TypeError, KeyError):
408
+ pass
409
+
410
+ rep.rows_used = used
411
+
412
+ # --- build the observations (identical to the clean-data path) ---
413
+ sample: list[Observation] = []
414
+ bad_window = 0
415
+ out_of_window = 0
416
+ for obs_id, evs in rows_by_obs.items():
417
+ evs.sort(key=lambda e: e[0])
418
+ if obs_id in window_by_obs:
419
+ o_start, o_end = window_by_obs[obs_id]
420
+ else:
421
+ o_start = evs[0][0]
422
+ o_end = max(ends_by_obs[obs_id]) if ends_by_obs.get(obs_id) else evs[-1][0]
423
+ if o_end < o_start: # inverted window (a zero-length one is fine)
424
+ bad_window += 1
425
+ else:
426
+ out_of_window += sum(1 for t, _ in evs if t < o_start or t > o_end)
427
+ sample.append(Observation(name=obs_id, start=o_start, end=o_end, events=evs))
428
+ rep.n_observations = len(sample)
429
+
430
+ # --- checks that need the parsed data ------------------------------------
431
+ if check:
432
+ # E04 / E05: nothing usable, or nothing that can host a pattern
433
+ if not sample:
434
+ reasons = []
435
+ if blank_obs:
436
+ reasons.append(f"{len(blank_obs)} had a blank {observation}")
437
+ if blank_event:
438
+ reasons.append(f"{len(blank_event)} had a blank {event}")
439
+ if bad_time:
440
+ reasons.append(f"{len(bad_time)} had a start time that isn't a number")
441
+ why = "; ".join(reasons) if reasons else "no rows had all three of "\
442
+ f"{observation}, {event} and {start}"
443
+ rep.add("E04", f"every row was left out ({why})")
444
+ finish_or_raise()
445
+ elif not any(len(o.events) >= 2 for o in sample):
446
+ rep.add("E05", "no observation contains two or more events, so no "
447
+ "pattern can form")
448
+ finish_or_raise()
449
+
450
+ # W10-W12: rows left out
451
+ if bad_time:
452
+ eg = "; ".join(bad_time_examples)
453
+ hint = " — if that's a mm:ss clock, convert it to seconds" if bad_time_examples else ""
454
+ rep.add("W10", f"{rep._pct(len(bad_time))} have a start time that isn't a "
455
+ f"number, so were left out (e.g. {eg}){hint}")
456
+ if blank_obs:
457
+ rep.add("W11", f"{rep._pct(len(blank_obs))} have a blank {observation} and "
458
+ f"were left out (e.g. {_example_rows(blank_obs)})")
459
+ if blank_event:
460
+ rep.add("W12", f"{rep._pct(len(blank_event))} have a blank {event} and "
461
+ f"were left out (e.g. {_example_rows(blank_event)})")
462
+ # W13: labels that differ only by case/spacing
463
+ groups: dict[str, set] = defaultdict(set)
464
+ for evs in rows_by_obs.values():
465
+ for _, code in evs:
466
+ groups[_norm_label(code)].add(code)
467
+ clashes = [sorted(v) for v in groups.values() if len(v) > 1]
468
+ if clashes:
469
+ eg = ", ".join(repr(x) for x in clashes[0])
470
+ more = f", and {len(clashes) - 1} more set(s)" if len(clashes) > 1 else ""
471
+ rep.add("W13", f"{len(clashes)} set(s) of labels differ only by "
472
+ f"capitalisation or spacing (e.g. {eg}){more}; they are "
473
+ f"counted as different events")
474
+ # W14: window problems
475
+ if bad_window or out_of_window:
476
+ bits = []
477
+ if bad_window:
478
+ bits.append(f"{bad_window} observation(s) have a window ending at or "
479
+ f"before it starts")
480
+ if out_of_window:
481
+ bits.append(f"{out_of_window} event(s) fall outside their window")
482
+ rep.add("W14", "; ".join(bits))
483
+ # W15: time unit looks wrong
484
+ all_t = [t for evs in rows_by_obs.values() for t, _ in evs]
485
+ if all_t:
486
+ hi = max(all_t)
487
+ if time_unit == "ms" and hi < 1000 and len(all_t) > 5:
488
+ rep.add("W15", f"with time_unit='ms', every event falls in under one "
489
+ f"second (largest time {hi} ms); the times may really be "
490
+ f"in seconds")
491
+ elif time_unit == "s" and hi > 1_000_000:
492
+ rep.add("W15", f"with time_unit='s', the times run very large (up to "
493
+ f"{hi} s, over 11 days); they may really be in milliseconds")
494
+
495
+ # I20: observations too small to host a pattern
496
+ tiny = sum(1 for o in sample if len(o.events) < 2)
497
+ if tiny:
498
+ rep.add("I20", f"{tiny} of {len(sample)} observations have fewer than two "
499
+ f"events; they set the baseline rates but cannot contain a "
500
+ f"pattern (the Methods text reports both counts)")
501
+ # I21: single observation
502
+ if len(sample) == 1:
503
+ rep.add("I21", "the file contains only one observation; patterns are "
504
+ "counted across observations, so a single time-line will "
505
+ "detect little")
506
+ # I22: a dominant event type
507
+ counts = Counter(code for evs in rows_by_obs.values() for _, code in evs)
508
+ total_ev = sum(counts.values())
509
+ if total_ev:
510
+ top, n_top = counts.most_common(1)[0]
511
+ if len(counts) > 1 and n_top / total_ev >= 0.30:
512
+ rep.add("I22", f"one event type, {top!r}, is {n_top / total_ev:.0%} of "
513
+ f"all events; a very common event can link to almost "
514
+ f"anything")
515
+ # I23: high co-timestamp fraction
516
+ adj = same = 0
517
+ for evs in rows_by_obs.values():
518
+ ts = sorted(t for t, _ in evs)
519
+ for a, b in zip(ts, ts[1:]):
520
+ adj += 1
521
+ if a == b:
522
+ same += 1
523
+ if adj and same / adj >= 0.10:
524
+ rep.add("I23", f"{same / adj:.0%} of neighbouring events share an identical "
525
+ f"time; their order is undefined at that resolution")
526
+
527
+ finish_or_raise()
528
+ return sample
529
+
530
+
531
+ def check_reference() -> list[dict]:
532
+ """Return the check registry as ordered rows (code, severity, condition, fix).
533
+
534
+ Used to generate the tutorial's Appendix A from the same source the runtime
535
+ messages use, so the paper and the software cannot disagree.
536
+ """
537
+ label = {"E": "Error (stops the read)", "W": "Warning (loads anyway)",
538
+ "I": "Information (loads anyway)"}
539
+ return [{"code": c, "severity": label[m["severity"]],
540
+ "condition": m["condition"], "fix": m["fix"],
541
+ "example": m.get("example", "")}
542
+ for c, m in CHECKS.items()]
543
+
544
+
545
+ def read_observation(path: str | Path) -> Observation:
546
+ """Read a single THEME ``.txt`` file into one :class:`Observation`.
547
+
548
+ THEME files have one ``time<TAB>event`` row per line, with ``:`` marking the
549
+ start of the window and ``&`` the end. Whitespace-separated files are tolerated,
550
+ and if a marker is missing the window falls back to the first and last event.
551
+ """
552
+ path = Path(path)
553
+ start: int | None = None
554
+ end: int | None = None
555
+ events: list[tuple[int, str]] = []
556
+
557
+ for raw in path.read_text().splitlines():
558
+ raw = raw.strip()
559
+ if not raw:
560
+ continue
561
+ parts = raw.split("\t")
562
+ if len(parts) != 2:
563
+ parts = raw.split()
564
+ if len(parts) != 2:
565
+ continue
566
+ tok_time, tok_event = parts[0].strip(), parts[1].strip()
567
+ if tok_time.lower() == "time": # header row
568
+ continue
569
+ try:
570
+ t = int(tok_time)
571
+ except ValueError:
572
+ continue
573
+
574
+ if tok_event == START_MARKER:
575
+ start = t
576
+ elif tok_event == END_MARKER:
577
+ end = t
578
+ else:
579
+ events.append((t, tok_event))
580
+
581
+ events.sort(key=lambda e: e[0])
582
+ if start is None:
583
+ start = events[0][0] if events else 0
584
+ if end is None:
585
+ end = events[-1][0] if events else start
586
+ return Observation(name=path.stem, start=start, end=end, events=events)
587
+
588
+
589
+ def read_sample(folder: str | Path, pattern: str = "*.txt") -> list[Observation]:
590
+ """Read every THEME file in a folder into a list of observations (a "sample")."""
591
+ folder = Path(folder)
592
+ files = sorted(folder.glob(pattern))
593
+ if not files:
594
+ raise FileNotFoundError(f"No files matching {pattern!r} in {folder}")
595
+ return [read_observation(f) for f in files]
596
+
597
+
598
+ def read_sofcoder(path: str | Path, *, dataname: str = "DATANAME",
599
+ t: str = "T", events: str = "Events", sep: str | None = None,
600
+ code_sep: str = ",", frames_per_second: float | None = None,
601
+ start_marker: str = ":", end_marker: str = "&") -> list[Observation]:
602
+ """Read a SOF-CODER / THEME data file (``DATANAME, T, Events``) into a sample.
603
+
604
+ SOF-CODER (Jonsson et al., 2006) writes one row per time frame, each row holding
605
+ several comma-separated codes that are active at that frame. This reader splits
606
+ each row so every code becomes one event at time ``T``; codes that share a frame
607
+ are co-timed, and ``min_lag`` / ``collapse_duplicates`` then decide how that
608
+ co-occurrence is treated. Each ``DATANAME`` becomes one :class:`Observation`, and
609
+ rows whose Events field is the ``:`` or ``&`` marker set the window (``T = end -
610
+ start`` is the NX/T denominator); without the markers the window falls back to
611
+ the first and last event.
612
+
613
+ The outer separator is auto-detected (tab if present, else whitespace) unless
614
+ ``sep`` is given; ``code_sep`` splits the Events field (default ``','``). ``T`` is
615
+ kept in the file's own frame unit unless ``frames_per_second`` is given, which
616
+ converts frames to milliseconds. A header naming the columns is used if present,
617
+ otherwise the columns are read positionally as (DATANAME, T, Events).
618
+
619
+ This is the way in for data collected in the SOF-CODER/THEME world; for a tool
620
+ that already exports one row per single event, use :func:`read_table`.
621
+ """
622
+ path = Path(path)
623
+ scale = (1000.0 / frames_per_second) if frames_per_second else 1.0
624
+
625
+ def split_row(line: str) -> list[str]:
626
+ if sep is not None:
627
+ parts = line.split(sep)
628
+ elif "\t" in line:
629
+ parts = line.split("\t")
630
+ else:
631
+ parts = line.split(None, 2) # DATANAME, T, Events (Events holds no spaces)
632
+ return [p.strip() for p in parts]
633
+
634
+ with open(path, newline="") as fh:
635
+ lines = [ln.strip() for ln in fh if ln.strip()]
636
+ if not lines:
637
+ return []
638
+
639
+ header = [h.lower() for h in split_row(lines[0])]
640
+ if dataname.lower() in header and events.lower() in header:
641
+ di, ti, ei = (header.index(dataname.lower()), header.index(t.lower()),
642
+ header.index(events.lower()))
643
+ body = lines[1:]
644
+ else:
645
+ di, ti, ei, body = 0, 1, 2, lines # positional fallback
646
+
647
+ rows_by_obs: dict[str, list] = defaultdict(list)
648
+ starts: dict[str, int] = {}
649
+ ends: dict[str, int] = {}
650
+ for line in body:
651
+ parts = split_row(line)
652
+ if len(parts) <= max(di, ti, ei):
653
+ continue
654
+ obs_id = parts[di]
655
+ if not obs_id:
656
+ continue
657
+ try:
658
+ tv = int(round(float(parts[ti]) * scale))
659
+ except (ValueError, TypeError):
660
+ continue
661
+ raw = parts[ei]
662
+ if raw == start_marker:
663
+ starts.setdefault(obs_id, tv)
664
+ continue
665
+ if raw == end_marker:
666
+ ends[obs_id] = tv
667
+ continue
668
+ for code in raw.split(code_sep):
669
+ code = code.strip()
670
+ if code and code not in (start_marker, end_marker):
671
+ rows_by_obs[obs_id].append((tv, code))
672
+
673
+ sample: list[Observation] = []
674
+ for obs_id, evs in rows_by_obs.items():
675
+ evs.sort(key=lambda e: e[0])
676
+ o_start = starts.get(obs_id, evs[0][0])
677
+ o_end = ends.get(obs_id, evs[-1][0])
678
+ sample.append(Observation(name=obs_id, start=o_start, end=o_end, events=evs))
679
+ return sample
@@ -48,6 +48,16 @@ def methods_text(config, *, observations=None, calibration=None,
48
48
  T = sum(max(o.end - o.start, 1) for o in observations)
49
49
  s.append(f"The sample comprised {n_obs} observations containing {n_ev} "
50
50
  f"event occurrences over {T} time units of total observation window.")
51
+ # Report the effective sample: how many observations can actually host a
52
+ # pattern (two or more events). The rest still set the baseline rates, so
53
+ # they are not discarded, but reporting only the submitted count would
54
+ # overstate what was analysed.
55
+ n_host = sum(1 for o in observations if len(o.events) >= 2)
56
+ if n_host < n_obs:
57
+ s.append(f"Of these, {n_host} observations contained at least two events and "
58
+ f"could therefore contain a pattern; the remaining {n_obs - n_host} "
59
+ f"contributed to the baseline event rates but could not themselves "
60
+ f"host a detected pattern.")
51
61
 
52
62
  # --- core detection ---
53
63
  s.append(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tpattern
3
- Version: 0.1.9
3
+ Version: 0.1.10
4
4
  Summary: Open, auditable temporal & sequential pattern analysis (T-patterns) for sport and behaviour
5
5
  Author-email: Andrew Callaway <acallaway@bournemouth.ac.uk>
6
6
  License-Expression: MIT
@@ -6,6 +6,7 @@ tests/test_contrast.py
6
6
  tests/test_dedup.py
7
7
  tests/test_guided.py
8
8
  tests/test_null_compare.py
9
+ tests/test_read_table_validation.py
9
10
  tests/test_report_dendrogram.py
10
11
  tests/test_reproduction.py
11
12
  tests/test_sofcoder.py
@@ -1,303 +0,0 @@
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 = "end", obs_start: str | None = "obs_start",
120
- obs_end: str | None = "obs_end", 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
- These default to the conventional column names ("obs_start"/"obs_end"), so a
139
- table that includes them is read correctly with **no arguments**; they are
140
- ignored silently if the columns are absent. Supply a window when the unit has
141
- real bounds that extend beyond its first and last event — a possession, rally
142
- or bout usually does. The window length T = obs_end - obs_start is the
143
- denominator of the NX/T baseline, so deriving it from the events instead will
144
- shift every baseline probability. If absent, the window falls back to the
145
- first/last event time.
146
- build_event_from : list[str], optional
147
- If given, the event code is built by joining these columns with '_'
148
- (for coders who keep the code split across descriptor columns) instead of
149
- reading `event` directly.
150
- time_unit : {'s', 'ms'}
151
- Unit of the time columns. Internally times are integer milliseconds, so
152
- seconds are scaled by 1000 (the engine's baseline maths is unit-agnostic;
153
- this only fixes the reported interval unit).
154
- """
155
- if time_unit not in ("s", "ms"):
156
- raise ValueError(f"time_unit must be 's' or 'ms', got {time_unit!r}")
157
- path = Path(path)
158
- scale = 1000 if time_unit == "s" else 1
159
- rows_by_obs: dict[str, list] = defaultdict(list)
160
- ends_by_obs: dict[str, list] = defaultdict(list)
161
- window_by_obs: dict[str, tuple] = {}
162
-
163
- with open(path, newline="") as fh:
164
- reader = csv.DictReader(fh, delimiter=sep)
165
- cols = set(reader.fieldnames or [])
166
- # the event code comes from `event`, unless it is built from other columns
167
- code_cols = set(build_event_from) if build_event_from else {event}
168
- required = {observation, start} | code_cols
169
- missing = required - cols
170
- if missing:
171
- raise ValueError(
172
- f"table {path.name} is missing required column(s) {sorted(missing)}. "
173
- f"Found columns: {sorted(cols)}. Expected one row per event with at "
174
- f"least '{observation}', '{event}' and '{start}' (rename via the "
175
- f"read_table arguments if your export uses different names; see SCHEMA.md).")
176
- for r in reader:
177
- obs_id = (r.get(observation) or "").strip()
178
- if not obs_id:
179
- continue
180
- if build_event_from:
181
- # join only the non-empty parts, so an event with no value for one
182
- # column (e.g. a Challenge with no outcome) yields "Challenge_Pressure",
183
- # not "Challenge__Pressure", and one with none yields just its type.
184
- parts = [(r.get(c) or "").strip() for c in build_event_from]
185
- code = "_".join(p for p in parts if p)
186
- else:
187
- code = (r.get(event) or "").strip()
188
- if not code:
189
- continue
190
- try:
191
- t = int(round(float(r[start]) * scale))
192
- except (ValueError, TypeError, KeyError):
193
- continue
194
- rows_by_obs[obs_id].append((t, code))
195
- if end and r.get(end) not in (None, ""):
196
- try:
197
- ends_by_obs[obs_id].append(int(round(float(r[end]) * scale)))
198
- except ValueError:
199
- pass
200
- if obs_start and obs_end and obs_id not in window_by_obs:
201
- try:
202
- window_by_obs[obs_id] = (
203
- int(round(float(r[obs_start]) * scale)),
204
- int(round(float(r[obs_end]) * scale)))
205
- except (ValueError, TypeError, KeyError):
206
- pass
207
-
208
- sample: list[Observation] = []
209
- for obs_id, evs in rows_by_obs.items():
210
- evs.sort(key=lambda e: e[0])
211
- if obs_id in window_by_obs:
212
- o_start, o_end = window_by_obs[obs_id]
213
- else:
214
- o_start = evs[0][0]
215
- o_end = max(ends_by_obs[obs_id]) if ends_by_obs.get(obs_id) else evs[-1][0]
216
- sample.append(Observation(name=obs_id, start=o_start, end=o_end, events=evs))
217
- return sample
218
-
219
-
220
- def read_sofcoder(path: str | Path, *, dataname: str = "DATANAME",
221
- t: str = "T", events: str = "Events", sep: str | None = None,
222
- code_sep: str = ",", frames_per_second: float | None = None,
223
- start_marker: str = ":", end_marker: str = "&") -> list[Observation]:
224
- """Read a SOF-CODER / THEME data file (DATANAME, T, Events) into a sample.
225
-
226
- SOF-CODER (Jonsson et al., 2006) exports one row per time frame, each row
227
- carrying several comma-separated category codes active at that frame (its
228
- multi-criteria "field format"). This reader explodes each row so every code
229
- becomes one event at time ``T``; codes that share a frame are co-timed, and
230
- ``min_lag``/``collapse_duplicates`` then decide how co-occurrence is treated.
231
- Each ``DATANAME`` becomes one :class:`Observation`. Rows whose Events field is
232
- the start (``:``) or end (``&``) marker set the observation window
233
- ``[start, end]`` (``T = end - start`` is the NX/T baseline denominator); if the
234
- markers are absent the window falls back to the first/last event.
235
-
236
- The outer column delimiter is auto-detected (tab if present, else any
237
- whitespace) unless ``sep`` is given; ``code_sep`` splits the Events field
238
- (default ``','``). ``T`` is kept in the file's native frame unit unless
239
- ``frames_per_second`` is supplied, in which case frames are converted to
240
- milliseconds. A header row naming the columns is used if present, else the
241
- columns are taken positionally as (DATANAME, T, Events).
242
-
243
- This is the on-ramp for data collected in the SOF-CODER/THEME ecosystem; for
244
- a coder that already exports one row per single event, use :func:`read_table`.
245
- """
246
- path = Path(path)
247
- scale = (1000.0 / frames_per_second) if frames_per_second else 1.0
248
-
249
- def split_row(line: str) -> list[str]:
250
- if sep is not None:
251
- parts = line.split(sep)
252
- elif "\t" in line:
253
- parts = line.split("\t")
254
- else:
255
- parts = line.split(None, 2) # DATANAME, T, Events (Events holds no spaces)
256
- return [p.strip() for p in parts]
257
-
258
- with open(path, newline="") as fh:
259
- lines = [ln.strip() for ln in fh if ln.strip()]
260
- if not lines:
261
- return []
262
-
263
- header = [h.lower() for h in split_row(lines[0])]
264
- if dataname.lower() in header and events.lower() in header:
265
- di, ti, ei = (header.index(dataname.lower()), header.index(t.lower()),
266
- header.index(events.lower()))
267
- body = lines[1:]
268
- else:
269
- di, ti, ei, body = 0, 1, 2, lines # positional fallback
270
-
271
- rows_by_obs: dict[str, list] = defaultdict(list)
272
- starts: dict[str, int] = {}
273
- ends: dict[str, int] = {}
274
- for line in body:
275
- parts = split_row(line)
276
- if len(parts) <= max(di, ti, ei):
277
- continue
278
- obs_id = parts[di]
279
- if not obs_id:
280
- continue
281
- try:
282
- tv = int(round(float(parts[ti]) * scale))
283
- except (ValueError, TypeError):
284
- continue
285
- raw = parts[ei]
286
- if raw == start_marker:
287
- starts.setdefault(obs_id, tv)
288
- continue
289
- if raw == end_marker:
290
- ends[obs_id] = tv
291
- continue
292
- for code in raw.split(code_sep):
293
- code = code.strip()
294
- if code and code not in (start_marker, end_marker):
295
- rows_by_obs[obs_id].append((tv, code))
296
-
297
- sample: list[Observation] = []
298
- for obs_id, evs in rows_by_obs.items():
299
- evs.sort(key=lambda e: e[0])
300
- o_start = starts.get(obs_id, evs[0][0])
301
- o_end = ends.get(obs_id, evs[-1][0])
302
- sample.append(Observation(name=obs_id, start=o_start, end=o_end, events=evs))
303
- return sample
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes