football-docs 0.1.2 → 0.2.0
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.
- package/README.md +94 -30
- package/data/docs.db +0 -0
- package/dist/crawl.d.ts +34 -0
- package/dist/crawl.js +419 -0
- package/dist/discover.d.ts +53 -0
- package/dist/discover.js +121 -0
- package/dist/http.d.ts +8 -0
- package/dist/http.js +36 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +126 -18
- package/dist/ingest.d.ts +36 -12
- package/dist/ingest.js +120 -52
- package/docs/databallpy/data-model.md +146 -0
- package/docs/databallpy/overview.md +71 -0
- package/docs/databallpy/usage.md +280 -0
- package/docs/mplsoccer/overview.md +25 -0
- package/docs/mplsoccer/pitch-types.md +88 -0
- package/docs/mplsoccer/visualizations.md +280 -0
- package/docs/soccerdata/data-sources.md +131 -0
- package/docs/soccerdata/overview.md +54 -0
- package/docs/soccerdata/usage.md +157 -0
- package/package.json +24 -5
- package/providers.json +170 -0
package/dist/ingest.js
CHANGED
|
@@ -1,35 +1,67 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Ingest provider documentation into the SQLite FTS5 index.
|
|
3
3
|
*
|
|
4
|
-
* Reads markdown files from docs/
|
|
5
|
-
* by heading (## or ###), storing each chunk with metadata.
|
|
4
|
+
* Reads markdown files from docs/{provider}/*.md and chunks them
|
|
5
|
+
* by heading (## or ###), storing each chunk with provenance metadata.
|
|
6
|
+
*
|
|
7
|
+
* Each markdown file can include a YAML frontmatter block with provenance:
|
|
8
|
+
* ---
|
|
9
|
+
* source_url: https://example.com/docs
|
|
10
|
+
* source_type: readthedocs
|
|
11
|
+
* upstream_version: 1.8.8
|
|
12
|
+
* crawled_at: 2026-03-26T00:00:00Z
|
|
13
|
+
* ---
|
|
14
|
+
*
|
|
15
|
+
* Files without frontmatter default to source_type: "curated".
|
|
6
16
|
*
|
|
7
17
|
* Usage:
|
|
8
18
|
* npm run ingest # ingest all providers
|
|
9
19
|
* npm run ingest -- --provider opta # ingest one provider
|
|
10
|
-
*
|
|
11
|
-
* Doc file naming convention:
|
|
12
|
-
* docs/providers/{provider}/{category}.md
|
|
13
|
-
*
|
|
14
|
-
* Example:
|
|
15
|
-
* docs/providers/opta/event-types.md
|
|
16
|
-
* docs/providers/opta/qualifiers.md
|
|
17
|
-
* docs/providers/opta/coordinate-system.md
|
|
18
|
-
* docs/providers/statsbomb/events.md
|
|
19
|
-
* docs/providers/kloppy/data-model.md
|
|
20
20
|
*/
|
|
21
|
-
import
|
|
22
|
-
import {
|
|
21
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
22
|
+
import { basename, dirname, resolve } from "node:path";
|
|
23
23
|
import { fileURLToPath } from "node:url";
|
|
24
|
-
import
|
|
24
|
+
import Database from "better-sqlite3";
|
|
25
25
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
26
|
const DOCS_DIR = resolve(__dirname, "..", "docs");
|
|
27
27
|
const DB_DIR = resolve(__dirname, "..", "data");
|
|
28
28
|
const DB_PATH = resolve(DB_DIR, "docs.db");
|
|
29
|
+
/** Parse optional YAML frontmatter from a markdown file. */
|
|
30
|
+
export function parseFrontmatter(text) {
|
|
31
|
+
const defaults = {
|
|
32
|
+
source_url: null,
|
|
33
|
+
source_type: "curated",
|
|
34
|
+
upstream_version: null,
|
|
35
|
+
crawled_at: null,
|
|
36
|
+
};
|
|
37
|
+
if (!text.startsWith("---")) {
|
|
38
|
+
return { frontmatter: defaults, body: text };
|
|
39
|
+
}
|
|
40
|
+
const endIndex = text.indexOf("\n---", 3);
|
|
41
|
+
if (endIndex === -1) {
|
|
42
|
+
return { frontmatter: defaults, body: text };
|
|
43
|
+
}
|
|
44
|
+
const yamlBlock = text.slice(4, endIndex);
|
|
45
|
+
const body = text.slice(endIndex + 4).trimStart();
|
|
46
|
+
// Simple YAML key: value parser (no dependency needed for flat frontmatter)
|
|
47
|
+
const fm = { ...defaults };
|
|
48
|
+
for (const line of yamlBlock.split("\n")) {
|
|
49
|
+
const match = line.match(/^(\w+):\s*(.+)$/);
|
|
50
|
+
if (match) {
|
|
51
|
+
const [, key, value] = match;
|
|
52
|
+
const cleaned = value.replace(/^["']|["']$/g, "").trim();
|
|
53
|
+
if (key in fm) {
|
|
54
|
+
fm[key] = cleaned || null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { frontmatter: fm, body };
|
|
59
|
+
}
|
|
29
60
|
/** Split a markdown file into chunks by ## or ### headings. */
|
|
30
|
-
function chunkMarkdown(text, provider, category) {
|
|
61
|
+
export function chunkMarkdown(text, provider, category) {
|
|
62
|
+
const { frontmatter, body } = parseFrontmatter(text);
|
|
31
63
|
const chunks = [];
|
|
32
|
-
const lines =
|
|
64
|
+
const lines = body.split("\n");
|
|
33
65
|
let currentTitle = `${provider} - ${category}`;
|
|
34
66
|
let currentLines = [];
|
|
35
67
|
for (const line of lines) {
|
|
@@ -37,7 +69,7 @@ function chunkMarkdown(text, provider, category) {
|
|
|
37
69
|
if (headingMatch && currentLines.length > 0) {
|
|
38
70
|
const content = currentLines.join("\n").trim();
|
|
39
71
|
if (content.length > 20) {
|
|
40
|
-
chunks.push({ provider, category, title: currentTitle, content });
|
|
72
|
+
chunks.push({ provider, category, title: currentTitle, content, ...frontmatter });
|
|
41
73
|
}
|
|
42
74
|
currentTitle = headingMatch[2].trim();
|
|
43
75
|
currentLines = [line];
|
|
@@ -48,7 +80,7 @@ function chunkMarkdown(text, provider, category) {
|
|
|
48
80
|
}
|
|
49
81
|
const content = currentLines.join("\n").trim();
|
|
50
82
|
if (content.length > 20) {
|
|
51
|
-
chunks.push({ provider, category, title: currentTitle, content });
|
|
83
|
+
chunks.push({ provider, category, title: currentTitle, content, ...frontmatter });
|
|
52
84
|
}
|
|
53
85
|
return chunks;
|
|
54
86
|
}
|
|
@@ -61,60 +93,92 @@ function ingestProvider(db, provider) {
|
|
|
61
93
|
}
|
|
62
94
|
const files = readdirSync(providerDir).filter((f) => f.endsWith(".md"));
|
|
63
95
|
let totalChunks = 0;
|
|
64
|
-
const insert = db.prepare(
|
|
96
|
+
const insert = db.prepare(`INSERT INTO docs (provider, category, title, content, source_url, source_type, upstream_version, crawled_at)
|
|
97
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
65
98
|
for (const file of files) {
|
|
66
99
|
const category = basename(file, ".md");
|
|
67
100
|
const text = readFileSync(resolve(providerDir, file), "utf-8");
|
|
68
101
|
const chunks = chunkMarkdown(text, provider, category);
|
|
69
102
|
for (const chunk of chunks) {
|
|
70
|
-
insert.run(chunk.provider, chunk.category, chunk.title, chunk.content);
|
|
103
|
+
insert.run(chunk.provider, chunk.category, chunk.title, chunk.content, chunk.source_url, chunk.source_type, chunk.upstream_version, chunk.crawled_at);
|
|
71
104
|
}
|
|
72
105
|
totalChunks += chunks.length;
|
|
73
|
-
|
|
106
|
+
const sourceTag = chunks[0]?.source_type ?? "curated";
|
|
107
|
+
console.log(` ${provider}/${file}: ${chunks.length} chunks [${sourceTag}]`);
|
|
74
108
|
}
|
|
75
109
|
return totalChunks;
|
|
76
110
|
}
|
|
111
|
+
export const SCHEMA_SQL = `
|
|
112
|
+
CREATE TABLE IF NOT EXISTS docs (
|
|
113
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
114
|
+
provider TEXT NOT NULL,
|
|
115
|
+
category TEXT NOT NULL,
|
|
116
|
+
title TEXT NOT NULL,
|
|
117
|
+
content TEXT NOT NULL,
|
|
118
|
+
source_url TEXT,
|
|
119
|
+
source_type TEXT NOT NULL DEFAULT 'curated',
|
|
120
|
+
upstream_version TEXT,
|
|
121
|
+
crawled_at TEXT
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(
|
|
125
|
+
provider,
|
|
126
|
+
category,
|
|
127
|
+
title,
|
|
128
|
+
content,
|
|
129
|
+
content='docs',
|
|
130
|
+
content_rowid='id',
|
|
131
|
+
tokenize='porter unicode61'
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
CREATE TRIGGER IF NOT EXISTS docs_ai AFTER INSERT ON docs BEGIN
|
|
135
|
+
INSERT INTO docs_fts(rowid, provider, category, title, content)
|
|
136
|
+
VALUES (new.id, new.provider, new.category, new.title, new.content);
|
|
137
|
+
END;
|
|
138
|
+
`;
|
|
139
|
+
/** Ensure tables exist without dropping existing data. */
|
|
140
|
+
function ensureSchema(db) {
|
|
141
|
+
db.exec(SCHEMA_SQL);
|
|
142
|
+
}
|
|
143
|
+
/** Drop everything and recreate from scratch. */
|
|
144
|
+
function rebuildSchema(db) {
|
|
145
|
+
db.exec(`
|
|
146
|
+
DROP TABLE IF EXISTS docs_fts;
|
|
147
|
+
DROP TABLE IF EXISTS docs;
|
|
148
|
+
`);
|
|
149
|
+
db.exec(SCHEMA_SQL);
|
|
150
|
+
}
|
|
77
151
|
function main() {
|
|
78
152
|
const providerArg = process.argv.indexOf("--provider");
|
|
79
153
|
const singleProvider = providerArg >= 0 ? process.argv[providerArg + 1] : undefined;
|
|
80
154
|
mkdirSync(DB_DIR, { recursive: true });
|
|
81
155
|
const db = new Database(DB_PATH);
|
|
82
|
-
db.
|
|
83
|
-
DROP TABLE IF EXISTS docs_fts;
|
|
84
|
-
DROP TABLE IF EXISTS docs;
|
|
85
|
-
|
|
86
|
-
CREATE TABLE docs (
|
|
87
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
88
|
-
provider TEXT NOT NULL,
|
|
89
|
-
category TEXT NOT NULL,
|
|
90
|
-
title TEXT NOT NULL,
|
|
91
|
-
content TEXT NOT NULL
|
|
92
|
-
);
|
|
93
|
-
|
|
94
|
-
CREATE VIRTUAL TABLE docs_fts USING fts5(
|
|
95
|
-
provider,
|
|
96
|
-
category,
|
|
97
|
-
title,
|
|
98
|
-
content,
|
|
99
|
-
content='docs',
|
|
100
|
-
content_rowid='id',
|
|
101
|
-
tokenize='porter unicode61'
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
CREATE TRIGGER docs_ai AFTER INSERT ON docs BEGIN
|
|
105
|
-
INSERT INTO docs_fts(rowid, provider, category, title, content)
|
|
106
|
-
VALUES (new.id, new.provider, new.category, new.title, new.content);
|
|
107
|
-
END;
|
|
108
|
-
`);
|
|
109
|
-
console.log("Ingesting provider docs...\n");
|
|
156
|
+
db.pragma("journal_mode = WAL");
|
|
110
157
|
if (singleProvider) {
|
|
158
|
+
// Single-provider mode: only rebuild that provider's data
|
|
159
|
+
// Ensure tables exist (in case DB is fresh)
|
|
160
|
+
ensureSchema(db);
|
|
161
|
+
// Atomically delete existing data and re-ingest for this provider
|
|
162
|
+
const deleteAndReinsert = db.transaction(() => {
|
|
163
|
+
const rows = db.prepare("SELECT id, provider, category, title, content FROM docs WHERE provider = ?").all(singleProvider);
|
|
164
|
+
const ftsDelete = db.prepare("INSERT INTO docs_fts(docs_fts, rowid, provider, category, title, content) VALUES('delete', ?, ?, ?, ?, ?)");
|
|
165
|
+
for (const row of rows) {
|
|
166
|
+
ftsDelete.run(row.id, row.provider, row.category, row.title, row.content);
|
|
167
|
+
}
|
|
168
|
+
db.prepare("DELETE FROM docs WHERE provider = ?").run(singleProvider);
|
|
169
|
+
});
|
|
170
|
+
deleteAndReinsert();
|
|
171
|
+
console.log(`Ingesting ${singleProvider}...\n`);
|
|
111
172
|
const count = ingestProvider(db, singleProvider);
|
|
112
173
|
console.log(`\nDone: ${count} chunks from ${singleProvider}`);
|
|
113
174
|
}
|
|
114
175
|
else {
|
|
176
|
+
// Full rebuild: drop and recreate everything
|
|
177
|
+
rebuildSchema(db);
|
|
178
|
+
console.log("Ingesting provider docs...\n");
|
|
115
179
|
if (!existsSync(DOCS_DIR)) {
|
|
116
180
|
console.log(`No docs directory at ${DOCS_DIR}`);
|
|
117
|
-
console.log("Create docs/
|
|
181
|
+
console.log("Create docs/{provider}/*.md files first.");
|
|
118
182
|
db.close();
|
|
119
183
|
return;
|
|
120
184
|
}
|
|
@@ -130,4 +194,8 @@ function main() {
|
|
|
130
194
|
}
|
|
131
195
|
db.close();
|
|
132
196
|
}
|
|
133
|
-
|
|
197
|
+
// Only run when executed directly, not when imported for tests
|
|
198
|
+
const isDirectRun = process.argv[1]?.endsWith("ingest.ts") || process.argv[1]?.endsWith("ingest.js");
|
|
199
|
+
if (isDirectRun) {
|
|
200
|
+
main();
|
|
201
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# DataBallPy Data Model
|
|
2
|
+
|
|
3
|
+
## Game Object
|
|
4
|
+
|
|
5
|
+
The `Game` dataclass is the central object, holding tracking data, event data, and match metadata.
|
|
6
|
+
|
|
7
|
+
**Key attributes**:
|
|
8
|
+
|
|
9
|
+
| Attribute | Type | Description |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `tracking_data` | `TrackingData` | Custom pandas DataFrame subclass |
|
|
12
|
+
| `event_data` | `EventData` | Custom pandas DataFrame subclass |
|
|
13
|
+
| `pitch_dimensions` | `list[float]` | [length, width] in metres (typically [106.0, 68.0]) |
|
|
14
|
+
| `periods` | `pd.DataFrame` | Start/end frames and datetimes for periods 1-5 |
|
|
15
|
+
| `home_team_id` | `int` | |
|
|
16
|
+
| `home_team_name` | `str` | |
|
|
17
|
+
| `home_players` | `pd.DataFrame` | Player info (see PlayersSchema) |
|
|
18
|
+
| `home_score` | `int` | |
|
|
19
|
+
| `home_formation` | `str` | |
|
|
20
|
+
| `away_team_id` | `int` | |
|
|
21
|
+
| `away_team_name` | `str` | |
|
|
22
|
+
| `away_players` | `pd.DataFrame` | |
|
|
23
|
+
| `away_score` | `int` | |
|
|
24
|
+
| `away_formation` | `str` | |
|
|
25
|
+
| `country` | `str \| None` | |
|
|
26
|
+
| `shot_events` | `pd.DataFrame` | Typed shot events |
|
|
27
|
+
| `dribble_events` | `pd.DataFrame` | Typed dribble events |
|
|
28
|
+
| `pass_events` | `pd.DataFrame` | Typed pass events |
|
|
29
|
+
|
|
30
|
+
**Key methods**: `synchronise_tracking_and_event_data()`, `get_column_ids()`, `get_event()`, `get_frames()`, `get_event_frame()`, `save_game()`, `copy()`.
|
|
31
|
+
|
|
32
|
+
**Key properties**: `is_synchronised`, `tracking_timestamp_is_precise`, `event_timestamp_is_precise`, `date`, `name`.
|
|
33
|
+
|
|
34
|
+
## TrackingData
|
|
35
|
+
|
|
36
|
+
Custom `pd.DataFrame` subclass with extra attributes: `provider: str`, `frame_rate: int|float`.
|
|
37
|
+
|
|
38
|
+
**Required columns** (validated by Pandera schema):
|
|
39
|
+
|
|
40
|
+
| Column | Type | Description |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `frame` | int (unique) | Frame number |
|
|
43
|
+
| `datetime` | datetime | Timestamp |
|
|
44
|
+
| `ball_x` | float | Ball x position (metres from centre) |
|
|
45
|
+
| `ball_y` | float | Ball y position (metres from centre) |
|
|
46
|
+
| `ball_z` | float | Ball height |
|
|
47
|
+
| `ball_status` | str | `"alive"` or `"dead"` |
|
|
48
|
+
| `team_possession` | str | Which team has possession |
|
|
49
|
+
|
|
50
|
+
**Player columns** follow the pattern: `home_1_x`, `home_1_y`, `away_3_x`, `away_3_y`, etc. Player IDs map to the `home_players` / `away_players` DataFrames on the Game object.
|
|
51
|
+
|
|
52
|
+
**Added columns** (from preprocessing methods):
|
|
53
|
+
- `{col_id}_vx`, `{col_id}_vy` -- velocity (from `add_velocity()`)
|
|
54
|
+
- `{col_id}_ax`, `{col_id}_ay` -- acceleration (from `add_acceleration()`)
|
|
55
|
+
- `event_id`, `databallpy_event`, `sync_certainty` -- after synchronization
|
|
56
|
+
|
|
57
|
+
## EventData
|
|
58
|
+
|
|
59
|
+
Custom `pd.DataFrame` subclass with extra attribute: `provider: str`.
|
|
60
|
+
|
|
61
|
+
**Required columns** (validated by Pandera schema):
|
|
62
|
+
|
|
63
|
+
| Column | Type | Description |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| `event_id` | int (unique) | Event identifier |
|
|
66
|
+
| `databallpy_event` | str (nullable) | One of: `"pass"`, `"shot"`, `"dribble"`, `"tackle"`, `"own_goal"` |
|
|
67
|
+
| `period_id` | int | Period number |
|
|
68
|
+
| `minutes` | int | Minute of event |
|
|
69
|
+
| `seconds` | int | Second within minute |
|
|
70
|
+
| `player_id` | int | Player identifier |
|
|
71
|
+
| `player_name` | str | |
|
|
72
|
+
| `team_id` | int | |
|
|
73
|
+
| `is_successful` | bool | |
|
|
74
|
+
| `start_x` | float | Range: -60 to 60 |
|
|
75
|
+
| `start_y` | float | Range: -45 to 45 |
|
|
76
|
+
| `datetime` | datetime | Timestamp |
|
|
77
|
+
| `original_event_id` | str | Provider's event ID |
|
|
78
|
+
| `original_event` | str | Provider's event type name |
|
|
79
|
+
|
|
80
|
+
**Optional columns**: `end_x`, `end_y`, `to_player_id`, `to_player_name`, `event_type_id`, `team_name`.
|
|
81
|
+
|
|
82
|
+
**Added columns after synchronization**: `tracking_frame`, `sync_certainty`.
|
|
83
|
+
|
|
84
|
+
## PlayersSchema
|
|
85
|
+
|
|
86
|
+
| Column | Type | Constraints |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `id` | int | Unique |
|
|
89
|
+
| `full_name` | str | Unique |
|
|
90
|
+
| `shirt_num` | int | Unique, 0-100 |
|
|
91
|
+
| `position` | str | goalkeeper / defender / midfielder / forward / unspecified |
|
|
92
|
+
| `start_frame` | int | |
|
|
93
|
+
| `end_frame` | int | |
|
|
94
|
+
| `starter` | bool | |
|
|
95
|
+
|
|
96
|
+
## Event Classes
|
|
97
|
+
|
|
98
|
+
All event classes are dataclasses in `databallpy/events/` with strict validation.
|
|
99
|
+
|
|
100
|
+
### IndividualCloseToBallEvent (base)
|
|
101
|
+
|
|
102
|
+
Fields: `event_id`, `period_id`, `minutes`, `seconds`, `datetime`, `start_x`, `start_y`, `team_id`, `team_side`, `pitch_size`, `player_id`, `jersey`, `outcome`, `related_event_id`.
|
|
103
|
+
|
|
104
|
+
### IndividualOnBallEvent (extends base)
|
|
105
|
+
|
|
106
|
+
Adds: `body_part`, `possession_type`, `set_piece`, plus computed `xt` (expected threat) property.
|
|
107
|
+
|
|
108
|
+
**body_part values**: `right_foot`, `left_foot`, `foot`, `head`, `other`, `unspecified`.
|
|
109
|
+
|
|
110
|
+
**set_piece values**: `goal_kick`, `free_kick`, `throw_in`, `corner_kick`, `kick_off`, `penalty`, `no_set_piece`, `unspecified`.
|
|
111
|
+
|
|
112
|
+
**possession_type values**: `open_play`, `counter_attack`, `corner_kick`, `free_kick`, `throw_in`, `penalty`, `kick_off`, `goal_kick`, `rebound`, `unspecified`.
|
|
113
|
+
|
|
114
|
+
### ShotEvent
|
|
115
|
+
|
|
116
|
+
Extends `IndividualOnBallEvent`. Adds: `outcome_str`, `y_target`, `z_target`, `first_touch`, `ball_goal_distance`, `shot_angle`, `xg` (auto-computed).
|
|
117
|
+
|
|
118
|
+
**outcome_str values**: `goal`, `miss_off_target`, `miss_hit_post`, `miss_on_target`, `blocked`, `own_goal`, `miss`, `unspecified`.
|
|
119
|
+
|
|
120
|
+
xG model: logistic regression on distance and angle, with separate models for foot/head/free_kick/penalty. Penalty xG = 0.79.
|
|
121
|
+
|
|
122
|
+
### PassEvent
|
|
123
|
+
|
|
124
|
+
Extends `IndividualOnBallEvent`. Adds: `outcome_str`, `end_x`, `end_y`, `pass_type`, `receiver_player_id`.
|
|
125
|
+
|
|
126
|
+
**outcome_str values**: `successful`, `unsuccessful`, `offside`, `results_in_shot`, `assist`, `fair_play`, `unspecified`.
|
|
127
|
+
|
|
128
|
+
**pass_type values**: `long_ball`, `cross`, `through_ball`, `chipped`, `lay-off`, `lounge`, `flick_on`, `pull_back`, `switch_off_play`, `unspecified`.
|
|
129
|
+
|
|
130
|
+
### DribbleEvent
|
|
131
|
+
|
|
132
|
+
Extends `IndividualOnBallEvent`. Adds: `duel_type` (offensive/defensive/unspecified), `with_opponent` (bool).
|
|
133
|
+
|
|
134
|
+
### TackleEvent
|
|
135
|
+
|
|
136
|
+
Extends `IndividualCloseToBallEvent`. No additional fields.
|
|
137
|
+
|
|
138
|
+
## Expected Threat (xT)
|
|
139
|
+
|
|
140
|
+
Karun Singh model with pre-computed lookup tables for open play, free kicks, and throw-ins. Auto-computed as `xt` property on all `IndividualOnBallEvent` subclasses.
|
|
141
|
+
|
|
142
|
+
Special values: penalty = 0.797, corner kick = 0.049, goal kick = 0.0, kick off = 0.001.
|
|
143
|
+
|
|
144
|
+
## Missing Values
|
|
145
|
+
|
|
146
|
+
`MISSING_INT = -999` is used as a sentinel for missing integers throughout the codebase.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# DataBallPy
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
DataBallPy is a Python package for loading, synchronizing, and analyzing soccer event and tracking data. Developed by Alexander Oonk and Daan Grob, licensed under MIT. Current version: 0.7.2.
|
|
6
|
+
|
|
7
|
+
Its key distinguishing feature is **smart synchronization of tracking and event data** using the Needleman-Wunsch algorithm (borrowed from bioinformatics sequence alignment), which preserves event ordering during alignment. Naive cost-function approaches do not guarantee this.
|
|
8
|
+
|
|
9
|
+
- **Install**: `pip install databallpy` (optional: `pip install 'databallpy[kloppy]'` for kloppy integration, `pip install 'databallpy[accessible-space]'` for dangerous accessible space)
|
|
10
|
+
- **Python**: 3.10 through 3.14
|
|
11
|
+
- **Key dependencies**: pandas>=2.1, numpy, scipy, matplotlib, pandera (schema validation), beautifulsoup4, pyarrow
|
|
12
|
+
|
|
13
|
+
## Supported Providers
|
|
14
|
+
|
|
15
|
+
### Tracking Data
|
|
16
|
+
|
|
17
|
+
| Provider | `tracking_data_provider` | Precise Timestamps | Notes |
|
|
18
|
+
|---|---|---|---|
|
|
19
|
+
| Tracab | `"tracab"` | Yes | Includes Sportec/DFL format |
|
|
20
|
+
| Metrica | `"metrica"` | Yes | Has open data support |
|
|
21
|
+
| Inmotio | `"inmotio"` | No | |
|
|
22
|
+
| Sportec/DFL | `"dfl"` or `"sportec"` | Yes | Uses Tracab parser internally |
|
|
23
|
+
|
|
24
|
+
### Event Data
|
|
25
|
+
|
|
26
|
+
| Provider | `event_data_provider` | Precise Timestamps | Notes |
|
|
27
|
+
|---|---|---|---|
|
|
28
|
+
| Opta | `"opta"` | Yes | F24 (events) + F7 (metadata) XML files |
|
|
29
|
+
| Metrica | `"metrica"` | Yes | Has open data support |
|
|
30
|
+
| Instat | `"instat"` | No | |
|
|
31
|
+
| SciSports | `"scisports"` | No | No separate metadata file needed |
|
|
32
|
+
| Sportec/DFL | `"dfl"` or `"sportec"` | Yes | |
|
|
33
|
+
| StatsBomb | `"statsbomb"` | No | Requires event_match_loc + event_lineup_loc |
|
|
34
|
+
|
|
35
|
+
### Kloppy Integration (v0.7.0+)
|
|
36
|
+
|
|
37
|
+
Install with `pip install 'databallpy[kloppy]'` to parse data from any kloppy-supported provider and convert to a DataBallPy `Game` object via `get_game_from_kloppy()`. This dramatically expands provider coverage.
|
|
38
|
+
|
|
39
|
+
## Coordinate System
|
|
40
|
+
|
|
41
|
+
Origin at center of pitch. All data is normalised so:
|
|
42
|
+
- Home team always plays left to right (negative x to positive x)
|
|
43
|
+
- Away team always plays right to left
|
|
44
|
+
- x range: roughly -53 to +53 metres (half pitch length)
|
|
45
|
+
- y range: roughly -34 to +34 metres (half pitch width)
|
|
46
|
+
- Validation bounds: ball_x [-62.5, 62.5], ball_y [-45, 45]
|
|
47
|
+
|
|
48
|
+
**Playing direction options** when retrieving frames:
|
|
49
|
+
- `"team_oriented"` (default): home left-to-right
|
|
50
|
+
- `"possession_oriented"`: coordinates flipped so possessing team always plays left-to-right
|
|
51
|
+
|
|
52
|
+
## Built-in Open Data
|
|
53
|
+
|
|
54
|
+
`get_open_game()` provides 7 DFL/Bundesliga matches (2 Bundesliga 1 + 5 Bundesliga 2):
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from databallpy import get_open_game
|
|
58
|
+
game = get_open_game(provider="sportec", game_id="J03WMX")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Limitations and Caveats
|
|
62
|
+
|
|
63
|
+
- **Both data streams often needed**: Many features (synchronization, team possession, event frames) require both tracking and event data.
|
|
64
|
+
- **Simple xG/xT models**: Built-in xG uses basic logistic regression on distance and angle. xT uses pre-computed static lookup tables. Useful for quick analysis, not production-grade.
|
|
65
|
+
- **Sync quality depends on data quality**: If ball_status is wrong, timestamps imprecise, or tracking data starts late, sync degrades.
|
|
66
|
+
- **No Second Spectrum native parser**: Must use Kloppy bridge.
|
|
67
|
+
- **Video export requires ffmpeg**: `save_tracking_video` needs ffmpeg installed.
|
|
68
|
+
- **Deprecation in progress (v0.7.x to v0.8.0)**: `Match` class deprecated in favour of `Game`. Standalone feature functions deprecated in favour of methods on `TrackingData`.
|
|
69
|
+
- **Strict coordinate validation**: Pandera schema validation on load can reject data with positions slightly outside expected bounds.
|
|
70
|
+
- **Performance**: Some features iterate frame-by-frame in Python (Voronoi, pressure), which can be slow for full-game tracking data at 25 Hz (~135,000 frames).
|
|
71
|
+
- **Soccer only**: Despite the generic name, no support for other sports.
|