diff --git a/.gitignore b/.gitignore index 6e52715..5ce19fd 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ data/*.db-wal node_modules/ +# Kept on the owner's machine only. +tools/download_ncaaf_logos.py + .claude/ # `playbook/` is private by defaul. diff --git a/database/columns.py b/database/columns.py new file mode 100644 index 0000000..d19c6dd --- /dev/null +++ b/database/columns.py @@ -0,0 +1,102 @@ +"""The columns of each stat category, declared once for models, ingestion and the API. + +Each `DataColumn` ties the Excel header in the preprocessed workbook to the model +attribute, the API payload key, and how the value is read. This one declaration +feeds the ingestion's required-column check and row building and the API +serializer, so a category's columns are added in a single place; a test checks +that the models carry exactly these attributes. The NFL tables use it now and the +college tables reuse it unchanged. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class DataColumn: + excel_header: str # Column name in the preprocessed xlsx sheet. + attr: str # Model attribute and ORM column name. + api_key: str # Key in the /api payload. + kind: str # "int", "float", or "complement" (stored as 100 - the Excel value). + + +# Identity columns read from every sheet. "Team" serializes as "team" and reads +# into team_code; "Player Id" reads into player_id. +IDENTITY_EXCEL_HEADERS = ("Team", "Player Id", "Player", "Abbr Name") + + +PASS_RUSH_DATA_COLUMNS = ( + DataColumn("Games", "games", "games", "int"), + DataColumn("PR Opp", "pr_opp", "PR Opp", "int"), + DataColumn("TPS PR Opp", "tps_pr_opp", "TPS PR Opp", "int"), + DataColumn( + "Pass Rush Percent", "pass_cov_percent", "Pass Cov Percent", "complement" + ), + DataColumn( + "TPS Pass Rush Percent", + "tps_pass_cov_percent", + "TPS Pass Cov Percent", + "complement", + ), + DataColumn("Win Rate", "win_rate", "Win Rate", "float"), + DataColumn("TPS Win Rate", "tps_win_rate", "TPS Win Rate", "float"), + DataColumn("Pressure Rate", "pressure_rate", "Pressure Rate", "float"), + DataColumn("TPS Pressure Rate", "tps_pressure_rate", "TPS Pressure Rate", "float"), + DataColumn("Havoc Rate", "havoc_rate", "Havoc Rate", "float"), + DataColumn("TPS Havoc Rate", "tps_havoc_rate", "TPS Havoc Rate", "float"), + DataColumn("PRP", "prp", "PRP", "float"), + DataColumn("TPS PRP", "tps_prp", "TPS PRP", "float"), +) + + +PASS_BLOCK_DATA_COLUMNS = ( + DataColumn("Games", "games", "games", "int"), + DataColumn("Non Spike PB Snaps", "non_spike_pb_snaps", "Non Spike PB Snaps", "int"), + DataColumn( + "TPS Non Spike PB Snaps", + "tps_non_spike_pb_snaps", + "TPS Non Spike PB Snaps", + "int", + ), + DataColumn( + "Allowed Pressure %", "allowed_pressure_pct", "Allowed Pressure %", "float" + ), + DataColumn( + "TPS Allowed Pressure %", + "tps_allowed_pressure_pct", + "TPS Allowed Pressure %", + "float", + ), + DataColumn("Allowed Havoc %", "allowed_havoc_pct", "Allowed Havoc %", "float"), + DataColumn( + "TPS Allowed Havoc %", "tps_allowed_havoc_pct", "TPS Allowed Havoc %", "float" + ), + DataColumn("PBE", "pbe", "PBE", "float"), + DataColumn("TPS PBE", "tps_pbe", "TPS PBE", "float"), +) + + +def required_excel_headers(data_columns: tuple[DataColumn, ...]) -> tuple[str, ...]: + """The headers a sheet must have: the identity columns plus every data column.""" + return IDENTITY_EXCEL_HEADERS + tuple( + column.excel_header for column in data_columns + ) + + +def row_to_payload(row, data_columns: tuple[DataColumn, ...]) -> dict: + """The /api payload for one ORM row: the common identity keys plus each data key.""" + payload = { + "id": row.id, + "season": row.season, + "position": row.position, + "team": row.team_code, + "player_id": row.player_id, + "player": row.player, + "abbr_name": row.abbr_name, + } + + for column in data_columns: + payload[column.api_key] = getattr(row, column.attr) + + return payload diff --git a/database/db_ingestion.py b/database/db_ingestion.py index f5576ce..ad0dfff 100644 --- a/database/db_ingestion.py +++ b/database/db_ingestion.py @@ -26,6 +26,7 @@ from __future__ import annotations import argparse import os +from dataclasses import dataclass import re import sys from datetime import date @@ -34,9 +35,14 @@ from sqlalchemy import delete from sqlalchemy.orm import Session from config import DYNAMIC_THRESHOLDS +from database.columns import ( + PASS_BLOCK_DATA_COLUMNS, + PASS_RUSH_DATA_COLUMNS, + required_excel_headers, +) from database.db_models import Base, NFLPassBlockStat, NFLPassRushStat, NFLTeam -from main import engine, SessionLocal -from teams_reference import TEAMS +from database.session import engine, SessionLocal +from teams.nfl import TEAMS FRONT_7_POSITIONS = ("DI", "ED", "LB") OL_POSITIONS = ("T", "G", "C") @@ -46,43 +52,6 @@ SEASON_CATEGORY_DIRS = ("front_7_pass_rush", "ol_pass_block") -# Columns each category's sheets must have; ingestion fails loudly without them. -PASS_RUSH_COLUMNS = ( - "Team", - "Player Id", - "Player", - "Abbr Name", - "Games", - "PR Opp", - "TPS PR Opp", - "Pass Rush Percent", - "TPS Pass Rush Percent", - "Win Rate", - "TPS Win Rate", - "Pressure Rate", - "TPS Pressure Rate", - "Havoc Rate", - "TPS Havoc Rate", - "PRP", - "TPS PRP", -) - -PASS_BLOCK_COLUMNS = ( - "Team", - "Player Id", - "Player", - "Abbr Name", - "Games", - "Non Spike PB Snaps", - "TPS Non Spike PB Snaps", - "Allowed Pressure %", - "TPS Allowed Pressure %", - "Allowed Havoc %", - "TPS Allowed Havoc %", - "PBE", - "TPS PBE", -) - def _find_project_root() -> Path: """Walk up from this file until we find a marker that identifies project root.""" @@ -183,128 +152,122 @@ def upsert_teams(sess: Session) -> None: sess.commit() -def ingest_pass_rush(sess: Session, data_dir: Path, seasons: list[int]) -> int: - total = 0 - for season in seasons: - path = data_dir / NFL_SUBDIR / "front_7_pass_rush" / f"{season}.xlsx" +def _read_value(kind: str, value): + """Read one Excel cell the way its column's kind says.""" + if kind == "int": + return _safe_int(value) - if not path.exists(): - print(f"Skip pass rush {season}: {path} not found") - continue + if kind == "float": + return _safe_float(value) - sheets = pd.read_excel(path, sheet_name=None) - for position in FRONT_7_POSITIONS: - position_df = sheets.get(position) - if position_df is None or position_df.empty: - continue + if kind == "complement": + return _complement(value) - _require_columns(position_df, PASS_RUSH_COLUMNS, path, position) - position_df.dropna(subset=["PR Opp"], inplace=True) + raise ValueError(f"unknown column kind: {kind!r}") - # Wipe slice before re-insertion. - sess.execute( - delete(NFLPassRushStat).where( - NFLPassRushStat.season == season, - NFLPassRushStat.position == position, - ) - ) - rows = [] - for _, row in position_df.iterrows(): - rows.append( - NFLPassRushStat( - season=season, - position=position, - team_code=row["Team"], - player_id=_player_id(row, path, position), - player=row["Player"], - abbr_name=row["Abbr Name"], - games=_safe_int(row.get("Games")), - pr_opp=_safe_int(row.get("PR Opp")), - tps_pr_opp=_safe_int(row.get("TPS PR Opp")), - pass_cov_percent=_complement(row.get("Pass Rush Percent")), - tps_pass_cov_percent=_complement( - row.get("TPS Pass Rush Percent") - ), - win_rate=_safe_float(row.get("Win Rate")), - tps_win_rate=_safe_float(row.get("TPS Win Rate")), - pressure_rate=_safe_float(row.get("Pressure Rate")), - tps_pressure_rate=_safe_float(row.get("TPS Pressure Rate")), - havoc_rate=_safe_float(row.get("Havoc Rate")), - tps_havoc_rate=_safe_float(row.get("TPS Havoc Rate")), - prp=_safe_float(row.get("PRP")), - tps_prp=_safe_float(row.get("TPS PRP")), - ) - ) +@dataclass(frozen=True) +class _Category: + """Everything the ingestion needs to load one category into its table.""" - sess.add_all(rows) - total += len(rows) - print(f" pass_rush {season} {position}: {len(rows)} rows") + model: type + folder: str + positions: tuple[str, ...] + data_columns: tuple + snap_header: str # The column a row must have a value in (dropna subset). + skip_label: str # Wording of the "file not found" line. + row_label: str # Wording of the per-position "N rows" line. - sess.commit() - return total +_PASS_RUSH = _Category( + model=NFLPassRushStat, + folder="front_7_pass_rush", + positions=FRONT_7_POSITIONS, + data_columns=PASS_RUSH_DATA_COLUMNS, + snap_header="PR Opp", + skip_label="pass rush", + row_label="pass_rush", +) -def ingest_pass_block(sess: Session, data_dir: Path, seasons: list[int]) -> int: +_PASS_BLOCK = _Category( + model=NFLPassBlockStat, + folder="ol_pass_block", + positions=OL_POSITIONS, + data_columns=PASS_BLOCK_DATA_COLUMNS, + snap_header="Non Spike PB Snaps", + skip_label="pass block", + row_label="pass_block", +) + + +def ingest_category( + sess: Session, data_dir: Path, seasons: list[int], category: _Category +) -> int: + """Load one category's seasons into its table, replacing each slice first. + + The body is shared by the two categories (and later the two leagues); the + column reads come from the declaration in `database/columns.py`. + """ + required = required_excel_headers(category.data_columns) total = 0 + for season in seasons: - path = data_dir / NFL_SUBDIR / "ol_pass_block" / f"{season}.xlsx" + path = data_dir / NFL_SUBDIR / category.folder / f"{season}.xlsx" + if not path.exists(): - print(f"Skip pass block {season}: {path} not found") + print(f"Skip {category.skip_label} {season}: {path} not found") continue sheets = pd.read_excel(path, sheet_name=None) - for position in OL_POSITIONS: + for position in category.positions: position_df = sheets.get(position) if position_df is None or position_df.empty: continue - _require_columns(position_df, PASS_BLOCK_COLUMNS, path, position) - position_df.dropna(subset=["Non Spike PB Snaps"], inplace=True) + _require_columns(position_df, required, path, position) + position_df.dropna(subset=[category.snap_header], inplace=True) + # Wipe slice before re-insertion. sess.execute( - delete(NFLPassBlockStat).where( - NFLPassBlockStat.season == season, - NFLPassBlockStat.position == position, + delete(category.model).where( + category.model.season == season, + category.model.position == position, ) ) rows = [] for _, row in position_df.iterrows(): - rows.append( - NFLPassBlockStat( - season=season, - position=position, - team_code=row["Team"], - player_id=_player_id(row, path, position), - player=row["Player"], - abbr_name=row["Abbr Name"], - games=_safe_int(row.get("Games")), - non_spike_pb_snaps=_safe_int(row.get("Non Spike PB Snaps")), - tps_non_spike_pb_snaps=_safe_int( - row.get("TPS Non Spike PB Snaps") - ), - allowed_pressure_pct=_safe_float(row.get("Allowed Pressure %")), - tps_allowed_pressure_pct=_safe_float( - row.get("TPS Allowed Pressure %") - ), - allowed_havoc_pct=_safe_float(row.get("Allowed Havoc %")), - tps_allowed_havoc_pct=_safe_float( - row.get("TPS Allowed Havoc %") - ), - pbe=_safe_float(row.get("PBE")), - tps_pbe=_safe_float(row.get("TPS PBE")), + values = { + "season": season, + "position": position, + "team_code": row["Team"], + "player_id": _player_id(row, path, position), + "player": row["Player"], + "abbr_name": row["Abbr Name"], + } + for column in category.data_columns: + values[column.attr] = _read_value( + column.kind, row.get(column.excel_header) ) - ) + + rows.append(category.model(**values)) sess.add_all(rows) total += len(rows) - print(f" pass_block {season} {position}: {len(rows)} rows") + print(f" {category.row_label} {season} {position}: {len(rows)} rows") sess.commit() return total +def ingest_pass_rush(sess: Session, data_dir: Path, seasons: list[int]) -> int: + return ingest_category(sess, data_dir, seasons, _PASS_RUSH) + + +def ingest_pass_block(sess: Session, data_dir: Path, seasons: list[int]) -> int: + return ingest_category(sess, data_dir, seasons, _PASS_BLOCK) + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) diff --git a/database/db_models.py b/database/db_models.py index 60c9387..dd4a986 100644 --- a/database/db_models.py +++ b/database/db_models.py @@ -9,9 +9,16 @@ in the same database as `ncaaf_...`. The stat tables are keyed by PFF's `player_id` (one row per player per season and position), which is also the id the college files use. + +The identity and metric columns live in mixins (`StatIdentityMixin`, +`PassRushMetricsMixin`, `PassBlockMetricsMixin`) so the college tables can reuse +them in a later PR; only the team foreign key and the table-level names differ +per league. The metric attributes match `database/columns.py` (checked by a test), +which also drives the API serializers below. """ from __future__ import annotations + from sqlalchemy import ( Column, Float, @@ -23,35 +30,38 @@ ) from sqlalchemy.orm import DeclarativeBase, relationship +from database.columns import ( + PASS_BLOCK_DATA_COLUMNS, + PASS_RUSH_DATA_COLUMNS, + row_to_payload, +) + class Base(DeclarativeBase): pass -class NFLTeam(Base): - __tablename__ = "nfl_teams" +class StatIdentityMixin: + """Columns every stat row carries, whatever the league or category. - code = Column(String(4), primary_key=True) - full_name = Column(String, nullable=False) - primary_color = Column(String(7)) # Hex like "#0085CA". - - pass_rush_stats = relationship("NFLPassRushStat", back_populates="team") - pass_block_stats = relationship("NFLPassBlockStat", back_populates="team") - - -class NFLPassRushStat(Base): - __tablename__ = "nfl_pass_rush_stats" + The team foreign key is left to each table, because it points at that + league's team table (`nfl_teams` here, `ncaaf_teams` later). + """ id = Column(Integer, primary_key=True, autoincrement=True) season = Column(Integer, nullable=False) - position = Column(String(4), nullable=False) # DI / ED. - team_code = Column(String(4), ForeignKey("nfl_teams.code"), nullable=False) + position = Column(String(4), nullable=False) # DI / ED, or T / G / C. player_id = Column(Integer, nullable=False) # PFF player id. player = Column(String, nullable=False) abbr_name = Column(String, nullable=False) games = Column(Integer) + + +class PassRushMetricsMixin: + """The front-7 pass-rush metric columns (see `PASS_RUSH_DATA_COLUMNS`).""" + pr_opp = Column(Integer) # PR Opp. tps_pr_opp = Column(Integer) # TPS PR Opp. @@ -70,6 +80,39 @@ class NFLPassRushStat(Base): prp = Column(Float) # PRP. tps_prp = Column(Float) # TPS PRP. + +class PassBlockMetricsMixin: + """The offensive-line pass-block metric columns (see `PASS_BLOCK_DATA_COLUMNS`).""" + + non_spike_pb_snaps = Column(Integer) # Non Spike PB Snaps. + tps_non_spike_pb_snaps = Column(Integer) # TPS Non Spike PB Snaps. + + allowed_pressure_pct = Column(Float) # Allowed Pressure %. + tps_allowed_pressure_pct = Column(Float) # TPS Allowed Pressure %. + + allowed_havoc_pct = Column(Float) # Allowed Havoc %. + tps_allowed_havoc_pct = Column(Float) # TPS Allowed Havoc %. + + pbe = Column(Float) # PBE. + tps_pbe = Column(Float) # TPS PBE. + + +class NFLTeam(Base): + __tablename__ = "nfl_teams" + + code = Column(String(4), primary_key=True) + full_name = Column(String, nullable=False) + primary_color = Column(String(7)) # Hex like "#0085CA". + + pass_rush_stats = relationship("NFLPassRushStat", back_populates="team") + pass_block_stats = relationship("NFLPassBlockStat", back_populates="team") + + +class NFLPassRushStat(StatIdentityMixin, PassRushMetricsMixin, Base): + __tablename__ = "nfl_pass_rush_stats" + + team_code = Column(String(4), ForeignKey("nfl_teams.code"), nullable=False) + team = relationship("NFLTeam", back_populates="pass_rush_stats") __table_args__ = ( @@ -84,30 +127,10 @@ class NFLPassRushStat(Base): ) -class NFLPassBlockStat(Base): +class NFLPassBlockStat(StatIdentityMixin, PassBlockMetricsMixin, Base): __tablename__ = "nfl_pass_block_stats" - id = Column(Integer, primary_key=True, autoincrement=True) - season = Column(Integer, nullable=False) - - position = Column(String(4), nullable=False) # T / G / C. team_code = Column(String(4), ForeignKey("nfl_teams.code"), nullable=False) - player_id = Column(Integer, nullable=False) # PFF player id. - player = Column(String, nullable=False) - abbr_name = Column(String, nullable=False) - - games = Column(Integer) - non_spike_pb_snaps = Column(Integer) # Non Spike PB Snaps. - tps_non_spike_pb_snaps = Column(Integer) # TPS Non Spike PB Snaps. - - allowed_pressure_pct = Column(Float) # Allowed Pressure %. - tps_allowed_pressure_pct = Column(Float) # TPS Allowed Pressure %. - - allowed_havoc_pct = Column(Float) # Allowed Havoc %. - tps_allowed_havoc_pct = Column(Float) # TPS Allowed Havoc %. - - pbe = Column(Float) # PBE. - tps_pbe = Column(Float) # TPS PBE. team = relationship("NFLTeam", back_populates="pass_block_stats") @@ -125,50 +148,13 @@ class NFLPassBlockStat(Base): # Serialization helpers used by main.py to keep API payload shape # identical to what frontend already expects (space-and-mixed-case keys -# preserved so no client-side renaming is needed). +# preserved so no client-side renaming is needed). Both read the one +# column declaration in database/columns.py. def pass_rush_row_to_dict(row: NFLPassRushStat) -> dict: - return { - "id": row.id, - "season": row.season, - "position": row.position, - "team": row.team_code, - "player_id": row.player_id, - "player": row.player, - "abbr_name": row.abbr_name, - "games": row.games, - "PR Opp": row.pr_opp, - "TPS PR Opp": row.tps_pr_opp, - "Pass Cov Percent": row.pass_cov_percent, - "TPS Pass Cov Percent": row.tps_pass_cov_percent, - "Win Rate": row.win_rate, - "TPS Win Rate": row.tps_win_rate, - "Pressure Rate": row.pressure_rate, - "TPS Pressure Rate": row.tps_pressure_rate, - "Havoc Rate": row.havoc_rate, - "TPS Havoc Rate": row.tps_havoc_rate, - "PRP": row.prp, - "TPS PRP": row.tps_prp, - } + return row_to_payload(row, PASS_RUSH_DATA_COLUMNS) def pass_block_row_to_dict(row: NFLPassBlockStat) -> dict: - return { - "id": row.id, - "season": row.season, - "position": row.position, - "team": row.team_code, - "player_id": row.player_id, - "player": row.player, - "abbr_name": row.abbr_name, - "games": row.games, - "Non Spike PB Snaps": row.non_spike_pb_snaps, - "TPS Non Spike PB Snaps": row.tps_non_spike_pb_snaps, - "Allowed Pressure %": row.allowed_pressure_pct, - "TPS Allowed Pressure %": row.tps_allowed_pressure_pct, - "Allowed Havoc %": row.allowed_havoc_pct, - "TPS Allowed Havoc %": row.tps_allowed_havoc_pct, - "PBE": row.pbe, - "TPS PBE": row.tps_pbe, - } + return row_to_payload(row, PASS_BLOCK_DATA_COLUMNS) diff --git a/database/session.py b/database/session.py new file mode 100644 index 0000000..20a80d1 --- /dev/null +++ b/database/session.py @@ -0,0 +1,37 @@ +"""Database engine and session factory, shared by the API and the ingestion. + +Split out of `main.py` so `database/db_ingestion.py` can reach the engine +without importing `main` (which would pull in the whole FastAPI app). Both the +SQLite (local dev) and PostgreSQL (Railway) branches behave exactly as before. +""" + +from __future__ import annotations + +import os + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + + +def _resolve_database_url() -> str: + url = os.environ.get("DATABASE_URL", "sqlite:///./data/lines_shines.db") + if url.startswith("postgres://"): + url = "postgresql://" + url[len("postgres://") :] + + # Force SQLAlchemy to use psycopg3, which is installed as `psycopg[binary]`. + if url.startswith("postgresql://"): + url = "postgresql+psycopg://" + url[len("postgresql://") :] + + return url + + +DATABASE_URL = _resolve_database_url() + +_engine_kwargs = {"pool_pre_ping": True} +if DATABASE_URL.startswith("sqlite"): + # SQLite locally: relax the same-thread guard so uvicorn's threadpool + # can serve requests off the main import thread. + _engine_kwargs["connect_args"] = {"check_same_thread": False} + +engine = create_engine(DATABASE_URL, **_engine_kwargs) +SessionLocal = sessionmaker(engine, expire_on_commit=False) diff --git a/frontend/config.js b/frontend/config.js index b9e71e6..f74cbbe 100644 --- a/frontend/config.js +++ b/frontend/config.js @@ -10,7 +10,7 @@ // static league structure, not PFF-derived data), so it lives here purely // to lay the Teams checklist out like NFL.com: AFC in the left column, NFC // in the right, each divided into East/North/South/West groups. Codes match -// the LinesShines/PFF spellings in teams_reference.py (BLT, CLV, HST, LA, +// the LinesShines/PFF spellings in teams/nfl.py (BLT, CLV, HST, LA, // LV, ...), not the NFL's own abbreviations. export const CONFERENCES = { AFC: { diff --git a/frontend/data.js b/frontend/data.js index 90ce222..768b80c 100644 --- a/frontend/data.js +++ b/frontend/data.js @@ -11,7 +11,7 @@ */ import { els } from "./dom.js"; -export const LOGO_PATH = (team) => `logos/${team}.png`; +export const LOGO_PATH = (team) => `logos/nfl/${team}.png`; export let metadata = null; // /api/metadata payload export const sliceCache = new Map(); // key = `${category}:${season}:${position}` → records[] @@ -47,7 +47,7 @@ export let playerPoolCategory = null; export let appliedFilters = null; // Decoded Image objects, keyed by team code, filled once at page load so -// filter changes never wait on the browser to re-resolve/decode /logos/*.png +// filter changes never wait on the browser to re-resolve/decode /logos/nfl/*.png // again — layout.images and the scout card both just point at these. export const logoCache = {}; diff --git a/frontend/images/team_logos/ncaaf/102.png b/frontend/images/team_logos/ncaaf/102.png new file mode 100644 index 0000000..3740ece Binary files /dev/null and b/frontend/images/team_logos/ncaaf/102.png differ diff --git a/frontend/images/team_logos/ncaaf/103.png b/frontend/images/team_logos/ncaaf/103.png new file mode 100644 index 0000000..c6a28d3 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/103.png differ diff --git a/frontend/images/team_logos/ncaaf/109.png b/frontend/images/team_logos/ncaaf/109.png new file mode 100644 index 0000000..928f237 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/109.png differ diff --git a/frontend/images/team_logos/ncaaf/110.png b/frontend/images/team_logos/ncaaf/110.png new file mode 100644 index 0000000..b79d6ae Binary files /dev/null and b/frontend/images/team_logos/ncaaf/110.png differ diff --git a/frontend/images/team_logos/ncaaf/111.png b/frontend/images/team_logos/ncaaf/111.png new file mode 100644 index 0000000..5d91b75 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/111.png differ diff --git a/frontend/images/team_logos/ncaaf/115.png b/frontend/images/team_logos/ncaaf/115.png new file mode 100644 index 0000000..ffc8380 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/115.png differ diff --git a/frontend/images/team_logos/ncaaf/117.png b/frontend/images/team_logos/ncaaf/117.png new file mode 100644 index 0000000..fdaf247 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/117.png differ diff --git a/frontend/images/team_logos/ncaaf/118.png b/frontend/images/team_logos/ncaaf/118.png new file mode 100644 index 0000000..3836ab9 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/118.png differ diff --git a/frontend/images/team_logos/ncaaf/120.png b/frontend/images/team_logos/ncaaf/120.png new file mode 100644 index 0000000..350bc81 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/120.png differ diff --git a/frontend/images/team_logos/ncaaf/121.png b/frontend/images/team_logos/ncaaf/121.png new file mode 100644 index 0000000..f747c06 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/121.png differ diff --git a/frontend/images/team_logos/ncaaf/122.png b/frontend/images/team_logos/ncaaf/122.png new file mode 100644 index 0000000..af45491 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/122.png differ diff --git a/frontend/images/team_logos/ncaaf/126.png b/frontend/images/team_logos/ncaaf/126.png new file mode 100644 index 0000000..cd521bc Binary files /dev/null and b/frontend/images/team_logos/ncaaf/126.png differ diff --git a/frontend/images/team_logos/ncaaf/128.png b/frontend/images/team_logos/ncaaf/128.png new file mode 100644 index 0000000..8669cba Binary files /dev/null and b/frontend/images/team_logos/ncaaf/128.png differ diff --git a/frontend/images/team_logos/ncaaf/130.png b/frontend/images/team_logos/ncaaf/130.png new file mode 100644 index 0000000..a929117 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/130.png differ diff --git a/frontend/images/team_logos/ncaaf/134.png b/frontend/images/team_logos/ncaaf/134.png new file mode 100644 index 0000000..3c0dea3 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/134.png differ diff --git a/frontend/images/team_logos/ncaaf/138.png b/frontend/images/team_logos/ncaaf/138.png new file mode 100644 index 0000000..0180d42 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/138.png differ diff --git a/frontend/images/team_logos/ncaaf/140.png b/frontend/images/team_logos/ncaaf/140.png new file mode 100644 index 0000000..ee7cf77 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/140.png differ diff --git a/frontend/images/team_logos/ncaaf/143.png b/frontend/images/team_logos/ncaaf/143.png new file mode 100644 index 0000000..5b20fe3 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/143.png differ diff --git a/frontend/images/team_logos/ncaaf/144.png b/frontend/images/team_logos/ncaaf/144.png new file mode 100644 index 0000000..da47f29 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/144.png differ diff --git a/frontend/images/team_logos/ncaaf/154.png b/frontend/images/team_logos/ncaaf/154.png new file mode 100644 index 0000000..7763104 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/154.png differ diff --git a/frontend/images/team_logos/ncaaf/160.png b/frontend/images/team_logos/ncaaf/160.png new file mode 100644 index 0000000..f8637f7 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/160.png differ diff --git a/frontend/images/team_logos/ncaaf/163.png b/frontend/images/team_logos/ncaaf/163.png new file mode 100644 index 0000000..89da0a8 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/163.png differ diff --git a/frontend/images/team_logos/ncaaf/167.png b/frontend/images/team_logos/ncaaf/167.png new file mode 100644 index 0000000..15b0912 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/167.png differ diff --git a/frontend/images/team_logos/ncaaf/169.png b/frontend/images/team_logos/ncaaf/169.png new file mode 100644 index 0000000..86d6675 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/169.png differ diff --git a/frontend/images/team_logos/ncaaf/173.png b/frontend/images/team_logos/ncaaf/173.png new file mode 100644 index 0000000..56de921 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/173.png differ diff --git a/frontend/images/team_logos/ncaaf/176.png b/frontend/images/team_logos/ncaaf/176.png new file mode 100644 index 0000000..9df88fd Binary files /dev/null and b/frontend/images/team_logos/ncaaf/176.png differ diff --git a/frontend/images/team_logos/ncaaf/182.png b/frontend/images/team_logos/ncaaf/182.png new file mode 100644 index 0000000..961d27f Binary files /dev/null and b/frontend/images/team_logos/ncaaf/182.png differ diff --git a/frontend/images/team_logos/ncaaf/187.png b/frontend/images/team_logos/ncaaf/187.png new file mode 100644 index 0000000..6089ae6 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/187.png differ diff --git a/frontend/images/team_logos/ncaaf/190.png b/frontend/images/team_logos/ncaaf/190.png new file mode 100644 index 0000000..9f06a3f Binary files /dev/null and b/frontend/images/team_logos/ncaaf/190.png differ diff --git a/frontend/images/team_logos/ncaaf/192.png b/frontend/images/team_logos/ncaaf/192.png new file mode 100644 index 0000000..ea15843 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/192.png differ diff --git a/frontend/images/team_logos/ncaaf/193.png b/frontend/images/team_logos/ncaaf/193.png new file mode 100644 index 0000000..0bd4f61 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/193.png differ diff --git a/frontend/images/team_logos/ncaaf/198.png b/frontend/images/team_logos/ncaaf/198.png new file mode 100644 index 0000000..6054a31 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/198.png differ diff --git a/frontend/images/team_logos/ncaaf/199.png b/frontend/images/team_logos/ncaaf/199.png new file mode 100644 index 0000000..859a6ef Binary files /dev/null and b/frontend/images/team_logos/ncaaf/199.png differ diff --git a/frontend/images/team_logos/ncaaf/201.png b/frontend/images/team_logos/ncaaf/201.png new file mode 100644 index 0000000..da4e400 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/201.png differ diff --git a/frontend/images/team_logos/ncaaf/202.png b/frontend/images/team_logos/ncaaf/202.png new file mode 100644 index 0000000..192f23e Binary files /dev/null and b/frontend/images/team_logos/ncaaf/202.png differ diff --git a/frontend/images/team_logos/ncaaf/210.png b/frontend/images/team_logos/ncaaf/210.png new file mode 100644 index 0000000..105b636 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/210.png differ diff --git a/frontend/images/team_logos/ncaaf/211.png b/frontend/images/team_logos/ncaaf/211.png new file mode 100644 index 0000000..4b6094a Binary files /dev/null and b/frontend/images/team_logos/ncaaf/211.png differ diff --git a/frontend/images/team_logos/ncaaf/215.png b/frontend/images/team_logos/ncaaf/215.png new file mode 100644 index 0000000..0a383fe Binary files /dev/null and b/frontend/images/team_logos/ncaaf/215.png differ diff --git a/frontend/images/team_logos/ncaaf/216.png b/frontend/images/team_logos/ncaaf/216.png new file mode 100644 index 0000000..5cd5401 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/216.png differ diff --git a/frontend/images/team_logos/ncaaf/220.png b/frontend/images/team_logos/ncaaf/220.png new file mode 100644 index 0000000..fb4e941 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/220.png differ diff --git a/frontend/images/team_logos/ncaaf/221.png b/frontend/images/team_logos/ncaaf/221.png new file mode 100644 index 0000000..1e17d14 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/221.png differ diff --git a/frontend/images/team_logos/ncaaf/222.png b/frontend/images/team_logos/ncaaf/222.png new file mode 100644 index 0000000..3929caf Binary files /dev/null and b/frontend/images/team_logos/ncaaf/222.png differ diff --git a/frontend/images/team_logos/ncaaf/223.png b/frontend/images/team_logos/ncaaf/223.png new file mode 100644 index 0000000..0f60283 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/223.png differ diff --git a/frontend/images/team_logos/ncaaf/225.png b/frontend/images/team_logos/ncaaf/225.png new file mode 100644 index 0000000..c7b2a2e Binary files /dev/null and b/frontend/images/team_logos/ncaaf/225.png differ diff --git a/frontend/images/team_logos/ncaaf/226.png b/frontend/images/team_logos/ncaaf/226.png new file mode 100644 index 0000000..7f8bb14 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/226.png differ diff --git a/frontend/images/team_logos/ncaaf/227.png b/frontend/images/team_logos/ncaaf/227.png new file mode 100644 index 0000000..0f2d943 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/227.png differ diff --git a/frontend/images/team_logos/ncaaf/229.png b/frontend/images/team_logos/ncaaf/229.png new file mode 100644 index 0000000..9c7946f Binary files /dev/null and b/frontend/images/team_logos/ncaaf/229.png differ diff --git a/frontend/images/team_logos/ncaaf/238.png b/frontend/images/team_logos/ncaaf/238.png new file mode 100644 index 0000000..f3c88f9 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/238.png differ diff --git a/frontend/images/team_logos/ncaaf/245.png b/frontend/images/team_logos/ncaaf/245.png new file mode 100644 index 0000000..f0871be Binary files /dev/null and b/frontend/images/team_logos/ncaaf/245.png differ diff --git a/frontend/images/team_logos/ncaaf/248.png b/frontend/images/team_logos/ncaaf/248.png new file mode 100644 index 0000000..5d3a70e Binary files /dev/null and b/frontend/images/team_logos/ncaaf/248.png differ diff --git a/frontend/images/team_logos/ncaaf/254.png b/frontend/images/team_logos/ncaaf/254.png new file mode 100644 index 0000000..9a4636e Binary files /dev/null and b/frontend/images/team_logos/ncaaf/254.png differ diff --git a/frontend/images/team_logos/ncaaf/256.png b/frontend/images/team_logos/ncaaf/256.png new file mode 100644 index 0000000..06dda2b Binary files /dev/null and b/frontend/images/team_logos/ncaaf/256.png differ diff --git a/frontend/images/team_logos/ncaaf/258.png b/frontend/images/team_logos/ncaaf/258.png new file mode 100644 index 0000000..443530e Binary files /dev/null and b/frontend/images/team_logos/ncaaf/258.png differ diff --git a/frontend/images/team_logos/ncaaf/259.png b/frontend/images/team_logos/ncaaf/259.png new file mode 100644 index 0000000..e4e41aa Binary files /dev/null and b/frontend/images/team_logos/ncaaf/259.png differ diff --git a/frontend/images/team_logos/ncaaf/260.png b/frontend/images/team_logos/ncaaf/260.png new file mode 100644 index 0000000..46e84eb Binary files /dev/null and b/frontend/images/team_logos/ncaaf/260.png differ diff --git a/frontend/images/team_logos/ncaaf/261.png b/frontend/images/team_logos/ncaaf/261.png new file mode 100644 index 0000000..8487798 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/261.png differ diff --git a/frontend/images/team_logos/ncaaf/262.png b/frontend/images/team_logos/ncaaf/262.png new file mode 100644 index 0000000..d1ebbd0 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/262.png differ diff --git a/frontend/images/team_logos/ncaaf/264.png b/frontend/images/team_logos/ncaaf/264.png new file mode 100644 index 0000000..d484aa3 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/264.png differ diff --git a/frontend/images/team_logos/ncaaf/265.png b/frontend/images/team_logos/ncaaf/265.png new file mode 100644 index 0000000..f955b20 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/265.png differ diff --git a/frontend/images/team_logos/ncaaf/266.png b/frontend/images/team_logos/ncaaf/266.png new file mode 100644 index 0000000..4e42ca3 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/266.png differ diff --git a/frontend/images/team_logos/ncaaf/268.png b/frontend/images/team_logos/ncaaf/268.png new file mode 100644 index 0000000..fda6f8b Binary files /dev/null and b/frontend/images/team_logos/ncaaf/268.png differ diff --git a/frontend/images/team_logos/ncaaf/273.png b/frontend/images/team_logos/ncaaf/273.png new file mode 100644 index 0000000..923d108 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/273.png differ diff --git a/frontend/images/team_logos/ncaaf/278.png b/frontend/images/team_logos/ncaaf/278.png new file mode 100644 index 0000000..fe6d990 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/278.png differ diff --git a/frontend/images/team_logos/ncaaf/279.png b/frontend/images/team_logos/ncaaf/279.png new file mode 100644 index 0000000..5ef0343 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/279.png differ diff --git a/frontend/images/team_logos/ncaaf/284.png b/frontend/images/team_logos/ncaaf/284.png new file mode 100644 index 0000000..11e2380 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/284.png differ diff --git a/frontend/images/team_logos/ncaaf/287.png b/frontend/images/team_logos/ncaaf/287.png new file mode 100644 index 0000000..8c9335d Binary files /dev/null and b/frontend/images/team_logos/ncaaf/287.png differ diff --git a/frontend/images/team_logos/ncaaf/289.png b/frontend/images/team_logos/ncaaf/289.png new file mode 100644 index 0000000..082a8ba Binary files /dev/null and b/frontend/images/team_logos/ncaaf/289.png differ diff --git a/frontend/images/team_logos/ncaaf/300.png b/frontend/images/team_logos/ncaaf/300.png new file mode 100644 index 0000000..0d932af Binary files /dev/null and b/frontend/images/team_logos/ncaaf/300.png differ diff --git a/frontend/images/team_logos/ncaaf/304.png b/frontend/images/team_logos/ncaaf/304.png new file mode 100644 index 0000000..2ce17ed Binary files /dev/null and b/frontend/images/team_logos/ncaaf/304.png differ diff --git a/frontend/images/team_logos/ncaaf/305.png b/frontend/images/team_logos/ncaaf/305.png new file mode 100644 index 0000000..2ae296a Binary files /dev/null and b/frontend/images/team_logos/ncaaf/305.png differ diff --git a/frontend/images/team_logos/ncaaf/307.png b/frontend/images/team_logos/ncaaf/307.png new file mode 100644 index 0000000..30b7511 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/307.png differ diff --git a/frontend/images/team_logos/ncaaf/311.png b/frontend/images/team_logos/ncaaf/311.png new file mode 100644 index 0000000..7df12ee Binary files /dev/null and b/frontend/images/team_logos/ncaaf/311.png differ diff --git a/frontend/images/team_logos/ncaaf/312.png b/frontend/images/team_logos/ncaaf/312.png new file mode 100644 index 0000000..3259a98 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/312.png differ diff --git a/frontend/images/team_logos/ncaaf/314.png b/frontend/images/team_logos/ncaaf/314.png new file mode 100644 index 0000000..a3ed112 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/314.png differ diff --git a/frontend/images/team_logos/ncaaf/315.png b/frontend/images/team_logos/ncaaf/315.png new file mode 100644 index 0000000..44eed75 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/315.png differ diff --git a/frontend/images/team_logos/ncaaf/316.png b/frontend/images/team_logos/ncaaf/316.png new file mode 100644 index 0000000..23674e0 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/316.png differ diff --git a/frontend/images/team_logos/ncaaf/323.png b/frontend/images/team_logos/ncaaf/323.png new file mode 100644 index 0000000..8f04459 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/323.png differ diff --git a/frontend/images/team_logos/ncaaf/324.png b/frontend/images/team_logos/ncaaf/324.png new file mode 100644 index 0000000..e6c7d45 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/324.png differ diff --git a/frontend/images/team_logos/ncaaf/326.png b/frontend/images/team_logos/ncaaf/326.png new file mode 100644 index 0000000..3e7966e Binary files /dev/null and b/frontend/images/team_logos/ncaaf/326.png differ diff --git a/frontend/images/team_logos/ncaaf/328.png b/frontend/images/team_logos/ncaaf/328.png new file mode 100644 index 0000000..1a57ca3 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/328.png differ diff --git a/frontend/images/team_logos/ncaaf/329.png b/frontend/images/team_logos/ncaaf/329.png new file mode 100644 index 0000000..1a30c8b Binary files /dev/null and b/frontend/images/team_logos/ncaaf/329.png differ diff --git a/frontend/images/team_logos/ncaaf/333.png b/frontend/images/team_logos/ncaaf/333.png new file mode 100644 index 0000000..bf1aec5 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/333.png differ diff --git a/frontend/images/team_logos/ncaaf/335.png b/frontend/images/team_logos/ncaaf/335.png new file mode 100644 index 0000000..8013aa0 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/335.png differ diff --git a/frontend/images/team_logos/ncaaf/337.png b/frontend/images/team_logos/ncaaf/337.png new file mode 100644 index 0000000..629c82e Binary files /dev/null and b/frontend/images/team_logos/ncaaf/337.png differ diff --git a/frontend/images/team_logos/ncaaf/339.png b/frontend/images/team_logos/ncaaf/339.png new file mode 100644 index 0000000..f4e5488 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/339.png differ diff --git a/frontend/images/team_logos/ncaaf/340.png b/frontend/images/team_logos/ncaaf/340.png new file mode 100644 index 0000000..8db2b84 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/340.png differ diff --git a/frontend/images/team_logos/ncaaf/341.png b/frontend/images/team_logos/ncaaf/341.png new file mode 100644 index 0000000..2d4a4ef Binary files /dev/null and b/frontend/images/team_logos/ncaaf/341.png differ diff --git a/frontend/images/team_logos/ncaaf/343.png b/frontend/images/team_logos/ncaaf/343.png new file mode 100644 index 0000000..05d7f45 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/343.png differ diff --git a/frontend/images/team_logos/ncaaf/347.png b/frontend/images/team_logos/ncaaf/347.png new file mode 100644 index 0000000..74145d4 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/347.png differ diff --git a/frontend/images/team_logos/ncaaf/349.png b/frontend/images/team_logos/ncaaf/349.png new file mode 100644 index 0000000..0827e59 Binary files /dev/null and b/frontend/images/team_logos/ncaaf/349.png differ diff --git a/frontend/images/team_logos/ARZ.png b/frontend/images/team_logos/nfl/ARZ.png similarity index 100% rename from frontend/images/team_logos/ARZ.png rename to frontend/images/team_logos/nfl/ARZ.png diff --git a/frontend/images/team_logos/ATL.png b/frontend/images/team_logos/nfl/ATL.png similarity index 100% rename from frontend/images/team_logos/ATL.png rename to frontend/images/team_logos/nfl/ATL.png diff --git a/frontend/images/team_logos/BLT.png b/frontend/images/team_logos/nfl/BLT.png similarity index 100% rename from frontend/images/team_logos/BLT.png rename to frontend/images/team_logos/nfl/BLT.png diff --git a/frontend/images/team_logos/BUF.png b/frontend/images/team_logos/nfl/BUF.png similarity index 100% rename from frontend/images/team_logos/BUF.png rename to frontend/images/team_logos/nfl/BUF.png diff --git a/frontend/images/team_logos/CAR.png b/frontend/images/team_logos/nfl/CAR.png similarity index 100% rename from frontend/images/team_logos/CAR.png rename to frontend/images/team_logos/nfl/CAR.png diff --git a/frontend/images/team_logos/CHI.png b/frontend/images/team_logos/nfl/CHI.png similarity index 100% rename from frontend/images/team_logos/CHI.png rename to frontend/images/team_logos/nfl/CHI.png diff --git a/frontend/images/team_logos/CIN.png b/frontend/images/team_logos/nfl/CIN.png similarity index 100% rename from frontend/images/team_logos/CIN.png rename to frontend/images/team_logos/nfl/CIN.png diff --git a/frontend/images/team_logos/CLV.png b/frontend/images/team_logos/nfl/CLV.png similarity index 100% rename from frontend/images/team_logos/CLV.png rename to frontend/images/team_logos/nfl/CLV.png diff --git a/frontend/images/team_logos/DAL.png b/frontend/images/team_logos/nfl/DAL.png similarity index 100% rename from frontend/images/team_logos/DAL.png rename to frontend/images/team_logos/nfl/DAL.png diff --git a/frontend/images/team_logos/DEN.png b/frontend/images/team_logos/nfl/DEN.png similarity index 100% rename from frontend/images/team_logos/DEN.png rename to frontend/images/team_logos/nfl/DEN.png diff --git a/frontend/images/team_logos/DET.png b/frontend/images/team_logos/nfl/DET.png similarity index 100% rename from frontend/images/team_logos/DET.png rename to frontend/images/team_logos/nfl/DET.png diff --git a/frontend/images/team_logos/GB.png b/frontend/images/team_logos/nfl/GB.png similarity index 100% rename from frontend/images/team_logos/GB.png rename to frontend/images/team_logos/nfl/GB.png diff --git a/frontend/images/team_logos/HST.png b/frontend/images/team_logos/nfl/HST.png similarity index 100% rename from frontend/images/team_logos/HST.png rename to frontend/images/team_logos/nfl/HST.png diff --git a/frontend/images/team_logos/IND.png b/frontend/images/team_logos/nfl/IND.png similarity index 100% rename from frontend/images/team_logos/IND.png rename to frontend/images/team_logos/nfl/IND.png diff --git a/frontend/images/team_logos/JAX.png b/frontend/images/team_logos/nfl/JAX.png similarity index 100% rename from frontend/images/team_logos/JAX.png rename to frontend/images/team_logos/nfl/JAX.png diff --git a/frontend/images/team_logos/KC.png b/frontend/images/team_logos/nfl/KC.png similarity index 100% rename from frontend/images/team_logos/KC.png rename to frontend/images/team_logos/nfl/KC.png diff --git a/frontend/images/team_logos/LA.png b/frontend/images/team_logos/nfl/LA.png similarity index 100% rename from frontend/images/team_logos/LA.png rename to frontend/images/team_logos/nfl/LA.png diff --git a/frontend/images/team_logos/LAC.png b/frontend/images/team_logos/nfl/LAC.png similarity index 100% rename from frontend/images/team_logos/LAC.png rename to frontend/images/team_logos/nfl/LAC.png diff --git a/frontend/images/team_logos/LV.png b/frontend/images/team_logos/nfl/LV.png similarity index 100% rename from frontend/images/team_logos/LV.png rename to frontend/images/team_logos/nfl/LV.png diff --git a/frontend/images/team_logos/MIA.png b/frontend/images/team_logos/nfl/MIA.png similarity index 100% rename from frontend/images/team_logos/MIA.png rename to frontend/images/team_logos/nfl/MIA.png diff --git a/frontend/images/team_logos/MIN.png b/frontend/images/team_logos/nfl/MIN.png similarity index 100% rename from frontend/images/team_logos/MIN.png rename to frontend/images/team_logos/nfl/MIN.png diff --git a/frontend/images/team_logos/NE.png b/frontend/images/team_logos/nfl/NE.png similarity index 100% rename from frontend/images/team_logos/NE.png rename to frontend/images/team_logos/nfl/NE.png diff --git a/frontend/images/team_logos/NO.png b/frontend/images/team_logos/nfl/NO.png similarity index 100% rename from frontend/images/team_logos/NO.png rename to frontend/images/team_logos/nfl/NO.png diff --git a/frontend/images/team_logos/NYG.png b/frontend/images/team_logos/nfl/NYG.png similarity index 100% rename from frontend/images/team_logos/NYG.png rename to frontend/images/team_logos/nfl/NYG.png diff --git a/frontend/images/team_logos/NYJ.png b/frontend/images/team_logos/nfl/NYJ.png similarity index 100% rename from frontend/images/team_logos/NYJ.png rename to frontend/images/team_logos/nfl/NYJ.png diff --git a/frontend/images/team_logos/PHI.png b/frontend/images/team_logos/nfl/PHI.png similarity index 100% rename from frontend/images/team_logos/PHI.png rename to frontend/images/team_logos/nfl/PHI.png diff --git a/frontend/images/team_logos/PIT.png b/frontend/images/team_logos/nfl/PIT.png similarity index 100% rename from frontend/images/team_logos/PIT.png rename to frontend/images/team_logos/nfl/PIT.png diff --git a/frontend/images/team_logos/SEA.png b/frontend/images/team_logos/nfl/SEA.png similarity index 100% rename from frontend/images/team_logos/SEA.png rename to frontend/images/team_logos/nfl/SEA.png diff --git a/frontend/images/team_logos/SF.png b/frontend/images/team_logos/nfl/SF.png similarity index 100% rename from frontend/images/team_logos/SF.png rename to frontend/images/team_logos/nfl/SF.png diff --git a/frontend/images/team_logos/TB.png b/frontend/images/team_logos/nfl/TB.png similarity index 100% rename from frontend/images/team_logos/TB.png rename to frontend/images/team_logos/nfl/TB.png diff --git a/frontend/images/team_logos/TEN.png b/frontend/images/team_logos/nfl/TEN.png similarity index 100% rename from frontend/images/team_logos/TEN.png rename to frontend/images/team_logos/nfl/TEN.png diff --git a/frontend/images/team_logos/WAS.png b/frontend/images/team_logos/nfl/WAS.png similarity index 100% rename from frontend/images/team_logos/WAS.png rename to frontend/images/team_logos/nfl/WAS.png diff --git a/main.py b/main.py index 90955cd..3114dbb 100644 --- a/main.py +++ b/main.py @@ -13,14 +13,14 @@ from __future__ import annotations import os +from dataclasses import dataclass from contextlib import asynccontextmanager from datetime import date from pathlib import Path -from fastapi import FastAPI, HTTPException, Query +from fastapi import APIRouter, FastAPI, HTTPException, Query from fastapi.responses import HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles -from sqlalchemy import create_engine, select -from sqlalchemy.orm import sessionmaker +from sqlalchemy import select from config import ( ALLOWED_HAVOC_RATE_NOTE, HAVOC_RATE_NOTE, @@ -38,6 +38,7 @@ pass_block_row_to_dict, pass_rush_row_to_dict, ) +from database.session import SessionLocal, engine # Railway sets this per-deploy; falls back to "dev" for local runs. Used to # cache-bust static assets referenced from index.html so iOS Safari (and @@ -46,30 +47,7 @@ COMMIT_SHA = os.environ.get("RAILWAY_GIT_COMMIT_SHA", "dev")[:7] -# ---- Database wiring. ------------------------------------------------------- - - -def _resolve_database_url() -> str: - url = os.environ.get("DATABASE_URL", "sqlite:///./data/lines_shines.db") - if url.startswith("postgres://"): - url = "postgresql://" + url[len("postgres://") :] - - # Force SQLAlchemy to use psycopg3, which is installed as `psycopg[binary]`. - if url.startswith("postgresql://"): - url = "postgresql+psycopg://" + url[len("postgresql://") :] - return url - - -DATABASE_URL = _resolve_database_url() - -_engine_kwargs = {"pool_pre_ping": True} -if DATABASE_URL.startswith("sqlite"): - # SQLite locally: relax the same-thread guard so uvicorn's threadpool - # can serve requests off the main import thread. - _engine_kwargs["connect_args"] = {"check_same_thread": False} - -engine = create_engine(DATABASE_URL, **_engine_kwargs) -SessionLocal = sessionmaker(engine, expire_on_commit=False) +# ---- Database wiring lives in database/session.py. -------------------------- # ---- Static metric/position descriptions (schema-level, not row-level). ----- @@ -209,161 +187,187 @@ async def lifespan(app: FastAPI): yield -app = FastAPI( - title="Game of Trenches API", - description="PFF-derived front-7 & O-line metrics for the LinesShines dashboard.", - lifespan=lifespan, -) +# ---- League API: one router factory, used by the NFL now and NCAAF later. --- -@app.get("/health") -def health() -> dict: - return {"status": "ok"} +@dataclass(frozen=True) +class _CategoryApi: + """One stat category's API wiring for a league.""" + key: str # Payload key, "category" field, and resolve_default_threshold key. + model: type + positions: dict + metrics: dict + threshold_field: str + order_attr: str # Model attribute to sort records by, descending. + row_to_dict: object -@app.get("/api/metadata") -def metadata() -> dict: - with SessionLocal() as sess: - pass_rush_seasons = sorted( - { - season - for (season,) in sess.execute(select(NFLPassRushStat.season).distinct()) - }, - reverse=True, - ) - pass_block_seasons = sorted( - { - season - for (season,) in sess.execute( - select(NFLPassBlockStat.season).distinct() +@dataclass(frozen=True) +class _LeagueApi: + """A league's team table and its two stat categories.""" + + team_model: type + categories: tuple + + +def build_api_router(league: _LeagueApi) -> APIRouter: + """Build /metadata, /pass_rush and /pass_block for one league. + + Mounted under a league prefix by the caller, so the NFL keeps today's + /api/... paths while NCAAF can be added later with its own prefix. + """ + router = APIRouter() + categories = {category.key: category for category in league.categories} + + @router.get("/metadata") + def metadata() -> dict: + with SessionLocal() as sess: + category_payload = {} + + for category in league.categories: + seasons = sorted( + { + season + for (season,) in sess.execute( + select(category.model.season).distinct() + ) + }, + reverse=True, ) - }, - reverse=True, - ) - teams = sess.execute(select(NFLTeam)).scalars().all() - - return { - # Same Calendar Year comparison resolve_default_threshold() uses to - # route a season to the static vs. dynamic threshold (see config.py) — - # exposed here so the frontend can classify a season as "ongoing" vs - # "historical" the same way, without duplicating a - # date.today().year call client-side (which would drift out of sync - # with whatever this process actually resolved thresholds against, - # e.g. across a stale page left open over a year boundary). - "current_year": date.today().year, - # Shared across every "TPS "-prefixed metric, not a per-metric - # formula — exposed once here rather than duplicated into each - # metric's own note. The frontend appends this to a TPS metric's own - # formula/note whenever the selected axis metric name starts with - # "TPS " (see main.js's axisFormulaContent()). - "tps_note": TPS_NOTE, - "pass_rush": { - "positions": PASS_RUSH_POSITIONS, - "metrics": PASS_RUSH_METRICS, - "threshold_field": "PR Opp", - "seasons": pass_rush_seasons, - "default_thresholds": { - season: resolve_default_threshold("pass_rush", season) - for season in pass_rush_seasons - }, - }, - "pass_block": { - "positions": PASS_BLOCK_POSITIONS, - "metrics": PASS_BLOCK_METRICS, - "threshold_field": "Non Spike PB Snaps", - "seasons": pass_block_seasons, - "default_thresholds": { - season: resolve_default_threshold("pass_block", season) - for season in pass_block_seasons + category_payload[category.key] = { + "positions": category.positions, + "metrics": category.metrics, + "threshold_field": category.threshold_field, + "seasons": seasons, + "default_thresholds": { + season: resolve_default_threshold(category.key, season) + for season in seasons + }, + } + + teams = sess.execute(select(league.team_model)).scalars().all() + + return { + # Same Calendar Year comparison resolve_default_threshold() uses to + # route a season to the static vs. dynamic threshold (see config.py) — + # exposed here so the frontend can classify a season as "ongoing" vs + # "historical" the same way, without duplicating a date.today().year + # call client-side (which would drift out of sync with whatever this + # process actually resolved thresholds against, e.g. across a stale + # page left open over a year boundary). + "current_year": date.today().year, + # Shared across every "TPS "-prefixed metric, not a per-metric + # formula — exposed once here rather than duplicated into each + # metric's own note. The frontend appends this to a TPS metric's own + # formula/note whenever the selected axis metric name starts with + # "TPS " (see main.js's axisFormulaContent()). + "tps_note": TPS_NOTE, + **category_payload, + "teams": { + team.code: { + "full_name": team.full_name, + "primary_color": team.primary_color, + } + for team in teams }, - }, - "teams": { - team.code: { - "full_name": team.full_name, - "primary_color": team.primary_color, - } - for team in teams - }, - } - - -@app.get("/api/pass_rush") -def pass_rush( - season: int = Query(..., ge=1980, le=2100), - position: str | None = Query(None, min_length=1, max_length=4), -) -> dict: - if position is not None and position not in PASS_RUSH_POSITIONS: - raise HTTPException( - 400, - f"invalid position: {position!r} — choose from {list(PASS_RUSH_POSITIONS)}", - ) - - filters = [NFLPassRushStat.season == season] - # Omitted position means "every supported position", not literally every - # row for the season — the table can carry positions PASS_RUSH_POSITIONS - # doesn't expose (e.g. LB), left over from a broader raw ingest. - filters.append( - NFLPassRushStat.position == position - if position is not None - else NFLPassRushStat.position.in_(PASS_RUSH_POSITIONS) - ) + } - with SessionLocal() as sess: - rows = ( - sess.execute( - select(NFLPassRushStat) - .where(*filters) - .order_by(NFLPassRushStat.pr_opp.desc()) + def category_records(category: _CategoryApi, season: int, position) -> dict: + if position is not None and position not in category.positions: + raise HTTPException( + 400, + f"invalid position: {position!r} — choose from {list(category.positions)}", ) - .scalars() - .all() - ) - return { - "category": "pass_rush", - "season": season, - "position": position, - "records": [pass_rush_row_to_dict(r) for r in rows], - } - - -@app.get("/api/pass_block") -def pass_block( - season: int = Query(..., ge=1980, le=2100), - position: str | None = Query(None, min_length=1, max_length=4), -) -> dict: - if position is not None and position not in PASS_BLOCK_POSITIONS: - raise HTTPException( - 400, - f"invalid position: {position!r} — choose from {list(PASS_BLOCK_POSITIONS)}", - ) + filters = [category.model.season == season] - filters = [NFLPassBlockStat.season == season] - filters.append( - NFLPassBlockStat.position == position - if position is not None - else NFLPassBlockStat.position.in_(PASS_BLOCK_POSITIONS) - ) + # Omitted position means "every supported position", not literally every + # row for the season — the table can carry positions the category does + # not expose (e.g. LB), left over from a broader raw ingest. - with SessionLocal() as sess: - rows = ( - sess.execute( - select(NFLPassBlockStat) - .where(*filters) - .order_by(NFLPassBlockStat.non_spike_pb_snaps.desc()) - ) - .scalars() - .all() + filters.append( + category.model.position == position + if position is not None + else category.model.position.in_(category.positions) ) - return { - "category": "pass_block", - "season": season, - "position": position, - "records": [pass_block_row_to_dict(r) for r in rows], - } + with SessionLocal() as sess: + rows = ( + sess.execute( + select(category.model) + .where(*filters) + .order_by(getattr(category.model, category.order_attr).desc()) + ) + .scalars() + .all() + ) + + return { + "category": category.key, + "season": season, + "position": position, + "records": [category.row_to_dict(row) for row in rows], + } + + @router.get("/pass_rush") + def pass_rush( + season: int = Query(..., ge=1980, le=2100), + position: str | None = Query(None, min_length=1, max_length=4), + ) -> dict: + return category_records(categories["pass_rush"], season, position) + + @router.get("/pass_block") + def pass_block( + season: int = Query(..., ge=1980, le=2100), + position: str | None = Query(None, min_length=1, max_length=4), + ) -> dict: + return category_records(categories["pass_block"], season, position) + + return router + + +NFL_API = _LeagueApi( + team_model=NFLTeam, + categories=( + _CategoryApi( + key="pass_rush", + model=NFLPassRushStat, + positions=PASS_RUSH_POSITIONS, + metrics=PASS_RUSH_METRICS, + threshold_field="PR Opp", + order_attr="pr_opp", + row_to_dict=pass_rush_row_to_dict, + ), + _CategoryApi( + key="pass_block", + model=NFLPassBlockStat, + positions=PASS_BLOCK_POSITIONS, + metrics=PASS_BLOCK_METRICS, + threshold_field="Non Spike PB Snaps", + order_attr="non_spike_pb_snaps", + row_to_dict=pass_block_row_to_dict, + ), + ), +) + + +app = FastAPI( + title="Game of Trenches API", + description="PFF-derived front-7 & O-line metrics for the LinesShines dashboard.", + lifespan=lifespan, +) + + +@app.get("/health") +def health() -> dict: + return {"status": "ok"} + + +# NFL stays at /api/metadata, /api/pass_rush, /api/pass_block (PR #19). PR #20 +# moves it under /api/nfl and adds /api/ncaaf with the same factory. +app.include_router(build_api_router(NFL_API), prefix="/api") # ---- Static frontend (mounted LAST so /api/* routes above win). ------------- diff --git a/playbook/DECISIONS.md b/playbook/DECISIONS.md index 123c055..67d059b 100644 --- a/playbook/DECISIONS.md +++ b/playbook/DECISIONS.md @@ -26,7 +26,7 @@ Detail for each item lives in `architecture/` (see `architecture/INDEX.md`); thi | Breakpoints and popup placement read `clientWidth`, never `innerWidth` | Real bug fixed earlier. | `architecture/UI-LAYOUT-AND-EXPORT.md` | | Chart Save Plot never passes `appendixNotes` to `compositeFooterCanvas()` | Chart notes are live Plotly annotations; passing them would draw them twice. Card exports do pass them. | `architecture/METRICS-AND-NOTES.md` | | Linemate Cards are not checked individually during pool-change invalidation | Their roster derives from the anchor plus applied filters. | `architecture/PINNED-INVALIDATION.md` | -| Team codes use PFF spellings (`ARZ`, `BLT`, `CLV`, `HST`) | Defined once in `teams_reference.py`; the frontend never hardcodes team data. | `architecture/BACKEND-AND-DEPLOY.md` | +| Team codes use PFF spellings (`ARZ`, `BLT`, `CLV`, `HST`) | Defined once in `teams/nfl.py`; the frontend never hardcodes team data. | `architecture/BACKEND-AND-DEPLOY.md` | | No Alembic; schema changes need a manual `ALTER TABLE` on Railway | Adding a column reaches local sqlite for free, not production. | `architecture/BACKEND-AND-DEPLOY.md` | | Rank ties share one rank and percentile (competition ranking) | A tiebreak would misrepresent equal performance. | `architecture/PERCENTILES.md` | | The chart panel uses `touch-action: manipulation`, not `auto` or `none` | Native pinch-zoom and pan are wanted, but `auto` would also turn on double-tap-zoom with its ~300 ms delay and break tap-to-open-a-card. Plotly's own zoom and pan stay off (`scrollZoom: false`, `dragmode: false`, `doubleClick: false`). | `architecture/UI-LAYOUT-AND-EXPORT.md` | @@ -120,3 +120,4 @@ Detail for each item lives in `architecture/` (see `architecture/INDEX.md`); thi - 2026-10-24: the NFL tables are renamed with the `nfl_` prefix and keyed by `(season, position, player_id)` (v1.14.0, live on Railway). The old `teams`, `pass_rush_stats` and `pass_block_stats` tables were backed up and dropped on 2026-10-25. - 2026-10-26: the preprocessing scripts take a league (`League` enum: `nfl`, `ncaaf`, one data folder each) and `preprocess.py` is run as `python -m preprocessing.preprocess --league nfl --seasons 2026`, with `--league` and `--seasons` both required and no default, so the wrong league is never processed by accident; several seasons go after one flag. An error in one CSV is printed and the remaining CSVs still run; the failures are listed at the end and the exit code is 1. A CSV missing a column raises one error naming the league, the season and the columns. The helpers the two category scripts share (paths, column check) live in `preprocess.py`. `database/session.py` and the shared column specification wait for the NCAAF release. - 2026-10-28: the Linemate Card has no roster cap and no "See more": the snap threshold is the only gate, and every qualifying linemate is on the card (and in the Line Summary). A reminder that a higher threshold keeps the card compact shows when the roster reaches 6 OL or 8 DL players (`LINEMATE_SIZE_REMINDER_AT`); it is not in the PNG. In Read, a roster wider than 760px scrolls sideways inside the card with a frozen label column and a "Please scroll right for more players." hint; the PNG export widens to show every column with no scrollbar. Linemate Cards stay outside the Pinned Players quota. Detail: `architecture/CARDS.md`. +- 2026-10-30: the NFL backend was refactored to be league-ready with no behaviour change (PR #19), so adding NCAAF in PR #20 is mostly configuration. The engine and `SessionLocal` moved to `database/session.py` (so `db_ingestion.py` no longer imports `main`). Each category's columns are declared once in `database/columns.py`, read by the ingestion's required-column check, its row building and the API serializers (`row_to_payload`). The stat models take their identity and metric columns from mixins (`StatIdentityMixin`, `PassRushMetricsMixin`, `PassBlockMetricsMixin`). One `ingest_category()` replaces the two near-identical ingestion functions (`ingest_pass_rush` / `ingest_pass_block` stay as thin wrappers). `percent_of()` and `write_position_sheets()` are shared by `preprocessing/front_7.py` and `offensive_line.py`. The three API endpoints come from a `build_api_router(league)` factory; the NFL stays mounted at `/api/metadata`, `/api/pass_rush`, `/api/pass_block` via `include_router(prefix="/api")`. No frontend was touched. Verified by diffing the DB rows, all API payloads and the regenerated xlsx against a pre-refactor baseline (byte-identical). PR #20 moves the NFL to `/api/nfl`, adds `/api/ncaaf` from the same factory, and brings the college tables, pages and Career Stats online. Detail: `architecture/BACKEND-AND-DEPLOY.md`. diff --git a/playbook/INVARIANTS.md b/playbook/INVARIANTS.md index deb8cd9..46b2a35 100644 --- a/playbook/INVARIANTS.md +++ b/playbook/INVARIANTS.md @@ -13,6 +13,7 @@ These apply to every task. Read them before starting. - **What to work on**: only the file in `next_steps/` that the owner names. Never act on anything in `discussions/`. - **Done means the whole CI set passes locally**: `pytest -v`, `black -S .`, `npm run lint:js`, `npm run format:js:check`. Not a single test file. - **Tests**: when adding a feature or fixing a bug, add the test to the file `playbook/tests/INDEX.md` points to. If none fits, create a new `tests/test_*.py` and its `playbook/tests/TEST_*.md` in the same change. A test file without its document, or a document `playbook/tests/INDEX.md` does not link (or an INDEX link to a file that does not exist), is incomplete, and CI fails on it (`tests/test_playbook_docs.py`). +- **Code style**: write code that reads top to bottom, never as a wall of consecutive lines. Put one blank line between steps (a new column, a filter, a rename, a rounding), as in `preprocessing/front_7.py` and `preprocessing/offensive_line.py`; `black -S` only wraps lines, so the blank lines are yours to add. Branches are separated too: put one blank line before every `elif`, `else`, `except` and `finally`, so the branches of an if-else or try-except never touch (see `main()` in `preprocessing/preprocess.py`). Names are spelled out in `snake_case` and say what the value is (`positional_df`, `pass_block_df`, `season_file_path`), DataFrames end in `_df`, module constants are `UPPER_SNAKE_CASE` and sit above the functions, every function signature has type hints, and no abbreviations in names or CLI flags. A module starts with a docstring saying what it does; a function gets a one-line docstring when its purpose is not obvious from its name; an inline comment says *why*, not *what*. Every comment and docstring sentence ends with a period (`# First name isn't already shortened.`), even a bare label (`# Akron.` beside a school's id); the only exceptions are things a period would change, such as a directive (`# noqa: E402`, `# fmt: skip`), a URL or a shell command. When unsure, copy the shape of `preprocessing/`. - **Say what you could not verify** (touch, pinch-zoom, standalone mode, real phone). The owner tests those by hand over the LAN IP. - **PFF data**: raw CSV/xlsx never enters the repo; test fixtures are synthetic only. - **Frontend**: zero npm runtime dependencies, no build step, no framework. `html2canvas` is the one vendored exception. diff --git a/playbook/ROSTER.md b/playbook/ROSTER.md index 2654ae9..4defbbb 100644 --- a/playbook/ROSTER.md +++ b/playbook/ROSTER.md @@ -27,7 +27,6 @@ Only the files marked **public** are tracked in git. Everything else stays on th | `playbook/next_steps/` | the folder is public (only a `.gitkeep` placeholder); every file inside is local only | | `playbook/discussions/` | the folder is public (only a `.gitkeep` placeholder); every file inside is local only | | `playbook/MEMORANDUM.md` | local only | -| `playbook/GUIDE.md` | local only | | `.claude/` | local only | ## Read this first, by task @@ -83,6 +82,6 @@ These files began as a verbatim split of the old `CLAUDE.md` "Architecture" sect - Changed behavior described in an `architecture/` file: update that file in the same change. - Keep `CLAUDE.md` short. If a paragraph is only needed for one kind of task, it belongs here. - Write files for Claude Code in English (`COMMANDS.md`, `DECISIONS.md`, `INVARIANTS.md`, `architecture/`), and write both `next_steps/` and - `discussions/` items in English too, so a file can move between them unchanged. Only `GUIDE.md` is Chinese, for the owner. + `discussions/` items in English too, so a file can move between them unchanged. - `next_steps/` and `discussions/` hold one file per item, with no numbering and no implied order; priority changes at any time. A topic moves from `discussions/` to `next_steps/` by moving its file; do not rename it. diff --git a/playbook/architecture/BACKEND-AND-DEPLOY.md b/playbook/architecture/BACKEND-AND-DEPLOY.md index 5cfccae..63daf85 100644 --- a/playbook/architecture/BACKEND-AND-DEPLOY.md +++ b/playbook/architecture/BACKEND-AND-DEPLOY.md @@ -13,11 +13,14 @@ 2. `preprocessing/preprocess.py --league nfl --seasons {season ...}` (both required) calls `preprocessing/front_7.py` and `preprocessing/offensive_line.py`, which each read the season CSV, rename PFF's snake_case columns to display names via `utils/renamers.py`, compute derived rate - metrics (Havoc Rate, Pressure Rate, and their TPS/"true pass set" variants), split by position - into separate sheets, and write `data/nfl/{front_7_pass_rush,ol_pass_block}/{season}.xlsx` (`config.NFL_DATA_PATH`; the `League` enum in `config.py` picks the league's folder, `data/ncaaf/` for the college side, and `preprocessing/preprocess.py` also builds the paths and checks the required columns first). + metrics (Havoc Rate, Pressure Rate, and their TPS/"true pass set" variants) through the shared + `percent_of()` helper in `preprocess.py`, split by position + into separate sheets written by the shared `write_position_sheets()` helper, and write `data/nfl/{front_7_pass_rush,ol_pass_block}/{season}.xlsx` (`config.NFL_DATA_PATH`; the `League` enum in `config.py` picks the league's folder, `data/ncaaf/` for the college side, and `preprocessing/preprocess.py` also builds the paths and checks the required columns first). 3. `database/db_ingestion.py` reads those xlsx workbooks and upserts rows into `nfl_pass_rush_stats` / - `nfl_pass_block_stats`. Every row carries PFF's `player_id` (NOT NULL; a null Player Id or a missing required column raises `ValueError`), and the unique key is `(season, position, player_id)`. It's idempotent by deleting all rows for a given `(season, position)` slice + `nfl_pass_block_stats`. One `ingest_category()` body loads both categories, driven by a `_Category` + descriptor; the required-column check and the row building read the one column declaration in + `database/columns.py` (`ingest_pass_rush` / `ingest_pass_block` remain as thin wrappers). Every row carries PFF's `player_id` (NOT NULL; a null Player Id or a missing required column raises `ValueError`), and the unique key is `(season, position, player_id)`. It's idempotent by deleting all rows for a given `(season, position)` slice and bulk-reinserting — simpler and dialect-portable vs. per-row upsert. Must be run from the repo root; it locates the repo root by walking up for a `.gitignore` marker, and resolves the data directory via `--data-dir` / `LINESHINES_DATA_DIR` / `LINESHINES_REPO_ROOT` env vars, in @@ -31,26 +34,35 @@ with `--seasons` omitted will scan the default location's `DEFAULT_SEASONS`, not the directory actually pointed at. -4. `database/db_models.py` defines the SQLAlchemy schema (`NFLTeam`, `NFLPassRushStat`, `NFLPassBlockStat`; the `nfl_` prefix leaves room for NCAAF tables) - plus `pass_rush_row_to_dict` / `pass_block_row_to_dict` serializers that intentionally reproduce - PFF's original space-and-mixed-case column names (e.g. `"PR Opp"`, `"TPS Havoc Rate"`) in the API - JSON payload — this keeps the frontend's key names stable across the CSV→xlsx→DB migration. +4. `database/db_models.py` defines the SQLAlchemy schema (`NFLTeam`, `NFLPassRushStat`, `NFLPassBlockStat`; the `nfl_` prefix leaves room for NCAAF tables). The stat tables take their + columns from mixins — `StatIdentityMixin` (id, season, position, player_id, player, abbr_name, games), + `PassRushMetricsMixin` and `PassBlockMetricsMixin` — so the college tables can reuse them later; only the + team foreign key and the table-level names stay per class. The metric attributes match the single column + declaration in `database/columns.py` (a test asserts this), which also drives the + `pass_rush_row_to_dict` / `pass_block_row_to_dict` serializers via `row_to_payload()`. The serializers + intentionally reproduce PFF's original space-and-mixed-case column names (e.g. `"PR Opp"`, `"TPS Havoc Rate"`) + in the API JSON payload — this keeps the frontend's key names stable across the CSV→xlsx→DB migration. -5. `main.py` is the FastAPI app: `/api/metadata` (seasons, positions, metric definitions, teams), - `/api/pass_rush`, `/api/pass_block` (season-filtered, DB-backed; `position` is an *optional* - query param — omitted, it returns every position in the category in one response, filtered, - it keeps the old single-position behavior), `/health`. It also mounts `frontend/` as static +5. `main.py` is the FastAPI app. The three data endpoints are built by `build_api_router(league)`, a + factory that takes a league's team model and its two `_CategoryApi` category configs and returns an + `APIRouter` with `/metadata`, `/pass_rush` and `/pass_block`; the NFL router is mounted with + `include_router(..., prefix="/api")`, so the paths stay `/api/metadata`, `/api/pass_rush`, + `/api/pass_block` (season-filtered, DB-backed; `position` is an *optional* query param — omitted, + it returns every position in the category in one response, filtered, it keeps the old single-position + behavior). PR #20 will move the NFL to `/api/nfl` and add NCAAF at `/api/ncaaf` from the same factory. + `/health` stays a plain route. It also mounts `frontend/` as static files (mounted *last* so `/api/*` routes take precedence) and `frontend/images/team_logos/` - under `/logos/`. `index.html` is served through a small handler that replaces `{{VERSION}}` + under `/logos/` (NFL logos at `/logos/nfl/{team code}.png`, NCAAF logos at `/logos/ncaaf/{franchise_id}.png`). `index.html` is served through a small handler that replaces `{{VERSION}}` with the Railway commit SHA for cache-busting on deploy. **Team codes** follow PFF's spellings (e.g. `ARZ`, `BLT`, `CLV`, `HST`, not the more common -`ARI`/`BAL`/`CLE`/`HOU`), defined once in `teams_reference.py` and used by both ingestion (team +`ARI`/`BAL`/`CLE`/`HOU`), defined once in `teams/nfl.py` and used by both ingestion (team upsert) and `/api/metadata` (colors, full names) — the frontend never hardcodes team data. **Deploy**: Railway, per `Procfile` (`uvicorn main:app`). `RAILWAY_GIT_COMMIT_SHA` env var drives frontend cache-busting; `DATABASE_URL` (Postgres, `postgres://` scheme) is rewritten in -`main._resolve_database_url` to `postgresql+psycopg://` for the psycopg3 driver. +`database/session.py` (`_resolve_database_url`, which also holds the engine and `SessionLocal`) to +`postgresql+psycopg://` for the psycopg3 driver. **Adding a nullable column to `db_models.py` requires a manual `ALTER TABLE` against the live Railway Postgres** — `Base.metadata.create_all(engine)` (see "Tests" below) only creates tables diff --git a/playbook/tests/INDEX.md b/playbook/tests/INDEX.md index 00b2a1f..15f4c81 100644 --- a/playbook/tests/INDEX.md +++ b/playbook/tests/INDEX.md @@ -21,6 +21,7 @@ grep -H "Add tests here when" playbook/tests/*.md | The `nfl_*` table names, unique key, NOT NULL `player_id`, and which table has the pass-rush percent columns | `test_db_models.py` ([doc](TEST_DB_MODELS.md)) | pure Python | | Neutral metrics: Pass Cov Percent order, grey chip on Player/Comparison/Linemate cards, chip contrast | `test_neutral_metric.py` ([doc](TEST_NEUTRAL_METRIC.md)) | browser | | The league handling of the preprocessing scripts: `League`, per-league paths, the required-column check, the command line | `test_preprocessing_league.py` ([doc](TEST_PREPROCESSING_LEAGUE.md)) | pure Python | +| The NCAAF conference table: a school's stints per conference, `conference_of()`, the out-of-scope list | `test_teams_ncaaf.py` ([doc](TEST_TEAMS_NCAAF.md)) | pure Python | | The folder layout of `data/` and `frontend/` | `test_paths.py` ([doc](TEST_PATHS.md)) | pure Python | | The Comparison (Merge) Card's table: Read and Sort layouts, and how it follows the sort order | `test_merge_table.py` ([doc](TEST_MERGE_TABLE.md)) | browser | | How percentiles, ranks and percentile pools are computed | `test_percentiles.py` ([doc](TEST_PERCENTILES.md)) | browser | diff --git a/playbook/tests/TEST_APP_STARTUP.md b/playbook/tests/TEST_APP_STARTUP.md index ee77ee5..b7edb2a 100644 --- a/playbook/tests/TEST_APP_STARTUP.md +++ b/playbook/tests/TEST_APP_STARTUP.md @@ -4,6 +4,6 @@ - **Kind:** API (HTTP client). - **Needs:** `client` from `conftest.py`. No browser. -- **Covers:** the app builds; `/health`; the logos mount and its folder; `/` serves the frontend; `/sw.js` is served with `{{VERSION}}` substituted and `no-cache`; the served `sw.js` carries the never-cache rule and `NETWORK_TIMEOUT_MS = 3000`; every root-mount file, `/` and `/sw.js` carry `Cache-Control: no-cache` while `/logos` does not; `index.html` registers the service worker; `/` serves the NFL page and the PWA manifest keeps `start_url` and `scope` at `/`. +- **Covers:** the app builds; `/health`; the logos mount and its folder (an NFL logo is served at `/logos/nfl/ARZ.png`); `/` serves the frontend; `/sw.js` is served with `{{VERSION}}` substituted and `no-cache`; the served `sw.js` carries the never-cache rule and `NETWORK_TIMEOUT_MS = 3000`; every root-mount file, `/` and `/sw.js` carry `Cache-Control: no-cache` while `/logos` does not; `index.html` registers the service worker; `/` serves the NFL page and the PWA manifest keeps `start_url` and `scope` at `/`. - **Not here:** how the Service Worker behaves at runtime → `TEST_SW_CACHE_BYPASS.md`; folder layout drift → `TEST_PATHS.md`. - **Known gaps:** none recorded. diff --git a/playbook/tests/TEST_DB_INGESTION.md b/playbook/tests/TEST_DB_INGESTION.md index b3a0591..ee4c6b3 100644 --- a/playbook/tests/TEST_DB_INGESTION.md +++ b/playbook/tests/TEST_DB_INGESTION.md @@ -4,6 +4,6 @@ - **Kind:** pure Python. - **Needs:** nothing beyond pandas and sqlalchemy. It writes tiny synthetic xlsx files in `tmp_path` and uses its own sqlite engine. -- **Covers:** reading from `data/nfl//.xlsx`; storing `player_id` and the pass-rush percent columns; a missing required column or a null Player Id raising `ValueError`; discovery scanning only the `nfl` subfolder; skipping a missing season file; no fallback to the old flat file names; the data-dir environment override for the repo root; re-ingesting replaces an existing season/position slice. +- **Covers:** reading from `data/nfl//.xlsx`; storing `player_id` and the pass-rush percent columns; a missing required column or a null Player Id raising `ValueError`; discovery scanning only the `nfl` subfolder; skipping a missing season file; no fallback to the old flat file names; the data-dir environment override for the repo root; re-ingesting replaces an existing season/position slice re-ingesting replaces an existing season/position slice. The required columns and the row building come from `database/columns.py` through the shared `ingest_category()`, so these tests exercise that one declaration. - **Not here:** the HTTP API that serves ingested rows → `TEST_API_ENDPOINTS.md`. - **Known gaps:** none recorded. diff --git a/playbook/tests/TEST_DB_MODELS.md b/playbook/tests/TEST_DB_MODELS.md index 1202997..5873499 100644 --- a/playbook/tests/TEST_DB_MODELS.md +++ b/playbook/tests/TEST_DB_MODELS.md @@ -4,6 +4,6 @@ - **Kind:** pure Python. - **Needs:** nothing beyond sqlalchemy. It inspects the models and an in-memory sqlite database. -- **Covers:** the `nfl_` prefix on every table; the `(season, position, player_id)` unique key and duplicate rejection; `player_id` NOT NULL; the pass-rush percent columns existing on the pass-rush table only. +- **Covers:** the `nfl_` prefix on every table; the `(season, position, player_id)` unique key and duplicate rejection; `player_id` NOT NULL; the pass-rush percent columns existing on the pass-rush table only the pass-rush percent columns existing on the pass-rush table only; the models' columns (from the identity and metric mixins) are exactly the identity columns plus the attributes declared in `database/columns.py`. - **Not here:** how files become rows → `TEST_DB_INGESTION.md`. - **Known gaps:** none recorded. diff --git a/playbook/tests/TEST_PATHS.md b/playbook/tests/TEST_PATHS.md index 61ee218..35a98ca 100644 --- a/playbook/tests/TEST_PATHS.md +++ b/playbook/tests/TEST_PATHS.md @@ -4,6 +4,6 @@ - **Kind:** pure Python. - **Needs:** nothing. Raw data files are git-ignored, so a CI checkout lacks the data folders; these tests assert only what is true with or without real season data. -- **Covers:** `NFL_DATA_PATH` is `data/nfl` and `NCAAF_DATA_PATH` is `data/ncaaf`; the data folder path matches the repo layout; no legacy flat data files; the team logo directory exists. +- **Covers:** `NFL_DATA_PATH` is `data/nfl` and `NCAAF_DATA_PATH` is `data/ncaaf`; the data folder path matches the repo layout; no legacy flat data files; the NFL logo directory `team_logos/nfl/` exists and holds PNGs, and no PNG sits loose in `team_logos/`. - **Not here:** ingestion behaviour → `TEST_DB_INGESTION.md`. - **Known gaps:** none recorded. diff --git a/playbook/tests/TEST_PREPROCESSING_LEAGUE.md b/playbook/tests/TEST_PREPROCESSING_LEAGUE.md index 72dadfa..1de0704 100644 --- a/playbook/tests/TEST_PREPROCESSING_LEAGUE.md +++ b/playbook/tests/TEST_PREPROCESSING_LEAGUE.md @@ -4,6 +4,6 @@ - **Kind:** pure Python. - **Needs:** nothing. The leagues' data folders are replaced by temporary ones with tiny synthetic CSVs, so no real PFF export is read. -- **Covers:** the two leagues are `nfl` and `ncaaf`; an unknown league is refused; a season file sits under its league's folder; for both categories and both leagues the workbook is written beside the CSV of the same league and nowhere else; a CSV missing a column raises one error naming the league, the season and the column, and writes no workbook; the command line needs `--league` and `--seasons`, refuses an unknown league and reads one or several seasons; an error in one CSV is printed to stderr and the remaining CSVs still run (the failures are returned), and a clean run reports none. +- **Covers:** the two leagues are `nfl` and `ncaaf`; an unknown league is refused; a season file sits under its league's folder; for both categories and both leagues the workbook is written beside the CSV of the same league and nowhere else; a CSV missing a column raises one error naming the league, the season and the column, and writes no workbook; the command line needs `--league` and `--seasons`, refuses an unknown league and reads one or several seasons; an error in one CSV is printed to stderr and the remaining CSVs still run (the failures are returned), and a clean run reports none and a clean run reports none; the shared `percent_of()` helper returns a rounded percentage, fills a zero denominator with zero, and rounds to `config.ROUNDING_DECIMALS`. - **Not here:** the rate calculations themselves (checked by running the real CSVs again and comparing the workbooks cell by cell, not by a test) and the data folder layout → `TEST_PATHS.md`. - **Known gaps:** the full list of required columns is not asserted, only that a missing one is caught. diff --git a/playbook/tests/TEST_TEAMS_NCAAF.md b/playbook/tests/TEST_TEAMS_NCAAF.md new file mode 100644 index 0000000..74c44b9 --- /dev/null +++ b/playbook/tests/TEST_TEAMS_NCAAF.md @@ -0,0 +1,9 @@ +# test_teams_ncaaf.py + +> **Add tests here when:** changing the NCAAF conference table in `teams/ncaaf.py` (a school's stints, the scoped conferences, the out-of-scope list) or `conference_of()`. + +- **Kind:** pure Python. +- **Needs:** nothing. The tables are plain Python, so no PFF data and no database are involved. +- **Covers:** every stint names a scoped conference and starts in 2014 or later; one school's stints are in order and never overlap; no school is both scoped and out of scope; a school that changes conference changes label in the move season (Texas, Oregon, California); a scoped school outside its stints is out of scope for that season (BYU 2022, Texas State 2025); UMass has two separate MAC stints; Northern Illinois leaves the MAC after 2025; only Oregon State and Washington State are in the Pac-12 in 2024; Notre Dame is the only independent; the size of each conference in the seasons where it changed; a never-scoped school returns `None`; an unknown `franchise_id` raises `KeyError`; `scoped_franchise_ids()` returns every school with a scoped season. +- **Not here:** that every team in a real PFF export resolves → checked by hand against the CSVs when a season is added (raw data never enters the repo). +- **Known gaps:** the table is written from ESPN and Wikipedia; Sacramento State's 2026 MAC membership has one source so far. diff --git a/preprocessing/front_7.py b/preprocessing/front_7.py index c7e9bca..f40eaae 100644 --- a/preprocessing/front_7.py +++ b/preprocessing/front_7.py @@ -1,6 +1,11 @@ import pandas as pd from config import FRONT_7_NAMES, ROUNDING_DECIMALS, League -from preprocessing.preprocess import require_columns, season_file_path +from preprocessing.preprocess import ( + percent_of, + require_columns, + season_file_path, + write_position_sheets, +) from utils.renamers import rename_pass_rush_columns, shorten_first_name CATEGORY = "front_7_pass_rush" @@ -37,51 +42,25 @@ def preprocess_front_7(league: League, season: int) -> None: ) positional_df["Havoc"] = positional_df["Sacks"] + positional_df["Hits"] - positional_df["Havoc Rate"] = positional_df["Havoc"] / positional_df["PR Opp"] - - positional_df["Havoc Rate"].fillna(inplace=True, value=0) - positional_df["Havoc Rate"] *= 100 - positional_df["Havoc Rate"] = positional_df["Havoc Rate"].round( - ROUNDING_DECIMALS - ) - - positional_df["Pressure Rate"] = ( - positional_df["Pressures"] / positional_df["PR Opp"] + positional_df["Havoc Rate"] = percent_of( + positional_df["Havoc"], positional_df["PR Opp"] ) - positional_df["Pressure Rate"].fillna(inplace=True, value=0) - positional_df["Pressure Rate"] *= 100 - positional_df["Pressure Rate"] = positional_df["Pressure Rate"].round( - ROUNDING_DECIMALS + positional_df["Pressure Rate"] = percent_of( + positional_df["Pressures"], positional_df["PR Opp"] ) positional_df["TPS Havoc"] = ( positional_df["TPS Sacks"] + positional_df["TPS Hits"] ) - - positional_df["TPS Havoc Rate"] = ( - positional_df["TPS Havoc"] / positional_df["TPS PR Opp"] - ) - - positional_df["TPS Havoc Rate"].fillna(inplace=True, value=0) - positional_df["TPS Havoc Rate"] *= 100 - positional_df["TPS Havoc Rate"] = positional_df["TPS Havoc Rate"].round( - ROUNDING_DECIMALS - ) - - positional_df["TPS Pressure Rate"] = ( - positional_df["TPS Pressures"] / positional_df["TPS PR Opp"] + positional_df["TPS Havoc Rate"] = percent_of( + positional_df["TPS Havoc"], positional_df["TPS PR Opp"] ) - positional_df["TPS Pressure Rate"].fillna(inplace=True, value=0) - positional_df["TPS Pressure Rate"] *= 100 - positional_df["TPS Pressure Rate"] = positional_df["TPS Pressure Rate"].round( - ROUNDING_DECIMALS + positional_df["TPS Pressure Rate"] = percent_of( + positional_df["TPS Pressures"], positional_df["TPS PR Opp"] ) positional_sheets.update({position: positional_df}) - destination_path = season_file_path(league, CATEGORY, season, "xlsx") - with pd.ExcelWriter(destination_path, engine="openpyxl") as writer: - for position, sheet in positional_sheets.items(): - sheet.to_excel(writer, sheet_name=position, index=False) + write_position_sheets(league, CATEGORY, season, positional_sheets) diff --git a/preprocessing/offensive_line.py b/preprocessing/offensive_line.py index f79470b..7655aa8 100644 --- a/preprocessing/offensive_line.py +++ b/preprocessing/offensive_line.py @@ -1,6 +1,11 @@ import pandas as pd -from config import OL_NAMES, ROUNDING_DECIMALS, League -from preprocessing.preprocess import require_columns, season_file_path +from config import OL_NAMES, League +from preprocessing.preprocess import ( + percent_of, + require_columns, + season_file_path, + write_position_sheets, +) from utils.renamers import rename_pass_block_columns, shorten_first_name CATEGORY = "ol_pass_block" @@ -31,46 +36,23 @@ def preprocess_offensive_line(league: League, season: int) -> None: positional_df = pass_block_df[pass_block_df["Position"] == position] positional_df["Havoc"] = positional_df["Sacks"] + positional_df["Hits"] - positional_df["Havoc %"] = ( - positional_df["Havoc"] / positional_df["Non Spike PB Snaps"] + positional_df["Havoc %"] = percent_of( + positional_df["Havoc"], positional_df["Non Spike PB Snaps"] ) - positional_df["Havoc %"].fillna(inplace=True, value=0) - positional_df["Havoc %"] *= 100 - positional_df["Havoc %"] = positional_df["Havoc %"].round(ROUNDING_DECIMALS) - - positional_df["Pressure %"] = ( - positional_df["Pressures"] / positional_df["Non Spike PB Snaps"] - ) - - positional_df["Pressure %"].fillna(inplace=True, value=0) - positional_df["Pressure %"] *= 100 - positional_df["Pressure %"] = positional_df["Pressure %"].round( - ROUNDING_DECIMALS + positional_df["Pressure %"] = percent_of( + positional_df["Pressures"], positional_df["Non Spike PB Snaps"] ) positional_df["TPS Havoc"] = ( positional_df["TPS Sacks"] + positional_df["TPS Hits"] ) - - positional_df["TPS Havoc %"] = ( - positional_df["TPS Havoc"] / positional_df["TPS Non Spike PB Snaps"] - ) - - positional_df["TPS Havoc %"].fillna(inplace=True, value=0) - positional_df["TPS Havoc %"] *= 100 - positional_df["TPS Havoc %"] = positional_df["TPS Havoc %"].round( - ROUNDING_DECIMALS - ) - - positional_df["TPS Pressure %"] = ( - positional_df["TPS Pressures"] / positional_df["TPS Non Spike PB Snaps"] + positional_df["TPS Havoc %"] = percent_of( + positional_df["TPS Havoc"], positional_df["TPS Non Spike PB Snaps"] ) - positional_df["TPS Pressure %"].fillna(inplace=True, value=0) - positional_df["TPS Pressure %"] *= 100 - positional_df["TPS Pressure %"] = positional_df["TPS Pressure %"].round( - ROUNDING_DECIMALS + positional_df["TPS Pressure %"] = percent_of( + positional_df["TPS Pressures"], positional_df["TPS Non Spike PB Snaps"] ) positional_df.rename( @@ -85,7 +67,4 @@ def preprocess_offensive_line(league: League, season: int) -> None: positional_sheets.update({position: positional_df}) - destination_path = season_file_path(league, CATEGORY, season, "xlsx") - with pd.ExcelWriter(destination_path, engine="openpyxl") as writer: - for position, sheet in positional_sheets.items(): - sheet.to_excel(writer, sheet_name=position, index=False) + write_position_sheets(league, CATEGORY, season, positional_sheets) diff --git a/preprocessing/preprocess.py b/preprocessing/preprocess.py index 2e948e5..b6c1784 100644 --- a/preprocessing/preprocess.py +++ b/preprocessing/preprocess.py @@ -11,7 +11,7 @@ import os import sys import pandas as pd -from config import LEAGUE_DATA_PATHS, League +from config import LEAGUE_DATA_PATHS, ROUNDING_DECIMALS, League def season_file_path(league: League, category: str, season: int, extension: str) -> str: @@ -31,6 +31,24 @@ def require_columns( ) +def percent_of(numerator, denominator): + """A rate as a rounded percentage: numerator / denominator, zero-filled, x100.""" + rate = numerator / denominator + rate = rate.fillna(0) + rate *= 100 + + return rate.round(ROUNDING_DECIMALS) + + +def write_position_sheets(league, category, season, positional_sheets) -> None: + """Write one sheet per position to the season's xlsx, in insertion order.""" + destination_path = season_file_path(league, category, season, "xlsx") + + with pd.ExcelWriter(destination_path, engine="openpyxl") as writer: + for position, sheet in positional_sheets.items(): + sheet.to_excel(writer, sheet_name=position, index=False) + + def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( description=__doc__.splitlines()[0], allow_abbrev=False diff --git a/teams/ncaaf.py b/teams/ncaaf.py new file mode 100644 index 0000000..c856bec --- /dev/null +++ b/teams/ncaaf.py @@ -0,0 +1,273 @@ +"""Which conference every NCAAF school belonged to in each season. + +PFF's college exports carry no conference column, so the conversion step looks +it up here by `franchise_id` (stable, one-to-one with the team name) and season. +Only the six scoped conferences and Notre Dame are listed in `CONFERENCE_STINTS`; +the database keeps a row if and only if its school was in one of them that season. + +Sources: ESPN "College football realignment: conference changes for 2026" and +Wikipedia "2010-2014 / 2021-2026 NCAA conference realignment". Design record: +`playbook/discussions/NCAAF-TEAM-LOGOS-AND-REALIGNMENT.md`. +""" + +from __future__ import annotations +from typing import NamedTuple + +SEC = "SEC" +B1G = "B1G" +ACC = "ACC" +BIG_12 = "Big 12" +PAC_12 = "Pac-12" +MAC = "MAC" +INDEPENDENT = "Independent" + +SCOPED_CONFERENCES = (SEC, B1G, ACC, BIG_12, PAC_12, MAC, INDEPENDENT) + +FIRST_SEASON_COVERED = 2014 + + +class Stint(NamedTuple): + """One stretch of consecutive seasons a school spent in one conference.""" + + first_season: int + last_season: int | None # None: still there. + conference: str + + +# franchise_id -> stints, in season order. A school can have several stints:. +# UMass was a MAC football member in 2014 and 2015, independent until 2024. +CONFERENCE_STINTS: dict[int, list[Stint]] = { + # SEC. + 103: [Stint(2014, None, SEC)], # Alabama. + 111: [Stint(2014, None, SEC)], # Arkansas. + 115: [Stint(2014, None, SEC)], # Auburn. + 163: [Stint(2014, None, SEC)], # Florida. + 173: [Stint(2014, None, SEC)], # Georgia. + 202: [Stint(2014, None, SEC)], # Kentucky. + 211: [Stint(2014, None, SEC)], # LSU. + 226: [Stint(2014, None, SEC)], # Ole Miss. + 227: [Stint(2014, None, SEC)], # Mississippi State. + 229: [Stint(2014, None, SEC)], # Missouri. + 289: [Stint(2014, None, SEC)], # South Carolina. + 307: [Stint(2014, None, SEC)], # Tennessee. + 312: [Stint(2014, None, SEC)], # Texas A&M. + 333: [Stint(2014, None, SEC)], # Vanderbilt. + 261: [Stint(2014, 2023, BIG_12), Stint(2024, None, SEC)], # Oklahoma. + 311: [Stint(2014, 2023, BIG_12), Stint(2024, None, SEC)], # Texas. + # Big Ten. + 187: [Stint(2014, None, B1G)], # Illinois. + 190: [Stint(2014, None, B1G)], # Indiana. + 192: [Stint(2014, None, B1G)], # Iowa. + 215: [Stint(2014, None, B1G)], # Maryland. + 222: [Stint(2014, None, B1G)], # Michigan. + 223: [Stint(2014, None, B1G)], # Michigan State. + 225: [Stint(2014, None, B1G)], # Minnesota. + 238: [Stint(2014, None, B1G)], # Nebraska. + 256: [Stint(2014, None, B1G)], # Northwestern. + 260: [Stint(2014, None, B1G)], # Ohio State. + 266: [Stint(2014, None, B1G)], # Penn State. + 273: [Stint(2014, None, B1G)], # Purdue. + 278: [Stint(2014, None, B1G)], # Rutgers. + 349: [Stint(2014, None, B1G)], # Wisconsin. + 264: [Stint(2014, 2023, PAC_12), Stint(2024, None, B1G)], # Oregon. + 324: [Stint(2014, 2023, PAC_12), Stint(2024, None, B1G)], # UCLA. + 326: [Stint(2014, 2023, PAC_12), Stint(2024, None, B1G)], # USC. + 340: [Stint(2014, 2023, PAC_12), Stint(2024, None, B1G)], # Washington. + # ACC. + 121: [Stint(2014, None, ACC)], # Boston College. + 140: [Stint(2014, None, ACC)], # Clemson. + 154: [Stint(2014, None, ACC)], # Duke. + 167: [Stint(2014, None, ACC)], # Florida State. + 176: [Stint(2014, None, ACC)], # Georgia Tech. + 210: [Stint(2014, None, ACC)], # Louisville. + 220: [Stint(2014, None, ACC)], # Miami (FL). + 245: [Stint(2014, None, ACC)], # North Carolina. + 248: [Stint(2014, None, ACC)], # NC State. + 268: [Stint(2014, None, ACC)], # Pittsburgh. + 304: [Stint(2014, None, ACC)], # Syracuse. + 335: [Stint(2014, None, ACC)], # Virginia. + 337: [Stint(2014, None, ACC)], # Virginia Tech. + 339: [Stint(2014, None, ACC)], # Wake Forest. + 287: [Stint(2024, None, ACC)], # SMU. + 130: [Stint(2014, 2023, PAC_12), Stint(2024, None, ACC)], # California. + 300: [Stint(2014, 2023, PAC_12), Stint(2024, None, ACC)], # Stanford. + # Big 12. + 118: [Stint(2014, None, BIG_12)], # Baylor. + 193: [Stint(2014, None, BIG_12)], # Iowa State. + 198: [Stint(2014, None, BIG_12)], # Kansas. + 199: [Stint(2014, None, BIG_12)], # Kansas State. + 262: [Stint(2014, None, BIG_12)], # Oklahoma State. + 305: [Stint(2014, None, BIG_12)], # TCU. + 315: [Stint(2014, None, BIG_12)], # Texas Tech. + 343: [Stint(2014, None, BIG_12)], # West Virginia. + 128: [Stint(2023, None, BIG_12)], # BYU. + 138: [Stint(2023, None, BIG_12)], # Cincinnati. + 182: [Stint(2023, None, BIG_12)], # Houston. + 323: [Stint(2023, None, BIG_12)], # UCF. + 109: [Stint(2014, 2023, PAC_12), Stint(2024, None, BIG_12)], # Arizona. + 110: [Stint(2014, 2023, PAC_12), Stint(2024, None, BIG_12)], # Arizona State. + 143: [Stint(2014, 2023, PAC_12), Stint(2024, None, BIG_12)], # Colorado. + 328: [Stint(2014, 2023, PAC_12), Stint(2024, None, BIG_12)], # Utah. + # Pac-12 (Oregon State and Washington State stayed after 2024 restructure). + 265: [Stint(2014, None, PAC_12)], # Oregon State. + 341: [Stint(2014, None, PAC_12)], # Washington State. + 120: [Stint(2026, None, PAC_12)], # Boise State. + 144: [Stint(2026, None, PAC_12)], # Colorado State. + 169: [Stint(2026, None, PAC_12)], # Fresno State. + 284: [Stint(2026, None, PAC_12)], # San Diego State. + 329: [Stint(2026, None, PAC_12)], # Utah State. + 314: [Stint(2026, None, PAC_12)], # Texas State. + # MAC. + 102: [Stint(2014, None, MAC)], # Akron. + 117: [Stint(2014, None, MAC)], # Ball State. + 122: [Stint(2014, None, MAC)], # Bowling Green. + 126: [Stint(2014, None, MAC)], # Buffalo. + 134: [Stint(2014, None, MAC)], # Central Michigan. + 160: [Stint(2014, None, MAC)], # Eastern Michigan. + 201: [Stint(2014, None, MAC)], # Kent State. + 221: [Stint(2014, None, MAC)], # Miami (OH). + 259: [Stint(2014, None, MAC)], # Ohio. + 316: [Stint(2014, None, MAC)], # Toledo. + 347: [Stint(2014, None, MAC)], # Western Michigan. + 254: [Stint(2014, 2025, MAC)], # Northern Illinois, Mountain West from 2026. + 216: [Stint(2014, 2015, MAC), Stint(2025, None, MAC)], # UMass. + 279: [Stint(2026, None, MAC)], # Sacramento State. + # Independent. + 258: [Stint(2014, None, INDEPENDENT)], # Notre Dame. +} + + +# franchise_id -> the school's name as used on screen and to match ESPN's team list. +SCHOOL_NAMES: dict[int, str] = { + 103: "Alabama", + 111: "Arkansas", + 115: "Auburn", + 163: "Florida", + 173: "Georgia", + 202: "Kentucky", + 211: "LSU", + 226: "Ole Miss", + 227: "Mississippi State", + 229: "Missouri", + 289: "South Carolina", + 307: "Tennessee", + 312: "Texas A&M", + 333: "Vanderbilt", + 261: "Oklahoma", + 311: "Texas", + 187: "Illinois", + 190: "Indiana", + 192: "Iowa", + 215: "Maryland", + 222: "Michigan", + 223: "Michigan State", + 225: "Minnesota", + 238: "Nebraska", + 256: "Northwestern", + 260: "Ohio State", + 266: "Penn State", + 273: "Purdue", + 278: "Rutgers", + 349: "Wisconsin", + 264: "Oregon", + 324: "UCLA", + 326: "USC", + 340: "Washington", + 121: "Boston College", + 140: "Clemson", + 154: "Duke", + 167: "Florida State", + 176: "Georgia Tech", + 210: "Louisville", + 220: "Miami (FL)", + 245: "North Carolina", + 248: "NC State", + 268: "Pittsburgh", + 304: "Syracuse", + 335: "Virginia", + 337: "Virginia Tech", + 339: "Wake Forest", + 287: "SMU", + 130: "California", + 300: "Stanford", + 118: "Baylor", + 193: "Iowa State", + 198: "Kansas", + 199: "Kansas State", + 262: "Oklahoma State", + 305: "TCU", + 315: "Texas Tech", + 343: "West Virginia", + 128: "BYU", + 138: "Cincinnati", + 182: "Houston", + 323: "UCF", + 109: "Arizona", + 110: "Arizona State", + 143: "Colorado", + 328: "Utah", + 265: "Oregon State", + 341: "Washington State", + 120: "Boise State", + 144: "Colorado State", + 169: "Fresno State", + 284: "San Diego State", + 329: "Utah State", + 314: "Texas State", + 102: "Akron", + 117: "Ball State", + 122: "Bowling Green", + 126: "Buffalo", + 134: "Central Michigan", + 160: "Eastern Michigan", + 201: "Kent State", + 221: "Miami (OH)", + 259: "Ohio", + 316: "Toledo", + 347: "Western Michigan", + 254: "Northern Illinois", + 216: "UMass", + 279: "Sacramento State", + 258: "Notre Dame", +} + +# Every other franchise_id found in the PFF exports of 2022 to 2026: deliberately. +# out of scope, dropped without a message. An id in neither dict is an error. +OUT_OF_SCOPE_FRANCHISE_IDS = frozenset( + { + 101, 108, 113, 114, 136, 141, 146, 151, 156, 165, 166, 174, 175, 180, 196, + 197, 200, 206, 207, 208, 209, 214, 218, 224, 230, 237, 239, 241, 242, 250, + 251, 263, 275, 281, 285, 288, 296, 306, 318, 319, 320, 321, 325, 327, 330, + 331, 346, 351, 1177, + } +) # fmt: skip + + +def conference_of(franchise_id: int, season: int) -> str | None: + """The conference a school played in that season, or None when out of scope. + + None covers a school that is never in scope and a scoped school in a season + outside its stints (BYU in 2022). Raises KeyError for an unknown franchise_id, + so a new team in a PFF export is noticed instead of silently dropped. + """ + if franchise_id in OUT_OF_SCOPE_FRANCHISE_IDS: + return None + + for stint in CONFERENCE_STINTS[franchise_id]: + ends_after_season = stint.last_season is None or season <= stint.last_season + if stint.first_season <= season and ends_after_season: + return stint.conference + + return None + + +def scoped_franchise_ids(first_season: int, last_season: int) -> list[int]: + """Franchise ids in scope in at least one season of the range, sorted.""" + return sorted( + franchise_id + for franchise_id in CONFERENCE_STINTS + if any( + conference_of(franchise_id, season) is not None + for season in range(first_season, last_season + 1) + ) + ) diff --git a/teams/nfl.py b/teams/nfl.py new file mode 100644 index 0000000..19def71 --- /dev/null +++ b/teams/nfl.py @@ -0,0 +1,45 @@ +"""Shared reference data for the 32 NFL teams. + +Kept in one place so `database/db_ingestion.py` doesn't drift, +and so `main.py`'s /api/metadata payload can serve +these values to frontend (rather than frontend hard-coding them, +which was the case in earlier static-JSON version). +""" + +# Team code → (full name, primary brand color hex). +# Codes match abbreviations used by LinesShines repo's PFF +# exports and /logos/nfl/ folder (e.g. ARZ, BLT, CLV, HST — PFF's spellings). +TEAMS: dict[str, tuple[str, str]] = { + "ARZ": ("Arizona Cardinals", "#97233F"), + "ATL": ("Atlanta Falcons", "#A71930"), + "BLT": ("Baltimore Ravens", "#241773"), + "BUF": ("Buffalo Bills", "#00338D"), + "CAR": ("Carolina Panthers", "#0085CA"), + "CHI": ("Chicago Bears", "#0B162A"), + "CIN": ("Cincinnati Bengals", "#FB4F14"), + "CLV": ("Cleveland Browns", "#FF3C00"), + "DAL": ("Dallas Cowboys", "#041E42"), + "DEN": ("Denver Broncos", "#FB4F14"), + "DET": ("Detroit Lions", "#0076B6"), + "GB": ("Green Bay Packers", "#203731"), + "HST": ("Houston Texans", "#03202F"), + "IND": ("Indianapolis Colts", "#002C5F"), + "JAX": ("Jacksonville Jaguars", "#006778"), + "KC": ("Kansas City Chiefs", "#E31837"), + "LA": ("Los Angeles Rams", "#003594"), + "LAC": ("Los Angeles Chargers", "#0080C6"), + "LV": ("Las Vegas Raiders", "#A5ACAF"), + "MIA": ("Miami Dolphins", "#008E97"), + "MIN": ("Minnesota Vikings", "#4F2683"), + "NE": ("New England Patriots", "#002244"), + "NO": ("New Orleans Saints", "#D3BC8D"), + "NYG": ("New York Giants", "#0B2265"), + "NYJ": ("New York Jets", "#125740"), + "PHI": ("Philadelphia Eagles", "#004C54"), + "PIT": ("Pittsburgh Steelers", "#FFB612"), + "SEA": ("Seattle Seahawks", "#69BE28"), + "SF": ("San Francisco 49ers", "#AA0000"), + "TB": ("Tampa Bay Buccaneers", "#D50A0A"), + "TEN": ("Tennessee Titans", "#4B92DB"), + "WAS": ("Washington Commanders", "#5A1414"), +} diff --git a/tests/test_app_startup.py b/tests/test_app_startup.py index 7bf1766..1e37a0d 100644 --- a/tests/test_app_startup.py +++ b/tests/test_app_startup.py @@ -23,7 +23,7 @@ def test_logos_mounted_at_expected_path(): def test_logos_served_from_team_logos_dir(client): - resp = client.get("/logos/ARZ.png") + resp = client.get("/logos/nfl/ARZ.png") assert resp.status_code == 200 assert resp.headers["content-type"] == "image/png" @@ -85,7 +85,7 @@ def test_frontend_files_must_be_revalidated(client): def test_logos_mount_is_left_alone(client): - resp = client.get("/logos/ARZ.png") + resp = client.get("/logos/nfl/ARZ.png") assert resp.headers.get("cache-control") != "no-cache" diff --git a/tests/test_db_models.py b/tests/test_db_models.py index 88a1dca..18dd142 100644 --- a/tests/test_db_models.py +++ b/tests/test_db_models.py @@ -69,6 +69,7 @@ def test_a_second_row_for_the_same_season_position_and_player_id_is_rejected( same_player_other_team, ): sessionmaker_ = sessionmaker(_engine()) + with sessionmaker_() as sess: sess.add_all( [ @@ -114,3 +115,30 @@ def test_the_same_player_id_may_appear_in_another_season_or_position(): sess.commit() assert sess.query(NFLPassRushStat).count() == 3 + + +def test_model_columns_match_the_column_spec(): + """The mixins' columns are exactly the identity columns plus the spec's attributes. + + This keeps the single source in `database/columns.py`: a metric added there + (and so to the ingestion and the API) must be given a model column too. + """ + from database.columns import PASS_BLOCK_DATA_COLUMNS, PASS_RUSH_DATA_COLUMNS + + identity = { + "id", + "season", + "position", + "team_code", + "player_id", + "player", + "abbr_name", + } + + for model, data_columns in ( + (NFLPassRushStat, PASS_RUSH_DATA_COLUMNS), + (NFLPassBlockStat, PASS_BLOCK_DATA_COLUMNS), + ): + table_columns = {column.name for column in model.__table__.columns} + expected = identity | {column.attr for column in data_columns} + assert table_columns == expected, model.__name__ diff --git a/tests/test_paths.py b/tests/test_paths.py index 67aefe9..7f88f3a 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -37,6 +37,7 @@ def test_no_legacy_flat_data_files(): def test_team_logo_directory(): - logos_dir = REPO_ROOT / "frontend" / "images" / "team_logos" + logos_dir = REPO_ROOT / "frontend" / "images" / "team_logos" / "nfl" assert logos_dir.is_dir() assert list(logos_dir.glob("*.png")), "no logo PNGs found" + assert not list(logos_dir.parent.glob("*.png")), "logos belong in nfl/ or ncaaf/" diff --git a/tests/test_preprocessing_league.py b/tests/test_preprocessing_league.py index b282e62..98d111d 100644 --- a/tests/test_preprocessing_league.py +++ b/tests/test_preprocessing_league.py @@ -7,7 +7,12 @@ from config import League from preprocessing.front_7 import preprocess_front_7 from preprocessing.offensive_line import preprocess_offensive_line -from preprocessing.preprocess import main, parse_arguments, season_file_path +from preprocessing.preprocess import ( + main, + parse_arguments, + percent_of, + season_file_path, +) FRONT_7_RAW = { "player": ["Alex Edge"], @@ -175,3 +180,24 @@ def test_without_an_error_nothing_is_reported_as_failed(monkeypatch, capsys): assert main(["--league", "nfl", "--seasons", "2026"]) == [] assert capsys.readouterr().err == "" + + +def test_percent_of_is_a_rounded_percentage(): + numerator = pd.Series([2.0, 3.0, 0.0]) + denominator = pd.Series([8.0, 12.0, 50.0]) + + result = percent_of(numerator, denominator) + + assert list(result) == [25.0, 25.0, 0.0] + + +def test_percent_of_fills_a_zero_denominator_with_zero(): + result = percent_of(pd.Series([0.0]), pd.Series([0.0])) + + assert list(result) == [0.0] + + +def test_percent_of_rounds_to_the_configured_decimals(): + result = percent_of(pd.Series([1.0]), pd.Series([3.0])) + + assert list(result) == [round(100.0 / 3.0, config.ROUNDING_DECIMALS)] diff --git a/tests/test_teams_ncaaf.py b/tests/test_teams_ncaaf.py new file mode 100644 index 0000000..5e9d17d --- /dev/null +++ b/tests/test_teams_ncaaf.py @@ -0,0 +1,134 @@ +"""The NCAAF conference lookup: stints are well formed and the known moves resolve. + +Uses only the tables in `teams/ncaaf.py`, so it needs no PFF data. +""" + +from __future__ import annotations + +from collections import Counter + +import pytest + +import teams.ncaaf as reference +from teams.ncaaf import ( + ACC, + B1G, + BIG_12, + INDEPENDENT, + MAC, + PAC_12, + SEC, + conference_of, +) + +FIRST_SEASON = 2014 +LAST_SEASON = 2026 + + +def test_every_stint_names_a_scoped_conference_and_starts_in_the_covered_range(): + for franchise_id, stints in reference.CONFERENCE_STINTS.items(): + for stint in stints: + assert stint.conference in reference.SCOPED_CONFERENCES, franchise_id + assert stint.first_season >= reference.FIRST_SEASON_COVERED, franchise_id + + +def test_stints_of_one_school_are_in_order_and_never_overlap(): + for franchise_id, stints in reference.CONFERENCE_STINTS.items(): + for earlier, later in zip(stints, stints[1:]): + assert earlier.last_season is not None, franchise_id + assert earlier.last_season < later.first_season, franchise_id + + +def test_a_school_is_not_both_scoped_and_out_of_scope(): + scoped_ids = set(reference.CONFERENCE_STINTS) + + assert not scoped_ids & reference.OUT_OF_SCOPE_FRANCHISE_IDS + + +def test_a_school_moving_conference_changes_label_in_the_move_season(): + assert conference_of(311, 2023) == BIG_12 # Texas. + assert conference_of(311, 2024) == SEC + assert conference_of(264, 2023) == PAC_12 # Oregon. + assert conference_of(264, 2024) == B1G + assert conference_of(130, 2024) == ACC # California. + + +def test_a_school_outside_its_stints_is_out_of_scope_for_that_season(): + assert conference_of(128, 2022) is None # BYU was independent. + assert conference_of(128, 2023) == BIG_12 + assert conference_of(314, 2025) is None # Texas State, Sun Belt until 2026. + assert conference_of(314, 2026) == PAC_12 + + +def test_umass_has_two_separate_mac_stints(): + assert conference_of(216, 2015) == MAC + assert conference_of(216, 2016) is None + assert conference_of(216, 2024) is None + assert conference_of(216, 2025) == MAC + + +def test_northern_illinois_leaves_the_mac_after_2025(): + assert conference_of(254, 2025) == MAC + assert conference_of(254, 2026) is None + + +def test_the_two_schools_left_in_the_pac_12_in_2024_and_2025(): + pac_12_ids = [ + franchise_id + for franchise_id in reference.CONFERENCE_STINTS + if conference_of(franchise_id, 2024) == PAC_12 + ] + + assert sorted(pac_12_ids) == [265, 341] # Oregon State, Washington State. + + +def test_notre_dame_is_the_only_independent_in_scope(): + independents = [ + franchise_id + for franchise_id in reference.CONFERENCE_STINTS + if conference_of(franchise_id, 2025) == INDEPENDENT + ] + + assert independents == [258] + + +@pytest.mark.parametrize( + "season, conference, expected_size", + [ + (2022, BIG_12, 10), + (2023, BIG_12, 14), + (2024, BIG_12, 16), + (2022, PAC_12, 12), + (2024, PAC_12, 2), + (2026, PAC_12, 8), + (2024, B1G, 18), + (2024, ACC, 17), + (2024, SEC, 16), + (2025, MAC, 13), + (2026, MAC, 13), + ], +) +def test_conference_size_in_season(season, conference, expected_size): + sizes = Counter( + conference_of(franchise_id, season) + for franchise_id in reference.CONFERENCE_STINTS + ) + + assert sizes[conference] == expected_size + + +def test_a_never_scoped_school_is_dropped_without_an_error(): + assert conference_of(101, 2024) is None # Air Force. + + +def test_an_unknown_franchise_id_raises_instead_of_being_dropped(): + with pytest.raises(KeyError): + conference_of(999999, 2024) + + +def test_scoped_franchise_ids_collects_schools_with_any_scoped_season(): + ids = reference.scoped_franchise_ids(FIRST_SEASON, LAST_SEASON) + + assert ids == sorted(reference.CONFERENCE_STINTS) + assert 128 in reference.scoped_franchise_ids(2023, 2023) + assert 128 not in reference.scoped_franchise_ids(2022, 2022)