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.
Files changed (34) hide show
  1. {korb-0.2.8 → korb-0.2.9}/.gitignore +5 -0
  2. {korb-0.2.8 → korb-0.2.9}/PKG-INFO +1 -1
  3. korb-0.2.9/korb/__init__.py +6 -0
  4. {korb-0.2.8 → korb-0.2.9}/korb/__main__.py +68 -74
  5. {korb-0.2.8 → korb-0.2.9}/korb/core.py +26 -11
  6. {korb-0.2.8 → korb-0.2.9}/korb/ergebnisse.py +1 -1
  7. {korb-0.2.8 → korb-0.2.9}/korb/predict.py +76 -67
  8. {korb-0.2.8 → korb-0.2.9}/korb/schedule.py +32 -62
  9. {korb-0.2.8 → korb-0.2.9}/korb/standings.py +3 -3
  10. {korb-0.2.8 → korb-0.2.9}/korb/team.py +102 -56
  11. {korb-0.2.8 → korb-0.2.9}/pyproject.toml +2 -1
  12. korb-0.2.9/tests/conftest.py +40 -0
  13. {korb-0.2.8 → korb-0.2.9}/tests/test_cli.py +4 -2
  14. {korb-0.2.8 → korb-0.2.9}/tests/test_core.py +3 -2
  15. {korb-0.2.8 → korb-0.2.9}/tests/test_predict.py +9 -9
  16. {korb-0.2.8 → korb-0.2.9}/tests/test_schedule.py +6 -5
  17. {korb-0.2.8 → korb-0.2.9}/tests/test_standings.py +3 -3
  18. {korb-0.2.8 → korb-0.2.9}/tests/test_team.py +21 -4
  19. korb-0.2.8/.coverage +0 -0
  20. korb-0.2.8/korb/__init__.py +0 -6
  21. korb-0.2.8/tests/conftest.py +0 -22
  22. {korb-0.2.8 → korb-0.2.9}/.github/workflows/release.yml +0 -0
  23. {korb-0.2.8 → korb-0.2.9}/.github/workflows/test.yml +0 -0
  24. {korb-0.2.8 → korb-0.2.9}/CHANGELOG.md +0 -0
  25. {korb-0.2.8 → korb-0.2.9}/LICENSE +0 -0
  26. {korb-0.2.8 → korb-0.2.9}/README.md +0 -0
  27. {korb-0.2.8 → korb-0.2.9}/files/.gitkeep +0 -0
  28. {korb-0.2.8 → korb-0.2.9}/korb/py.typed +0 -0
  29. {korb-0.2.8 → korb-0.2.9}/tests/__init__.py +0 -0
  30. {korb-0.2.8 → korb-0.2.9}/tests/fixtures/ergebnisse_minimal.html +0 -0
  31. {korb-0.2.8 → korb-0.2.9}/tests/fixtures/spielplan_finalized.html +0 -0
  32. {korb-0.2.8 → korb-0.2.9}/tests/fixtures/spielplan_minimal.html +0 -0
  33. {korb-0.2.8 → korb-0.2.9}/tests/test_download.py +0 -0
  34. {korb-0.2.8 → korb-0.2.9}/tests/test_ergebnisse.py +0 -0
@@ -6,6 +6,11 @@ __pycache__/
6
6
  dist/
7
7
  build/
8
8
 
9
+ # Coverage
10
+ .coverage
11
+ .coverage.*
12
+ htmlcov/
13
+
9
14
  # Lock file
10
15
  uv.lock
11
16
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: korb
3
- Version: 0.2.8
3
+ Version: 0.2.9
4
4
  Summary: A CLI toolkit for DBB basketball league analysis
5
5
  Project-URL: Homepage, https://github.com/malvavisc0/korb
6
6
  Project-URL: Repository, https://github.com/malvavisc0/korb
@@ -0,0 +1,6 @@
1
+ """Basketball league analysis package for DBB (Deutscher Basketball Bund).
2
+
3
+ Target: DBB Version ≤11.50.1-2f7f327 (legacy JSP platform).
4
+ """
5
+
6
+ __version__ = "0.2.9"
@@ -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.0-623b018 (legacy JSP platform).
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.results
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.results
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.results
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.schedule
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, pending_count = is_season_finalized(sp)
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 cmd_top(args: argparse.Namespace) -> None:
231
- """Handle 'top' subcommand."""
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
- diff = st.diff
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
- st = s.stats
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.0-623b018 (legacy JSP platform).
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 (ValueError, AttributeError):
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 _HTMLResultsParser(HTMLParser):
105
- """Parse basketball-bund.net HTML results tables."""
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.0-623b018 (legacy JSP platform).
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.0-623b018 (legacy JSP platform).
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
- if days_ago < 0:
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] / w_gp[name]
171
- avg_pa = w_pa[name] / w_gp[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
- rp = results_path
267
- teams_base, ratings, league_avg = calc_strength(rp)
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
- for name, st in teams_base.items():
271
- t = teams[name]
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
- FATIGUE_PENALTY
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(