tpattern 0.1.8__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.
- {tpattern-0.1.8/tpattern.egg-info → tpattern-0.1.10}/PKG-INFO +1 -1
- {tpattern-0.1.8 → tpattern-0.1.10}/pyproject.toml +1 -1
- tpattern-0.1.10/tests/test_read_table_validation.py +121 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tpattern/__init__.py +4 -2
- tpattern-0.1.10/tpattern/io.py +679 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tpattern/methods.py +10 -0
- {tpattern-0.1.8 → tpattern-0.1.10/tpattern.egg-info}/PKG-INFO +1 -1
- {tpattern-0.1.8 → tpattern-0.1.10}/tpattern.egg-info/SOURCES.txt +1 -0
- tpattern-0.1.8/tpattern/io.py +0 -300
- {tpattern-0.1.8 → tpattern-0.1.10}/LICENSE +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/README.md +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/setup.cfg +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tests/test_calibrated_pins.py +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tests/test_contrast.py +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tests/test_dedup.py +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tests/test_guided.py +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tests/test_null_compare.py +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tests/test_report_dendrogram.py +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tests/test_reproduction.py +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tests/test_sofcoder.py +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tests/test_support_reporting.py +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tests/test_synthetic.py +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tpattern/advisor.py +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tpattern/ci.py +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tpattern/contrast.py +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tpattern/detect.py +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tpattern/diagnostics.py +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tpattern/guided.py +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tpattern/pattern.py +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tpattern/randomise.py +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tpattern/report.py +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tpattern/significance.py +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tpattern/synthetic.py +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tpattern/viz.py +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tpattern.egg-info/dependency_links.txt +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tpattern.egg-info/requires.txt +0 -0
- {tpattern-0.1.8 → tpattern-0.1.10}/tpattern.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tpattern"
|
|
7
|
-
version = "0.1.
|
|
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
|
+
__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,
|
|
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(
|
tpattern-0.1.8/tpattern/io.py
DELETED
|
@@ -1,300 +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 = 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
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def read_sofcoder(path: str | Path, *, dataname: str = "DATANAME",
|
|
218
|
-
t: str = "T", events: str = "Events", sep: str | None = None,
|
|
219
|
-
code_sep: str = ",", frames_per_second: float | None = None,
|
|
220
|
-
start_marker: str = ":", end_marker: str = "&") -> list[Observation]:
|
|
221
|
-
"""Read a SOF-CODER / THEME data file (DATANAME, T, Events) into a sample.
|
|
222
|
-
|
|
223
|
-
SOF-CODER (Jonsson et al., 2006) exports one row per time frame, each row
|
|
224
|
-
carrying several comma-separated category codes active at that frame (its
|
|
225
|
-
multi-criteria "field format"). This reader explodes each row so every code
|
|
226
|
-
becomes one event at time ``T``; codes that share a frame are co-timed, and
|
|
227
|
-
``min_lag``/``collapse_duplicates`` then decide how co-occurrence is treated.
|
|
228
|
-
Each ``DATANAME`` becomes one :class:`Observation`. Rows whose Events field is
|
|
229
|
-
the start (``:``) or end (``&``) marker set the observation window
|
|
230
|
-
``[start, end]`` (``T = end - start`` is the NX/T baseline denominator); if the
|
|
231
|
-
markers are absent the window falls back to the first/last event.
|
|
232
|
-
|
|
233
|
-
The outer column delimiter is auto-detected (tab if present, else any
|
|
234
|
-
whitespace) unless ``sep`` is given; ``code_sep`` splits the Events field
|
|
235
|
-
(default ``','``). ``T`` is kept in the file's native frame unit unless
|
|
236
|
-
``frames_per_second`` is supplied, in which case frames are converted to
|
|
237
|
-
milliseconds. A header row naming the columns is used if present, else the
|
|
238
|
-
columns are taken positionally as (DATANAME, T, Events).
|
|
239
|
-
|
|
240
|
-
This is the on-ramp for data collected in the SOF-CODER/THEME ecosystem; for
|
|
241
|
-
a coder that already exports one row per single event, use :func:`read_table`.
|
|
242
|
-
"""
|
|
243
|
-
path = Path(path)
|
|
244
|
-
scale = (1000.0 / frames_per_second) if frames_per_second else 1.0
|
|
245
|
-
|
|
246
|
-
def split_row(line: str) -> list[str]:
|
|
247
|
-
if sep is not None:
|
|
248
|
-
parts = line.split(sep)
|
|
249
|
-
elif "\t" in line:
|
|
250
|
-
parts = line.split("\t")
|
|
251
|
-
else:
|
|
252
|
-
parts = line.split(None, 2) # DATANAME, T, Events (Events holds no spaces)
|
|
253
|
-
return [p.strip() for p in parts]
|
|
254
|
-
|
|
255
|
-
with open(path, newline="") as fh:
|
|
256
|
-
lines = [ln.strip() for ln in fh if ln.strip()]
|
|
257
|
-
if not lines:
|
|
258
|
-
return []
|
|
259
|
-
|
|
260
|
-
header = [h.lower() for h in split_row(lines[0])]
|
|
261
|
-
if dataname.lower() in header and events.lower() in header:
|
|
262
|
-
di, ti, ei = (header.index(dataname.lower()), header.index(t.lower()),
|
|
263
|
-
header.index(events.lower()))
|
|
264
|
-
body = lines[1:]
|
|
265
|
-
else:
|
|
266
|
-
di, ti, ei, body = 0, 1, 2, lines # positional fallback
|
|
267
|
-
|
|
268
|
-
rows_by_obs: dict[str, list] = defaultdict(list)
|
|
269
|
-
starts: dict[str, int] = {}
|
|
270
|
-
ends: dict[str, int] = {}
|
|
271
|
-
for line in body:
|
|
272
|
-
parts = split_row(line)
|
|
273
|
-
if len(parts) <= max(di, ti, ei):
|
|
274
|
-
continue
|
|
275
|
-
obs_id = parts[di]
|
|
276
|
-
if not obs_id:
|
|
277
|
-
continue
|
|
278
|
-
try:
|
|
279
|
-
tv = int(round(float(parts[ti]) * scale))
|
|
280
|
-
except (ValueError, TypeError):
|
|
281
|
-
continue
|
|
282
|
-
raw = parts[ei]
|
|
283
|
-
if raw == start_marker:
|
|
284
|
-
starts.setdefault(obs_id, tv)
|
|
285
|
-
continue
|
|
286
|
-
if raw == end_marker:
|
|
287
|
-
ends[obs_id] = tv
|
|
288
|
-
continue
|
|
289
|
-
for code in raw.split(code_sep):
|
|
290
|
-
code = code.strip()
|
|
291
|
-
if code and code not in (start_marker, end_marker):
|
|
292
|
-
rows_by_obs[obs_id].append((tv, code))
|
|
293
|
-
|
|
294
|
-
sample: list[Observation] = []
|
|
295
|
-
for obs_id, evs in rows_by_obs.items():
|
|
296
|
-
evs.sort(key=lambda e: e[0])
|
|
297
|
-
o_start = starts.get(obs_id, evs[0][0])
|
|
298
|
-
o_end = ends.get(obs_id, evs[-1][0])
|
|
299
|
-
sample.append(Observation(name=obs_id, start=o_start, end=o_end, events=evs))
|
|
300
|
-
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
|
|
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
|
|
File without changes
|
|
File without changes
|