Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
pythonpath = .
2 changes: 1 addition & 1 deletion backend/src/api/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

event_type_query = Query(
None,
description="One of [`regional`, `district`, `district_cmp`, `champs_div`, or `einstein`].",
description="One of [`regional`, `district`, `district_cmp`, `champs_div`, `einstein`, or `offseason`].",
)

limit_query = Query(
Expand Down
52 changes: 49 additions & 3 deletions backend/src/data/router.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import re
import time

import requests
from fastapi import APIRouter, BackgroundTasks
from fastapi import APIRouter, BackgroundTasks, Response

from src.constants import BACKEND_URL, CURR_YEAR
from src.data.main import refresh_teams, update_curr_year, reset_all_years
from src.data.main import refresh_teams, reset_all_years, update_curr_year
from src.data.tba import check_year_partial as check_year_partial_tba
from src.db.read import get_etags as get_etags_db, get_events as get_events_db
from src.db.read import get_etags as get_etags_db
from src.db.read import get_events as get_events_db

data_router = APIRouter()
site_router = APIRouter()
Expand Down Expand Up @@ -61,3 +65,45 @@ async def update_curr_year_site_endpoint(background_tasks: BackgroundTasks):

background_tasks.add_task(update_curr_year_background)
return {"status": "backgrounded"}


# Read-triggered freshness ping.
#
# Event pages fire-and-forget GET /v3/site/ping/event/{key} while an event is
# live. The hot path below is pure in-process memory (no DB, no GCS, no TBA):
# during the cooldown or while a probe is in flight, a ping costs a regex, a
# float compare, and a 204. The data service runs a single gunicorn worker by
# design (structurally single-writer), so module globals are authoritative.
#
# A cold ping schedules a background self-HTTP to /v3/site/update_curr_year —
# the existing cheap probe (TBA etag pre-check, then a backgrounded partial
# cycle only if something actually changed). The 300s cooldown bounds TBA
# traffic to one probe per 5 minutes no matter how many viewers pile on.
PING_COOLDOWN_S = 300

_ping_last_probe: float = float("-inf")
_ping_inflight: bool = False


def _ping_probe():
global _ping_inflight
try:
# Bounded timeout so a hung probe can never wedge _ping_inflight (and
# thus disable the fast path) permanently: (connect, read) seconds.
requests.get(f"{BACKEND_URL}/v3/site/update_curr_year", timeout=(5, 30))
finally:
_ping_inflight = False


@site_router.get("/ping/event/{event_key}")
async def ping_event_endpoint(event_key: str, background_tasks: BackgroundTasks):
global _ping_last_probe, _ping_inflight
if not re.fullmatch(rf"{CURR_YEAR}[a-z0-9]+", event_key):
return Response(status_code=204)
now = time.monotonic()
if _ping_inflight or now - _ping_last_probe < PING_COOLDOWN_S:
return Response(status_code=204)
_ping_last_probe = now
_ping_inflight = True
background_tasks.add_task(_ping_probe)
return Response(status_code=202)
9 changes: 7 additions & 2 deletions backend/src/data/wins.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from src.constants import CURR_YEAR
from src.data.utils import objs_type
from src.db.models import Event, Match, Team, TeamEvent, TeamYear
from src.types.enums import MatchStatus, MatchWinner
from src.types.enums import EventType, MatchStatus, MatchWinner
from src.utils.utils import r


Expand All @@ -19,6 +19,7 @@ def winrate(wins: int, ties: int, count: int) -> float:

def process_year(objs: objs_type) -> objs_type:
year_num = objs[0].year
event_to_type = {e.key: e.type for e in objs[2].values()}

ty_record: Dict[int, TRecord] = defaultdict(lambda: (0, 0, 0, 0))
te_record: Dict[Tuple[int, str], TRecord] = defaultdict(lambda: (0, 0, 0, 0))
Expand All @@ -31,7 +32,11 @@ def process_year(objs: objs_type) -> objs_type:
status = m_obj.status
winner = m_obj.winner

if status != MatchStatus.COMPLETED or winner is None:
if (
event_to_type[event] == EventType.OFFSEASON
or status != MatchStatus.COMPLETED
or winner is None
):
continue

for alliance in ["red", "blue"]:
Expand Down
3 changes: 2 additions & 1 deletion backend/src/db/functions/noteworthy_matches.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from src.db.main import Session
from src.db.models.event import EventORM
from src.db.models.match import Match, MatchORM
from src.types.enums import MatchStatus
from src.types.enums import EventType, MatchStatus


def get_noteworthy_matches(
Expand All @@ -29,6 +29,7 @@ def callback(session: SessionType):
(MatchORM.year == year)
& (MatchORM.status == MatchStatus.COMPLETED)
& (MatchORM.event == EventORM.key)
& (EventORM.type != EventType.OFFSEASON)
)

if country is not None:
Expand Down
7 changes: 4 additions & 3 deletions backend/src/models/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from src.db.models import Event, Match, TeamEvent, TeamYear, Year
from src.models.types import AlliancePred, Attribution, MatchPred
from src.tba.constants import PLACEHOLDER_TEAMS
from src.types.enums import MatchStatus
from src.types.enums import EventType, MatchStatus


class Model:
Expand Down Expand Up @@ -74,7 +74,8 @@ def process_match(

attributions = self.attribute_match(match, red_pred, blue_pred)

# Don't update if 1) placeholder match, 2) elim dq, 3) all fouls
# Don't update if 1) offseason, 2) placeholder match, 3) elim dq, 4) all fouls
offseason_event = event.type == EventType.OFFSEASON
teams = set(match.get_red() + match.get_blue())
placeholder_match = len(set(PLACEHOLDER_TEAMS).intersection(teams)) > 0
elim_dq = match.elim and (
Expand All @@ -87,7 +88,7 @@ def process_match(
and match.red_no_foul == 0
and (match.red_foul or 0) > 0
)
skip_update = placeholder_match or elim_dq or all_fouls
skip_update = offseason_event or placeholder_match or elim_dq or all_fouls

epas: Dict[str, Any] = {}
for team, attr in attributions.items():
Expand Down
29 changes: 28 additions & 1 deletion backend/src/tba/read_tba.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
EVENT_BLACKLIST,
EVENT_TYPE_OVERRIDES,
MATCH_BLACKLIST,
PLACEHOLDER_TEAMS,
)
from src.tba.main import get_tba
from src.tba.types import EventDict, MatchDict, TeamDict
Expand Down Expand Up @@ -95,7 +96,29 @@ def get_events(

event_type_int = int(event["event_type"])
if event_type_int in (99, 100) and key not in EVENT_TYPE_OVERRIDES:
continue
if event_type_int == 100:
continue # preseason
# offseason events are ingested for 2025+ with quality filters
if year < 2025:
continue
try:
event_teams = get_event_teams(key, etag=None, cache=cache)[0]
# remove events with less than 6 teams
if len(event_teams) < 6:
continue
if len(set(PLACEHOLDER_TEAMS).intersection(set(event_teams))) > 0:
continue
matches = get_tba(f"event/{key}/matches", etag=None, cache=cache)[0]
end_date = datetime.strptime(event["end_date"], "%Y-%m-%d")
if len(matches) == 0 and (datetime.now() - end_date).days >= 1: # type: ignore
continue
for match in matches: # type: ignore
all_teams = match["alliances"]["red"]["team_keys"]
all_teams += match["alliances"]["blue"]["team_keys"]
all_teams = [int(x[3:]) for x in all_teams] # asserts no B teams
except Exception:
# remove events with B teams
continue

event_type_dict: Dict[int, EventType] = defaultdict(lambda: EventType.INVALID)
event_type_dict[0] = EventType.REGIONAL
Expand All @@ -107,6 +130,7 @@ def get_events(
event_type_dict[5] = EventType.DISTRICT_CMP
# rename festival of championships to einsteins
event_type_dict[6] = EventType.EINSTEIN
event_type_dict[99] = EventType.OFFSEASON

event_type = event_type_dict[event_type_int]
if key in EVENT_TYPE_OVERRIDES:
Expand All @@ -122,6 +146,9 @@ def get_events(
if event_type.is_champs():
event["week"] = 8

if event_type == EventType.OFFSEASON:
event["week"] = 9

# filter out incomplete events
if "week" not in event or event["week"] is None:
continue
Expand Down
73 changes: 73 additions & 0 deletions backend/tests/test_offseason_epa_freeze.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from src.db.models import Event, Match, TeamEvent, TeamYear, Year
from src.models.template import Model
from src.models.types import AlliancePred, Attribution
from src.types.enums import CompLevel, EventType, MatchStatus


class RecordingModel(Model):
def __init__(self):
super().__init__()
self.updated = []

def predict_match(self, match, event):
return 0.5, AlliancePred(10.0, None), AlliancePred(10.0, None)

def attribute_match(self, match, red_pred, blue_pred):
return {t: Attribution() for t in match.get_red() + match.get_blue()}

def update_team(self, team, attrib, match):
self.updated.append(team)


def mk_match(event_key, week):
return Match(
key=f"{event_key}_qm1",
year=2026,
event=event_key,
week=week,
elim=False,
comp_level=CompLevel.QUAL,
set_number=1,
match_number=1,
time=0,
status=MatchStatus.COMPLETED,
red_1=1,
red_2=2,
red_3=3,
blue_1=4,
blue_2=5,
blue_3=6,
red_dq="",
red_surrogate="",
blue_dq="",
blue_surrogate="",
red_score=20,
blue_score=10,
red_no_foul=20,
blue_no_foul=10,
)


def run_model(event_type, week):
model = RecordingModel()
model.start_season(Year(year=2026), {}, {})
event = Event(key="2026x", year=2026, name="X", type=event_type, week=week)
match = mk_match("2026x", week)
teams = [1, 2, 3, 4, 5, 6]
team_events = {t: TeamEvent(team=t, year=2026, event="2026x") for t in teams}
team_years = {t: TeamYear(team=t, year=2026) for t in teams}
model.process_match(match, event, team_events, team_years)
return model, match


def test_offseason_match_skips_epa_update():
model, match = run_model(EventType.OFFSEASON, 9)
assert model.updated == []
# predictions and post-match records are still produced
assert match.pre_epas is not None
assert match.epas is not None


def test_regular_match_updates_epa():
model, _ = run_model(EventType.REGIONAL, 1)
assert sorted(model.updated) == [1, 2, 3, 4, 5, 6]
Loading