korb 0.2.8__tar.gz → 0.2.9__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.
- {korb-0.2.8 → korb-0.2.9}/.gitignore +5 -0
- {korb-0.2.8 → korb-0.2.9}/PKG-INFO +1 -1
- korb-0.2.9/korb/__init__.py +6 -0
- {korb-0.2.8 → korb-0.2.9}/korb/__main__.py +68 -74
- {korb-0.2.8 → korb-0.2.9}/korb/core.py +26 -11
- {korb-0.2.8 → korb-0.2.9}/korb/ergebnisse.py +1 -1
- {korb-0.2.8 → korb-0.2.9}/korb/predict.py +76 -67
- {korb-0.2.8 → korb-0.2.9}/korb/schedule.py +32 -62
- {korb-0.2.8 → korb-0.2.9}/korb/standings.py +3 -3
- {korb-0.2.8 → korb-0.2.9}/korb/team.py +102 -56
- {korb-0.2.8 → korb-0.2.9}/pyproject.toml +2 -1
- korb-0.2.9/tests/conftest.py +40 -0
- {korb-0.2.8 → korb-0.2.9}/tests/test_cli.py +4 -2
- {korb-0.2.8 → korb-0.2.9}/tests/test_core.py +3 -2
- {korb-0.2.8 → korb-0.2.9}/tests/test_predict.py +9 -9
- {korb-0.2.8 → korb-0.2.9}/tests/test_schedule.py +6 -5
- {korb-0.2.8 → korb-0.2.9}/tests/test_standings.py +3 -3
- {korb-0.2.8 → korb-0.2.9}/tests/test_team.py +21 -4
- korb-0.2.8/.coverage +0 -0
- korb-0.2.8/korb/__init__.py +0 -6
- korb-0.2.8/tests/conftest.py +0 -22
- {korb-0.2.8 → korb-0.2.9}/.github/workflows/release.yml +0 -0
- {korb-0.2.8 → korb-0.2.9}/.github/workflows/test.yml +0 -0
- {korb-0.2.8 → korb-0.2.9}/CHANGELOG.md +0 -0
- {korb-0.2.8 → korb-0.2.9}/LICENSE +0 -0
- {korb-0.2.8 → korb-0.2.9}/README.md +0 -0
- {korb-0.2.8 → korb-0.2.9}/files/.gitkeep +0 -0
- {korb-0.2.8 → korb-0.2.9}/korb/py.typed +0 -0
- {korb-0.2.8 → korb-0.2.9}/tests/__init__.py +0 -0
- {korb-0.2.8 → korb-0.2.9}/tests/fixtures/ergebnisse_minimal.html +0 -0
- {korb-0.2.8 → korb-0.2.9}/tests/fixtures/spielplan_finalized.html +0 -0
- {korb-0.2.8 → korb-0.2.9}/tests/fixtures/spielplan_minimal.html +0 -0
- {korb-0.2.8 → korb-0.2.9}/tests/test_download.py +0 -0
- {korb-0.2.8 → korb-0.2.9}/tests/test_ergebnisse.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Basketball league CLI for DBB (Deutscher Basketball Bund) data.
|
|
3
3
|
|
|
4
|
-
Target: DBB Version ≤11.50.
|
|
4
|
+
Target: DBB Version ≤11.50.1-2f7f327 (legacy JSP platform).
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import argparse
|
|
@@ -26,7 +26,7 @@ from .schedule import (
|
|
|
26
26
|
parse_schedule,
|
|
27
27
|
print_schedule,
|
|
28
28
|
)
|
|
29
|
-
from .standings import calculate_standings, print_table
|
|
29
|
+
from .standings import Standing, calculate_standings, print_table
|
|
30
30
|
from .team import get_team_results, print_bars, print_metrics, print_results
|
|
31
31
|
|
|
32
32
|
|
|
@@ -38,7 +38,7 @@ def _json_out(data: object) -> None:
|
|
|
38
38
|
def _liga_meta(
|
|
39
39
|
league_info: LeagueInfo,
|
|
40
40
|
ligaid: int | None,
|
|
41
|
-
) -> dict:
|
|
41
|
+
) -> dict[str, object]:
|
|
42
42
|
"""Build liga metadata dict for JSON output.
|
|
43
43
|
|
|
44
44
|
Args:
|
|
@@ -54,18 +54,41 @@ def _liga_meta(
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
def _resolve_path(
|
|
58
|
+
explicit: str | None, ligaid: int | None, filename: str, flag: str, label: str
|
|
59
|
+
) -> str:
|
|
60
|
+
"""Resolve an HTML path from an explicit flag value or --ligaid.
|
|
61
|
+
|
|
62
|
+
Exits with an error if neither is provided.
|
|
63
|
+
"""
|
|
64
|
+
if explicit is not None:
|
|
65
|
+
return explicit
|
|
66
|
+
if ligaid is None:
|
|
67
|
+
print(
|
|
68
|
+
f"Error: pass {flag} PATH or --ligaid LIGAID for {label}.",
|
|
69
|
+
file=sys.stderr,
|
|
70
|
+
)
|
|
71
|
+
sys.exit(1)
|
|
72
|
+
return str(Path("files") / str(ligaid) / filename)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _resolve_results(args: argparse.Namespace, label: str) -> str:
|
|
76
|
+
"""Resolve the results HTML path from --results or --ligaid."""
|
|
77
|
+
return _resolve_path(
|
|
78
|
+
args.results, args.ligaid, "ergebnisse.html", "--results", label
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _resolve_schedule(args: argparse.Namespace, label: str) -> str:
|
|
83
|
+
"""Resolve the schedule HTML path from --schedule or --ligaid."""
|
|
84
|
+
return _resolve_path(
|
|
85
|
+
args.schedule, args.ligaid, "spielplan.html", "--schedule", label
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
57
89
|
def cmd_standings(args: argparse.Namespace) -> None:
|
|
58
90
|
"""Handle 'standings' subcommand."""
|
|
59
|
-
fp = args
|
|
60
|
-
if fp is None:
|
|
61
|
-
if args.ligaid is None:
|
|
62
|
-
print(
|
|
63
|
-
"Error: pass --results PATH or --ligaid LIGAID for standings.",
|
|
64
|
-
file=sys.stderr,
|
|
65
|
-
)
|
|
66
|
-
sys.exit(1)
|
|
67
|
-
fp = str(Path("files") / str(args.ligaid) / "ergebnisse.html")
|
|
68
|
-
|
|
91
|
+
fp = _resolve_results(args, "standings")
|
|
69
92
|
standings, league_info = calculate_standings(fp)
|
|
70
93
|
if args.json:
|
|
71
94
|
_json_out(
|
|
@@ -80,16 +103,7 @@ def cmd_standings(args: argparse.Namespace) -> None:
|
|
|
80
103
|
|
|
81
104
|
def cmd_team(args: argparse.Namespace) -> None:
|
|
82
105
|
"""Handle 'team' subcommand."""
|
|
83
|
-
fp = args
|
|
84
|
-
if fp is None:
|
|
85
|
-
if args.ligaid is None:
|
|
86
|
-
print(
|
|
87
|
-
"Error: pass --results PATH or --ligaid LIGAID for team results.",
|
|
88
|
-
file=sys.stderr,
|
|
89
|
-
)
|
|
90
|
-
sys.exit(1)
|
|
91
|
-
fp = str(Path("files") / str(args.ligaid) / "ergebnisse.html")
|
|
92
|
-
|
|
106
|
+
fp = _resolve_results(args, "team results")
|
|
93
107
|
results, league_info = get_team_results(args.name, fp)
|
|
94
108
|
if args.json:
|
|
95
109
|
data = [r.to_dict() for r in results]
|
|
@@ -112,16 +126,7 @@ def cmd_team(args: argparse.Namespace) -> None:
|
|
|
112
126
|
|
|
113
127
|
def cmd_ergebnisse(args: argparse.Namespace) -> None:
|
|
114
128
|
"""Handle 'ergebnisse' subcommand."""
|
|
115
|
-
fp = args
|
|
116
|
-
if fp is None:
|
|
117
|
-
if args.ligaid is None:
|
|
118
|
-
print(
|
|
119
|
-
"Error: pass --results PATH or --ligaid LIGAID for ergebnisse.",
|
|
120
|
-
file=sys.stderr,
|
|
121
|
-
)
|
|
122
|
-
sys.exit(1)
|
|
123
|
-
fp = str(Path("files") / str(args.ligaid) / "ergebnisse.html")
|
|
124
|
-
|
|
129
|
+
fp = _resolve_results(args, "ergebnisse")
|
|
125
130
|
games, league_info = read_games(fp)
|
|
126
131
|
filtered = filter_ergebnisse(games, team=args.team)
|
|
127
132
|
filtered.sort(key=lambda g: g.date)
|
|
@@ -147,16 +152,7 @@ def cmd_ergebnisse(args: argparse.Namespace) -> None:
|
|
|
147
152
|
|
|
148
153
|
def cmd_schedule(args: argparse.Namespace) -> None:
|
|
149
154
|
"""Handle 'schedule' subcommand."""
|
|
150
|
-
html = args
|
|
151
|
-
if html is None:
|
|
152
|
-
if args.ligaid is None:
|
|
153
|
-
print(
|
|
154
|
-
"Error: pass --schedule PATH or --ligaid LIGAID for schedule.",
|
|
155
|
-
file=sys.stderr,
|
|
156
|
-
)
|
|
157
|
-
sys.exit(1)
|
|
158
|
-
html = str(Path("files") / str(args.ligaid) / "spielplan.html")
|
|
159
|
-
|
|
155
|
+
html = _resolve_schedule(args, "schedule")
|
|
160
156
|
games, league_info = parse_schedule(html)
|
|
161
157
|
filtered = filter_schedule(
|
|
162
158
|
games, show_all=args.all, pending=args.pending, team=args.team
|
|
@@ -189,7 +185,7 @@ def cmd_predict(args: argparse.Namespace) -> None:
|
|
|
189
185
|
sp = sp or str(base / "spielplan.html")
|
|
190
186
|
|
|
191
187
|
# Check if season is finalized
|
|
192
|
-
finalized,
|
|
188
|
+
finalized, _pending_count = is_season_finalized(sp)
|
|
193
189
|
if finalized:
|
|
194
190
|
print(
|
|
195
191
|
"Error: Season is finalized (no pending games). "
|
|
@@ -227,32 +223,8 @@ def _blocks(value: int, scale: int) -> str:
|
|
|
227
223
|
return "█" * max(0, value // scale)
|
|
228
224
|
|
|
229
225
|
|
|
230
|
-
def
|
|
231
|
-
"""
|
|
232
|
-
|
|
233
|
-
fp = args.results
|
|
234
|
-
if fp is None:
|
|
235
|
-
if args.ligaid is None:
|
|
236
|
-
print(
|
|
237
|
-
"Error: pass --results PATH or --ligaid LIGAID for top.",
|
|
238
|
-
file=sys.stderr,
|
|
239
|
-
)
|
|
240
|
-
sys.exit(1)
|
|
241
|
-
fp = str(Path("files") / str(args.ligaid) / "ergebnisse.html")
|
|
242
|
-
|
|
243
|
-
standings, league_info = calculate_standings(fp)
|
|
244
|
-
top = standings[: args.n]
|
|
245
|
-
|
|
246
|
-
if args.json:
|
|
247
|
-
_json_out(
|
|
248
|
-
{
|
|
249
|
-
**_liga_meta(league_info, args.ligaid),
|
|
250
|
-
"top": [s.to_dict() for s in top],
|
|
251
|
-
}
|
|
252
|
-
)
|
|
253
|
-
return
|
|
254
|
-
|
|
255
|
-
tw = max(4, max(len(s.name) for s in top))
|
|
226
|
+
def _print_top_table(top: list[Standing], tw: int) -> None:
|
|
227
|
+
"""Print the ranked top-teams table."""
|
|
256
228
|
print("\n" + "=" * 70)
|
|
257
229
|
print(" Top Teams — current table")
|
|
258
230
|
print("=" * 70)
|
|
@@ -262,22 +234,44 @@ def cmd_top(args: argparse.Namespace) -> None:
|
|
|
262
234
|
print("-" * len(hdr))
|
|
263
235
|
for i, s in enumerate(top, 1):
|
|
264
236
|
st = s.stats
|
|
265
|
-
|
|
266
|
-
diff_str = f"+{diff}" if diff > 0 else str(diff)
|
|
237
|
+
diff_str = f"+{st.diff}" if st.diff > 0 else str(st.diff)
|
|
267
238
|
print(f"{i:>2} {s.name:<{tw}} {st.gp:>2} {st.pts:>3} {diff_str:>5}")
|
|
268
239
|
|
|
240
|
+
|
|
241
|
+
def _print_points_chart(top: list[Standing], tw: int) -> None:
|
|
242
|
+
"""Print the ASCII points bar chart for the top teams."""
|
|
269
243
|
pts_vals = [s.stats.pts for s in top]
|
|
270
244
|
max_pts = max(pts_vals) if pts_vals else 0
|
|
245
|
+
# Keep chart readable: 1 block per 1–2 points depending on scale.
|
|
271
246
|
scale = 1 if max_pts <= 20 else max(1, max_pts // 20)
|
|
272
247
|
print("\nStandings Points (scaled)")
|
|
273
248
|
print("─────────────────────────")
|
|
274
249
|
for s in top:
|
|
275
|
-
|
|
276
|
-
pts = st.pts
|
|
250
|
+
pts = s.stats.pts
|
|
277
251
|
print(f"{s.name:<{tw}} {_blocks(pts, scale):<22} {pts}")
|
|
278
252
|
print(f"(scale: 1 block ≈ {scale} pts)")
|
|
279
253
|
|
|
280
254
|
|
|
255
|
+
def cmd_top(args: argparse.Namespace) -> None:
|
|
256
|
+
"""Handle 'top' subcommand."""
|
|
257
|
+
fp = _resolve_results(args, "top")
|
|
258
|
+
standings, league_info = calculate_standings(fp)
|
|
259
|
+
top = standings[: args.n]
|
|
260
|
+
|
|
261
|
+
if args.json:
|
|
262
|
+
_json_out(
|
|
263
|
+
{
|
|
264
|
+
**_liga_meta(league_info, args.ligaid),
|
|
265
|
+
"top": [s.to_dict() for s in top],
|
|
266
|
+
}
|
|
267
|
+
)
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
tw = max(4, max(len(s.name) for s in top))
|
|
271
|
+
_print_top_table(top, tw)
|
|
272
|
+
_print_points_chart(top, tw)
|
|
273
|
+
|
|
274
|
+
|
|
281
275
|
_BASE = "https://www.basketball-bund.net/public"
|
|
282
276
|
_RESULTS_URL = (
|
|
283
277
|
f"{_BASE}/ergebnisse.jsp?print=1"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Shared models and utilities for DBB (Deutscher Basketball Bund) analysis.
|
|
2
2
|
|
|
3
3
|
Provides common types, HTML parsing, and date helpers used across modules.
|
|
4
|
-
Target: DBB Version ≤11.50.
|
|
4
|
+
Target: DBB Version ≤11.50.1-2f7f327 (legacy JSP platform).
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import re
|
|
@@ -10,8 +10,11 @@ from dataclasses import dataclass
|
|
|
10
10
|
from datetime import datetime
|
|
11
11
|
from html.parser import HTMLParser
|
|
12
12
|
from typing import Final
|
|
13
|
+
from zoneinfo import ZoneInfo
|
|
13
14
|
|
|
14
15
|
DATE_FMT: Final[str] = "%d.%m.%Y %H:%M"
|
|
16
|
+
# DBB schedule times are local German wall-clock times.
|
|
17
|
+
LOCAL_TZ: Final[ZoneInfo] = ZoneInfo("Europe/Berlin")
|
|
15
18
|
_LEAGUE_FALLBACK: Final[str] = "Basketball League"
|
|
16
19
|
|
|
17
20
|
# Regex for league name in DBB HTML titles like:
|
|
@@ -30,7 +33,7 @@ class LeagueInfo:
|
|
|
30
33
|
name: str
|
|
31
34
|
number: int | None = None
|
|
32
35
|
|
|
33
|
-
def to_dict(self) -> dict:
|
|
36
|
+
def to_dict(self) -> dict[str, object]:
|
|
34
37
|
"""Serializable dict."""
|
|
35
38
|
return {"liga_name": self.name, "liga_number": self.number}
|
|
36
39
|
|
|
@@ -46,26 +49,28 @@ class Game:
|
|
|
46
49
|
away_score: int
|
|
47
50
|
|
|
48
51
|
|
|
49
|
-
def parse_date(date_str: str) -> datetime | None:
|
|
52
|
+
def parse_date(date_str: str | None) -> datetime | None:
|
|
50
53
|
"""Parse date string in format 'DD.MM.YYYY HH:MM'.
|
|
51
54
|
|
|
52
55
|
Args:
|
|
53
|
-
date_str: Date string to parse.
|
|
56
|
+
date_str: Date string to parse, or None.
|
|
54
57
|
|
|
55
58
|
Returns:
|
|
56
59
|
Parsed datetime or None if invalid format.
|
|
57
60
|
"""
|
|
61
|
+
if date_str is None:
|
|
62
|
+
return None
|
|
58
63
|
try:
|
|
59
|
-
return datetime.strptime(date_str.strip(), DATE_FMT)
|
|
60
|
-
except
|
|
64
|
+
return datetime.strptime(date_str.strip(), DATE_FMT).replace(tzinfo=LOCAL_TZ)
|
|
65
|
+
except ValueError:
|
|
61
66
|
return None
|
|
62
67
|
|
|
63
68
|
|
|
64
|
-
def parse_score(score_str: str) -> tuple[int | None, int | None]:
|
|
69
|
+
def parse_score(score_str: str | None) -> tuple[int | None, int | None]:
|
|
65
70
|
"""Parse score string in format '79 : 75'.
|
|
66
71
|
|
|
67
72
|
Args:
|
|
68
|
-
score_str: Score string to parse.
|
|
73
|
+
score_str: Score string to parse, or None.
|
|
69
74
|
|
|
70
75
|
Returns:
|
|
71
76
|
Tuple of (home_score, away_score) or (None, None) if invalid.
|
|
@@ -101,12 +106,11 @@ def read_file_safe(filepath: str) -> str:
|
|
|
101
106
|
sys.exit(1)
|
|
102
107
|
|
|
103
108
|
|
|
104
|
-
class
|
|
105
|
-
"""
|
|
109
|
+
class _DBBRowParser(HTMLParser):
|
|
110
|
+
"""Shared <td>/<tr>/<strike> row-scanning state machine for DBB HTML tables."""
|
|
106
111
|
|
|
107
112
|
def __init__(self) -> None:
|
|
108
113
|
super().__init__()
|
|
109
|
-
self.games: list[Game] = []
|
|
110
114
|
self._in_td = False
|
|
111
115
|
self._td_depth = 0
|
|
112
116
|
self._cells: list[str] = []
|
|
@@ -137,6 +141,17 @@ class _HTMLResultsParser(HTMLParser):
|
|
|
137
141
|
if self._in_td and self._td_depth == 1:
|
|
138
142
|
self._current_cell += data
|
|
139
143
|
|
|
144
|
+
def _finalize_row(self) -> None:
|
|
145
|
+
raise NotImplementedError
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class _HTMLResultsParser(_DBBRowParser):
|
|
149
|
+
"""Parse basketball-bund.net HTML results tables."""
|
|
150
|
+
|
|
151
|
+
def __init__(self) -> None:
|
|
152
|
+
super().__init__()
|
|
153
|
+
self.games: list[Game] = []
|
|
154
|
+
|
|
140
155
|
def _finalize_row(self) -> None:
|
|
141
156
|
"""Parse completed row into Game if valid."""
|
|
142
157
|
if len(self._cells) < 6 or self._row_cancelled:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Game results viewer for DBB (Deutscher Basketball Bund) data.
|
|
2
2
|
|
|
3
3
|
Displays all completed game results for a league with optional team filter.
|
|
4
|
-
Target: DBB Version ≤11.50.
|
|
4
|
+
Target: DBB Version ≤11.50.1-2f7f327 (legacy JSP platform).
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from korb.core import Game, print_header
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Uses a multiplicative efficiency model: each team's offensive and
|
|
4
4
|
defensive ratings (relative to league average) are multiplied to
|
|
5
5
|
predict matchup scores. Recent games are weighted more heavily.
|
|
6
|
-
Target: DBB Version ≤11.50.
|
|
6
|
+
Target: DBB Version ≤11.50.1-2f7f327 (legacy JSP platform).
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
from collections import defaultdict
|
|
@@ -37,8 +37,7 @@ def _recency_weight(game: Game, ref_ts: float) -> float:
|
|
|
37
37
|
Weight between 0 and 1, decreasing with age.
|
|
38
38
|
"""
|
|
39
39
|
days_ago = (ref_ts - game.date.timestamp()) / 86400
|
|
40
|
-
|
|
41
|
-
days_ago = 0
|
|
40
|
+
days_ago = max(days_ago, 0)
|
|
42
41
|
return exp(-0.693 * days_ago / RECENCY_HALF_LIFE)
|
|
43
42
|
|
|
44
43
|
|
|
@@ -166,9 +165,9 @@ def _build_ratings(
|
|
|
166
165
|
Dict mapping team name to (off_rating, pts_allowed_rating).
|
|
167
166
|
"""
|
|
168
167
|
ratings: dict[str, tuple[float, float]] = {}
|
|
169
|
-
for name in w_gp:
|
|
170
|
-
avg_pf = w_pf[name] /
|
|
171
|
-
avg_pa = w_pa[name] /
|
|
168
|
+
for name, gp_weight in w_gp.items():
|
|
169
|
+
avg_pf = w_pf[name] / gp_weight
|
|
170
|
+
avg_pa = w_pa[name] / gp_weight
|
|
172
171
|
raw_off = avg_pf / league_avg
|
|
173
172
|
raw_def = avg_pa / league_avg
|
|
174
173
|
|
|
@@ -244,6 +243,69 @@ def predict_game(
|
|
|
244
243
|
return "away", ph, pa
|
|
245
244
|
|
|
246
245
|
|
|
246
|
+
def _copy_stats(teams_base: dict[str, TeamStats]) -> dict[str, TeamStats]:
|
|
247
|
+
"""Deep-copy current team stats into a fresh defaultdict."""
|
|
248
|
+
teams: dict[str, TeamStats] = defaultdict(TeamStats)
|
|
249
|
+
for name, st in teams_base.items():
|
|
250
|
+
t = teams[name]
|
|
251
|
+
t.gp, t.w, t.l, t.d, t.pf, t.pa = st.gp, st.w, st.l, st.d, st.pf, st.pa
|
|
252
|
+
return teams
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _pending_games(html_path: str, played: list[Game]) -> list[ScheduledGame]:
|
|
256
|
+
"""Return future scheduled games not already present in results."""
|
|
257
|
+
played_keys = {(g.home, g.away, g.date.strftime("%d.%m.%Y")) for g in played}
|
|
258
|
+
schedule, _ = parse_schedule(html_path)
|
|
259
|
+
pending = [
|
|
260
|
+
g
|
|
261
|
+
for g in filter_schedule(schedule, pending=True)
|
|
262
|
+
if (g.home, g.away, g.date.strftime("%d.%m.%Y")) not in played_keys
|
|
263
|
+
]
|
|
264
|
+
pending.sort(key=lambda g: g.date)
|
|
265
|
+
return pending
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _last_game_times(played: list[Game]) -> dict[str, float]:
|
|
269
|
+
"""Map each team to the timestamp of its most recent played game."""
|
|
270
|
+
last_game_ts: dict[str, float] = {}
|
|
271
|
+
for g in played:
|
|
272
|
+
ts = g.date.timestamp()
|
|
273
|
+
for name in (g.home, g.away):
|
|
274
|
+
if ts > last_game_ts.get(name, 0):
|
|
275
|
+
last_game_ts[name] = ts
|
|
276
|
+
return last_game_ts
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _fatigue(
|
|
280
|
+
name: str, gt: float, last_game_ts: dict[str, float], threshold_s: float
|
|
281
|
+
) -> float:
|
|
282
|
+
"""Return the fatigue multiplier for a team playing at time ``gt``."""
|
|
283
|
+
if name in last_game_ts and (gt - last_game_ts[name]) <= threshold_s:
|
|
284
|
+
return FATIGUE_PENALTY
|
|
285
|
+
return 1.0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _apply_result(
|
|
289
|
+
home: TeamStats, away: TeamStats, winner: str, hs: int, as_: int
|
|
290
|
+
) -> None:
|
|
291
|
+
"""Update both teams' stats with a predicted game result."""
|
|
292
|
+
home.gp += 1
|
|
293
|
+
away.gp += 1
|
|
294
|
+
home.pf += hs
|
|
295
|
+
home.pa += as_
|
|
296
|
+
away.pf += as_
|
|
297
|
+
away.pa += hs
|
|
298
|
+
if winner == "home":
|
|
299
|
+
home.w += 1
|
|
300
|
+
away.l += 1
|
|
301
|
+
elif winner == "away":
|
|
302
|
+
away.w += 1
|
|
303
|
+
home.l += 1
|
|
304
|
+
else:
|
|
305
|
+
home.d += 1
|
|
306
|
+
away.d += 1
|
|
307
|
+
|
|
308
|
+
|
|
247
309
|
def predict_standings(
|
|
248
310
|
results_path: str,
|
|
249
311
|
html_path: str,
|
|
@@ -263,56 +325,20 @@ def predict_standings(
|
|
|
263
325
|
(game, winner, home_score, away_score) tuples, and league_info
|
|
264
326
|
contains league metadata.
|
|
265
327
|
"""
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
teams: dict[str, TeamStats] = defaultdict(TeamStats)
|
|
328
|
+
teams_base, ratings, league_avg = calc_strength(results_path)
|
|
329
|
+
teams = _copy_stats(teams_base)
|
|
269
330
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
t.gp = st.gp
|
|
273
|
-
t.w = st.w
|
|
274
|
-
t.l = st.l
|
|
275
|
-
t.d = st.d
|
|
276
|
-
t.pf = st.pf
|
|
277
|
-
t.pa = st.pa
|
|
278
|
-
|
|
279
|
-
played, league_info = read_games(rp)
|
|
280
|
-
played_keys: set[tuple[str, str, str]] = {
|
|
281
|
-
(g.home, g.away, g.date.strftime("%d.%m.%Y")) for g in played
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
schedule, _ = parse_schedule(html_path)
|
|
285
|
-
pending = [
|
|
286
|
-
g
|
|
287
|
-
for g in filter_schedule(schedule, pending=True)
|
|
288
|
-
if (g.home, g.away, g.date.strftime("%d.%m.%Y")) not in played_keys
|
|
289
|
-
]
|
|
290
|
-
|
|
291
|
-
pending.sort(key=lambda g: g.date)
|
|
292
|
-
last_game_ts: dict[str, float] = {}
|
|
293
|
-
for g in played:
|
|
294
|
-
ts = g.date.timestamp()
|
|
295
|
-
for name in (g.home, g.away):
|
|
296
|
-
if ts > last_game_ts.get(name, 0):
|
|
297
|
-
last_game_ts[name] = ts
|
|
331
|
+
played, league_info = read_games(results_path)
|
|
332
|
+
pending = _pending_games(html_path, played)
|
|
298
333
|
|
|
334
|
+
last_game_ts = _last_game_times(played)
|
|
299
335
|
threshold_s = FATIGUE_THRESHOLD_H * 3600
|
|
300
336
|
|
|
301
337
|
preds: list[tuple[ScheduledGame, str, int, int]] = []
|
|
302
338
|
for game in pending:
|
|
303
339
|
gt = game.date.timestamp()
|
|
304
|
-
h_fat = (
|
|
305
|
-
|
|
306
|
-
if game.home in last_game_ts
|
|
307
|
-
and (gt - last_game_ts[game.home]) <= threshold_s
|
|
308
|
-
else 1.0
|
|
309
|
-
)
|
|
310
|
-
a_fat = (
|
|
311
|
-
FATIGUE_PENALTY
|
|
312
|
-
if game.away in last_game_ts
|
|
313
|
-
and (gt - last_game_ts[game.away]) <= threshold_s
|
|
314
|
-
else 1.0
|
|
315
|
-
)
|
|
340
|
+
h_fat = _fatigue(game.home, gt, last_game_ts, threshold_s)
|
|
341
|
+
a_fat = _fatigue(game.away, gt, last_game_ts, threshold_s)
|
|
316
342
|
|
|
317
343
|
winner, hs, as_ = predict_game(
|
|
318
344
|
game.home,
|
|
@@ -326,24 +352,7 @@ def predict_standings(
|
|
|
326
352
|
last_game_ts[game.home] = gt
|
|
327
353
|
last_game_ts[game.away] = gt
|
|
328
354
|
preds.append((game, winner, hs, as_))
|
|
329
|
-
|
|
330
|
-
h, a = teams[game.home], teams[game.away]
|
|
331
|
-
h.gp += 1
|
|
332
|
-
a.gp += 1
|
|
333
|
-
h.pf += hs
|
|
334
|
-
h.pa += as_
|
|
335
|
-
a.pf += as_
|
|
336
|
-
a.pa += hs
|
|
337
|
-
|
|
338
|
-
if winner == "home":
|
|
339
|
-
h.w += 1
|
|
340
|
-
a.l += 1
|
|
341
|
-
elif winner == "away":
|
|
342
|
-
a.w += 1
|
|
343
|
-
h.l += 1
|
|
344
|
-
else:
|
|
345
|
-
h.d += 1
|
|
346
|
-
a.d += 1
|
|
355
|
+
_apply_result(teams[game.home], teams[game.away], winner, hs, as_)
|
|
347
356
|
|
|
348
357
|
standings = [Standing(n, s) for n, s in teams.items()]
|
|
349
358
|
standings.sort(
|