-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathprecompute_metrics.py
More file actions
384 lines (362 loc) · 13.8 KB
/
Copy pathprecompute_metrics.py
File metadata and controls
384 lines (362 loc) · 13.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
#!/usr/bin/env python3
"""CLI helper to persist precomputed player metrics for one or more states."""
import argparse
from datetime import datetime, timezone
import os
from pathlib import Path
from typing import List, Optional
import pandas as pd
from smashcc.analysis import (
auto_select_series,
precompute_series_metrics,
precompute_state_metrics,
)
from smashcc.datastore import SQLiteStore
from smashcc.startgg_client import TournamentFilter
def _resolve_states(
*,
states: List[str],
include_all: bool,
videogame_id: int,
store_path: Path,
) -> List[str]:
"""Return the list of states that should be processed."""
if include_all:
store = SQLiteStore(store_path)
try:
discovered = store.list_states_with_data(videogame_id)
finally:
store.close()
return discovered
normalized = [s.strip().upper() for s in states if s.strip()]
return sorted(set(normalized))
def _derive_series_key(
*,
name_terms: List[str],
slug_terms: List[str],
override: Optional[str] = None,
) -> str:
"""Generate a deterministic series key from provided terms."""
if override and override.strip():
return override.strip()
for term in [*(slug_terms or []), *(name_terms or [])]:
cleaned = term.strip().lower().replace(" ", "-").replace("/", "-")
if cleaned:
while "--" in cleaned:
cleaned = cleaned.replace("--", "-")
return cleaned
return "custom-series"
def _suggest_top_n_for_state(
*,
state: str,
videogame_id: int,
months_back: int,
window_offset_months: int,
window_size_months: Optional[int],
store_path: Path,
all_time: bool = False,
small_default: int = 25,
medium_default: int = 75,
large_default: int = 125,
huge_default: int = 200,
) -> tuple[int, str]:
"""
Pick a top-N cap based on cached tournament volume for the window.
Returns (top_n, reason).
"""
filt = TournamentFilter(
state=state,
videogame_id=videogame_id,
months_back=0 if all_time else months_back,
window_offset=window_offset_months,
window_size=window_size_months,
start_ts_override=0 if all_time else None,
end_ts_override=int(datetime.now(timezone.utc).timestamp()) if all_time else None,
)
try:
store = SQLiteStore(store_path)
except Exception:
return small_default, "fallback (no store available)"
try:
window_start, window_end = filt.window_bounds()
tournaments = store.load_tournaments(
filt.state,
filt.videogame_id,
window_start,
window_end,
)
count = len(tournaments)
finally:
store.close()
state_upper = state.upper()
high_population_states = {"CA", "TX", "FL", "NJ", "NY"}
if state_upper in high_population_states:
return huge_default, f"population override ({state_upper})"
if count >= 200:
return huge_default, f"huge state: {count} cached tournaments"
if count >= 120:
return large_default, f"large state: {count} cached tournaments"
if count >= 70:
return medium_default, f"medium state: {count} cached tournaments"
return small_default, f"small state: {count} cached tournaments"
def main() -> None:
parser = argparse.ArgumentParser(description="Precompute and persist player metrics per state.")
parser.add_argument(
"--state",
dest="states",
action="append",
default=[],
help="State code to process (can be provided multiple times).",
)
parser.add_argument(
"--all-states",
action="store_true",
help="Process every state that already has tournaments in the local SQLite store.",
)
parser.add_argument(
"--videogame-id",
type=int,
default=1386,
help="start.gg videogame identifier (Ultimate=1386, Melee=1).",
)
parser.add_argument(
"--months-back",
type=int,
default=6,
help="Rolling tournament window (in months) used when computing metrics.",
)
parser.add_argument(
"--all-time",
action="store_true",
help="Compute an all-time window (ignores --months-back/--window-offset/--window-size).",
)
parser.add_argument(
"--window-offset",
type=int,
default=0,
help="Shift the window this many months into the past (0 = latest window).",
)
parser.add_argument(
"--window-size",
type=int,
help="Override the window length (months). Defaults to --months-back.",
)
parser.add_argument(
"--character",
default="Marth",
help="Character to emphasise when deriving metrics.",
)
parser.add_argument(
"--assume-target-main",
action="store_true",
help="Treat the target character as a player's main when no per-character sets exist.",
)
parser.add_argument(
"--large-event-threshold",
type=int,
default=32,
help="Entrant count that defines a 'large' event (affects derived metrics).",
)
parser.add_argument(
"--store-path",
default=None,
help="Optional override for the SQLite store path.",
)
parser.add_argument(
"--auto-series",
action="store_true",
help=(
"Automatically select series per state and precompute series-scoped metrics "
"(top N by total attendees; defaults dynamically to 200/125/75/25 based on cached tournament volume/state)."
),
)
parser.add_argument(
"--top-n-per-state",
type=int,
default=None,
help="How many series per state to include (largest by total attendees). Defaults dynamically by state size if omitted.",
)
parser.add_argument(
"--offline-only",
action="store_true",
help="Use cached tournaments/events only; fail if data is missing or stale and never hit start.gg.",
)
parser.add_argument(
"--output",
help=(
"Optional CSV path to write the stored precomputed state metrics. "
"If multiple states are processed, the state code is appended to the filename."
),
)
parser.add_argument(
"--tournament-contains",
dest="tournament_contains",
action="append",
help=(
"Only include tournaments whose name contains this substring (case-insensitive). "
"Repeatable; mirrors run_report.py."
),
)
parser.add_argument(
"--tournament-slug-contains",
dest="tournament_slug_contains",
action="append",
help=(
"Only include tournaments whose slug contains this substring (case-insensitive). "
"Repeatable; mirrors run_report.py."
),
)
parser.add_argument(
"--series-key",
help=(
"Optional override for the series key when using --tournament-contains/--tournament-slug-contains. "
"Defaults to the first provided term."
),
)
args = parser.parse_args()
if not args.states and not args.all_states:
parser.error("provide at least one --state or pass --all-states")
if args.all_time and (args.window_offset != 0 or args.window_size is not None):
parser.error("--all-time does not support --window-offset or --window-size")
if not os.getenv("STARTGG_API_TOKEN") and not args.offline_only:
parser.error("STARTGG_API_TOKEN is not set; export it before running.")
store_path = Path(args.store_path) if args.store_path else None
states = _resolve_states(
states=args.states,
include_all=args.all_states,
videogame_id=args.videogame_id,
store_path=store_path or Path(".cache") / "startgg" / "smash.db",
)
name_terms = [t.strip() for t in args.tournament_contains or [] if t and t.strip()]
slug_terms = [t.strip() for t in args.tournament_slug_contains or [] if t and t.strip()]
manual_series = bool(name_terms or slug_terms)
manual_series_key = (
_derive_series_key(
name_terms=name_terms,
slug_terms=slug_terms,
override=args.series_key,
)
if manual_series
else None
)
if not states:
print("No states found to process.")
return
effective_months_back = 0 if args.all_time else args.months_back
processed = 0
for state in states:
print(f"[+] Computing metrics for {state}...")
row_count = precompute_state_metrics(
state=state,
months_back=effective_months_back,
videogame_id=args.videogame_id,
target_character=args.character,
assume_target_main=args.assume_target_main,
store_path=store_path,
large_event_threshold=args.large_event_threshold,
window_offset_months=args.window_offset,
window_size_months=args.window_size,
all_time=args.all_time,
offline_only=args.offline_only,
)
print(f" Stored {row_count} players for {state}.")
if args.output:
output_path = Path(args.output)
if len(states) > 1:
suffix = output_path.suffix or ".csv"
stem = output_path.stem if output_path.suffix else output_path.name
output_path = output_path.with_name(f"{stem}_{state}{suffix}")
store = SQLiteStore(store_path)
try:
rows = store.load_player_metrics(
state=state,
videogame_id=args.videogame_id,
months_back=effective_months_back,
target_character=args.character,
all_time=args.all_time,
limit=None,
)
finally:
store.close()
df = pd.DataFrame(rows)
df.to_csv(output_path, index=False)
print(f" Wrote {len(df)} rows to {output_path}")
processed += 1
if manual_series:
print(
" Precomputing series for provided tournament filters..."
)
series_rows = precompute_series_metrics(
state=state,
series_key=manual_series_key or "custom-series",
series_name_term=name_terms[0] if name_terms else None,
series_slug_term=slug_terms[0] if slug_terms else None,
tournament_name_contains=name_terms or None,
tournament_slug_contains=slug_terms or None,
months_back=effective_months_back,
videogame_id=args.videogame_id,
target_character=args.character,
assume_target_main=args.assume_target_main,
store_path=store_path,
large_event_threshold=args.large_event_threshold,
window_offset_months=args.window_offset,
window_size_months=args.window_size,
all_time=args.all_time,
offline_only=args.offline_only,
)
print(f" Stored {series_rows} players for series '{manual_series_key}'.")
if args.auto_series:
resolved_top_n = args.top_n_per_state
if resolved_top_n is None:
suggested_top_n, reason = _suggest_top_n_for_state(
state=state,
videogame_id=args.videogame_id,
months_back=effective_months_back,
window_offset_months=args.window_offset,
window_size_months=args.window_size,
store_path=store_path or Path(".cache") / "startgg" / "smash.db",
all_time=args.all_time,
)
resolved_top_n = suggested_top_n
print(f" Auto-series top N set to {resolved_top_n} ({reason}).")
else:
print(f" Auto-series top N override: {resolved_top_n}.")
print(" Selecting series candidates...")
candidates = auto_select_series(
state=state,
months_back=effective_months_back,
videogame_id=args.videogame_id,
window_offset_months=args.window_offset,
window_size_months=args.window_size,
store_path=store_path,
top_n=resolved_top_n,
all_time=args.all_time,
offline_only=args.offline_only,
)
if not candidates:
print(" No series candidates found for this state/window.")
for cand in candidates:
print(
f" Precomputing series '{cand.series_key}' "
f"(events={cand.event_count}, max={cand.max_attendees}, total={cand.total_attendees})..."
)
series_rows = precompute_series_metrics(
state=state,
series_key=cand.series_key,
series_name_term=cand.name_term,
series_slug_term=cand.slug_term,
months_back=effective_months_back,
videogame_id=args.videogame_id,
target_character=args.character,
assume_target_main=args.assume_target_main,
store_path=store_path,
large_event_threshold=args.large_event_threshold,
window_offset_months=args.window_offset,
window_size_months=args.window_size,
all_time=args.all_time,
offline_only=args.offline_only,
)
print(f" Stored {series_rows} players for series '{cand.series_key}'.")
print(f"Finished processing {processed} state(s).")
if __name__ == "__main__":
main()