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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ Before any task, read `playbook/INVARIANTS.md`. It holds the working rules (git,
PFF data, frontend limits, versioning) and the invariants that must stay true. Then use the table below to find the
detail for your task.

Read it again in full at the start of every new session and after every context compaction, before editing any file: a summary
of an earlier conversation is not a substitute for the rules, and a rule recalled from memory may be incomplete.

To find a work item or an architecture file, do not open whole folders. First read the top of each file (the title and one-line summary,
or the "Load when" line), then open only the ones that apply. The commands are in `playbook/ROSTER.md`.

Expand Down
50 changes: 45 additions & 5 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ class League(str, Enum):
NCAAF = "ncaaf"


class CareerStatus(str, Enum):
"""Who is on the college Career page. The value is what the table stores and the page shows."""

JOINED_NFL = "Joined NFL"
PROSPECTS = "Prospects"


LEAGUE_DATA_PATHS: dict[League, str] = {
League.NFL: NFL_DATA_PATH,
League.NCAAF: NCAAF_DATA_PATH,
Expand All @@ -39,9 +46,9 @@ class League(str, Enum):
"Allowed Havoc Rate = (Sacks + QB Hits) / Non Spike Pass Block Snaps."
)

PRP_NOTE = "PRP = (Sacks + 0.75 x (QB Hits + Hurries)) / Pass Rush Snaps x 100."
PRP_NOTE = "PRP = (Sacks + 0.5 x (QB Hits + Hurries)) / Pass Rush Opportunities x 100."
PASS_COV_PERCENT_NOTE = "Pass Cov Percent = 100 - Pass Rush Percent, where Pass Rush Percent = Pass Rush Snaps / Pass Play Snaps x 100."
PBE_NOTE = "PBE = 100 - (Sacks + 0.75 x (QB Hits + Hurries)) / Pass Block Snaps."
PBE_NOTE = "PBE = 100 - (Sacks + 0.5 x (QB Hits + Hurries)) / Non Spike Pass Block Snaps x 100."

# Shared across every "TPS "-prefixed metric — not a per-metric formula, so
# it lives once here rather than duplicated into a TPS_*_NOTE per metric.
Expand Down Expand Up @@ -80,8 +87,41 @@ class League(str, Enum):
}


# NCAAF thresholds. The NFL values do not fit: the NFL's 230 pass rush opportunities
# lets only 12 to 17% of the college ED and DI players through (about 36% in the NFL),
# so the college value is about 140; the NFL's 300 pass block snaps already fits the OL.
# Starting values, confirmed by trying the real page; change them if they feel wrong.
NCAAF_DEFAULT_THRESHOLDS: dict[str, int] = {
"pass_rush": 230, # Min PR Opp for pass rush filter.
"pass_block": 360, # Min Non Spike PB Snaps for pass block filter.
}

# Set BY HAND each week, like the NFL ones above. The 2026 entry is a placeholder
# of about 2/3 of the static values (a college season has about 12 games).
NCAAF_DYNAMIC_THRESHOLDS: dict[int, dict[str, int]] = {
2026: {
"pass_rush": 120, # Min PR Opp for pass rush filter.
"pass_block": 205, # Min Non Spike PB Snaps for pass block filter.
},
}

LEAGUE_DEFAULT_THRESHOLDS: dict[League, dict[str, int]] = {
League.NFL: DEFAULT_THRESHOLDS,
League.NCAAF: NCAAF_DEFAULT_THRESHOLDS,
}

LEAGUE_DYNAMIC_THRESHOLDS: dict[League, dict[int, dict[str, int]]] = {
League.NFL: DYNAMIC_THRESHOLDS,
League.NCAAF: NCAAF_DYNAMIC_THRESHOLDS,
}


def resolve_default_threshold(
category: str, season: int, *, today: date | None = None
category: str,
season: int,
league: League = League.NFL,
*,
today: date | None = None
) -> int:
"""Static preset for a finalized season, dynamic preset for the season
currently in progress. Routing is a Calendar Year comparison
Expand All @@ -92,8 +132,8 @@ def resolve_default_threshold(
current_year = (today or date.today()).year

if season == current_year:
dynamic = DYNAMIC_THRESHOLDS.get(season, {})
dynamic = LEAGUE_DYNAMIC_THRESHOLDS[league].get(season, {})
if category in dynamic:
return dynamic[category]

return DEFAULT_THRESHOLDS[category]
return LEAGUE_DEFAULT_THRESHOLDS[league][category]
71 changes: 71 additions & 0 deletions config/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""All configurations, split by what they describe.

- `leagues`: the leagues, who is on the college Career page, and the data paths.
- `metrics`: position names, rounding, and the notes that explain each metric.
- `thresholds`: the default thresholds per league and the rule that picks one.

Every name is re-exported here, so code writes `from config import League` and does
not care which file defines it.
"""

from config.leagues import (
BASE_PATH,
DATA_FOLDER_PATH,
LEAGUE_DATA_PATHS,
NCAAF_DATA_PATH,
NFL_DATA_PATH,
CareerStatus,
League,
)
from config.metrics import (
ALLOWED_HAVOC_RATE_NOTE,
DISPLAY_DECIMALS,
FRONT_7_NAMES,
HAVOC_RATE_NOTE,
OL_NAMES,
PASS_COV_PERCENT_NOTE,
PBE_NOTE,
PRP_NOTE,
ROUNDING_DECIMALS,
TPS_NOTE,
TPS_PROPORTION_NOTE,
)
from config.thresholds import (
DEFAULT_THRESHOLDS,
DYNAMIC_THRESHOLDS,
LEAGUE_DEFAULT_THRESHOLDS,
LEAGUE_DYNAMIC_THRESHOLDS,
NCAAF_CAREER_DEFAULT_THRESHOLDS,
NCAAF_DEFAULT_THRESHOLDS,
NCAAF_DYNAMIC_THRESHOLDS,
resolve_default_threshold,
)

__all__ = [
"ALLOWED_HAVOC_RATE_NOTE",
"BASE_PATH",
"DATA_FOLDER_PATH",
"DEFAULT_THRESHOLDS",
"DISPLAY_DECIMALS",
"DYNAMIC_THRESHOLDS",
"FRONT_7_NAMES",
"HAVOC_RATE_NOTE",
"LEAGUE_DATA_PATHS",
"LEAGUE_DEFAULT_THRESHOLDS",
"LEAGUE_DYNAMIC_THRESHOLDS",
"NCAAF_CAREER_DEFAULT_THRESHOLDS",
"NCAAF_DATA_PATH",
"NCAAF_DEFAULT_THRESHOLDS",
"NCAAF_DYNAMIC_THRESHOLDS",
"NFL_DATA_PATH",
"OL_NAMES",
"PASS_COV_PERCENT_NOTE",
"PBE_NOTE",
"PRP_NOTE",
"ROUNDING_DECIMALS",
"TPS_NOTE",
"TPS_PROPORTION_NOTE",
"CareerStatus",
"League",
"resolve_default_threshold",
]
34 changes: 34 additions & 0 deletions config/leagues.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""The leagues, who is on the college Career page, and where the data lives."""

import os
from enum import Enum

# Repo root: this file sits one folder below it, in config/.
BASE_PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_FOLDER_PATH = os.path.join(BASE_PATH, "data")

# Each league keeps its files in its own subfolder:
# data/{nfl,ncaaf}/{front_7_pass_rush,ol_pass_block}/.
# DATA_FOLDER_PATH stays root of all data.
NFL_DATA_PATH = os.path.join(DATA_FOLDER_PATH, "nfl")
NCAAF_DATA_PATH = os.path.join(DATA_FOLDER_PATH, "ncaaf")


class League(str, Enum):
"""The leagues the data pipeline knows. The value is the data folder name."""

NFL = "nfl"
NCAAF = "ncaaf"


class CareerStatus(str, Enum):
"""Who is on the college Career page. The value is what the table stores and the page shows."""

JOINED_NFL = "Joined NFL"
PROSPECTS = "Prospects"


LEAGUE_DATA_PATHS: dict[League, str] = {
League.NFL: NFL_DATA_PATH,
League.NCAAF: NCAAF_DATA_PATH,
}
29 changes: 29 additions & 0 deletions config/metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Position names, rounding, and the notes that explain each metric."""

ROUNDING_DECIMALS = 3 # Better precision alleviates overlaps in plots.
DISPLAY_DECIMALS = 1 # For UI displays only.


FRONT_7_NAMES = {"DI": "Defensive Interior", "ED": "Edge", "LB": "Linebacker"}
OL_NAMES = {"T": "Offensive Tackles", "G": "Guards", "C": "Centers"}


HAVOC_RATE_NOTE = "Havoc Rate = (Sacks + QB Hits) / Pass Rush Opportunities."
ALLOWED_HAVOC_RATE_NOTE = (
"Allowed Havoc Rate = (Sacks + QB Hits) / Non Spike Pass Block Snaps."
)

PRP_NOTE = "PRP = (Sacks + 0.5 x (QB Hits + Hurries)) / Pass Rush Opportunities x 100."
PASS_COV_PERCENT_NOTE = "Pass Cov Percent = 100 - Pass Rush Percent, where Pass Rush Percent = Pass Rush Snaps / Pass Play Snaps x 100."
TPS_PROPORTION_NOTE = "TPS Proportion % = TPS Non Spike Pass Block Snaps / Non Spike Pass Block Snaps x 100."
PBE_NOTE = "PBE = 100 - (Sacks + 0.5 x (QB Hits + Hurries)) / Non Spike Pass Block Snaps x 100."

# Shared across every "TPS "-prefixed metric — not a per-metric formula, so
# it lives once here rather than duplicated into a TPS_*_NOTE per metric.
# The frontend appends this to whatever a TPS metric's own note/formula
# already says, keyed off the "TPS " prefix rather than a per-metric flag
# (see main.py's /api/metadata: exposed once at the top level, not per metric).
TPS_NOTE = (
"True Pass Set: excludes plays with less than 4 rushers, play action, "
"screens, short dropbacks and time-to-throws under 2 seconds."
)
97 changes: 97 additions & 0 deletions config/thresholds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""The default thresholds per league, and the rule that picks one for a season."""

from datetime import date
from config.leagues import CareerStatus, League

# Default "historical seasons'" thresholds applied on page load.
DEFAULT_THRESHOLDS: dict[str, int] = {
"pass_rush": 230, # Min PR Opp for pass rush filter.
"pass_block": 300, # Min Non Spike PB Snaps for pass block filter.
}

# Dynamic threshold for the current season in progress.
# Set thresholds BY HAND each week during data ingestion.
# Eyeballed against how far the season has actually progressed.

# Deliberately NOT derived from a fraction-of-season-elapsed formula.
# Because season pacing is not uniform. But — the final end is quite stable throughout years.
# Hereby, we use static thresholds for already ended seasons.

# Add/edit current year's entry weekly; once a season ends, leave it in place.
# With a season's end, resolve_default_threshold() falls back to static
# DEFAULT_THRESHOLDS automatically after entering next following calendar year.
# Key, which means current season, must be "renamed" when a new season comes.
DYNAMIC_THRESHOLDS: dict[int, dict[str, int]] = {
2026: {
"pass_rush": 95, # Min PR Opp for pass rush filter.
"pass_block": 105, # Min Non Spike PB Snaps for pass block filter.
},
}


# NCAAF thresholds, their own because the NFL values left too many or too few players on
# the college chart: the front 7 uses the same 230 as the NFL, the OL a stricter 360.
# Set by the owner after trying the real page; change them if they feel wrong.
NCAAF_DEFAULT_THRESHOLDS: dict[str, int] = {
"pass_rush": 230, # Min PR Opp for pass rush filter.
"pass_block": 360, # Min Non Spike PB Snaps for pass block filter.
}

# Set BY HAND each week, like the NFL ones above. The 2026 entry is a placeholder
# of about 2/3 of the static values (a college season has about 12 games).
NCAAF_DYNAMIC_THRESHOLDS: dict[int, dict[str, int]] = {
2026: {
"pass_rush": 120, # Min PR Opp for pass rush filter.
"pass_block": 205, # Min Non Spike PB Snaps for pass block filter.
},
}

# The college Career page has no season, so no dynamic entry, and two thresholds per
# category: a Joined NFL player must have played more college snaps than a Prospect,
# whose few snaps (a young player) still make a projection worth showing. The
# numbers count the merged career (summed PR Opp, summed Non Spike PB Snaps). The owner
# tunes them by running the page locally; the front 7 pair was set that way, the OL pair
# is still a starting point.
NCAAF_CAREER_DEFAULT_THRESHOLDS: dict[str, dict[str, int]] = {
"pass_rush": {
CareerStatus.JOINED_NFL.value: 400,
CareerStatus.PROSPECTS.value: 300,
},
"pass_block": {
CareerStatus.JOINED_NFL.value: 650,
CareerStatus.PROSPECTS.value: 500,
},
}

LEAGUE_DEFAULT_THRESHOLDS: dict[League, dict[str, int]] = {
League.NFL: DEFAULT_THRESHOLDS,
League.NCAAF: NCAAF_DEFAULT_THRESHOLDS,
}

LEAGUE_DYNAMIC_THRESHOLDS: dict[League, dict[int, dict[str, int]]] = {
League.NFL: DYNAMIC_THRESHOLDS,
League.NCAAF: NCAAF_DYNAMIC_THRESHOLDS,
}


def resolve_default_threshold(
category: str,
season: int,
league: League = League.NFL,
*,
today: date | None = None
) -> int:
"""Static preset for a finalized season, dynamic preset for the season
currently in progress. Routing is a Calendar Year comparison
(season_year vs. today's year), not schedule-aware.
Regular season is ~90% complete before New Year's Day, so this is an accepted
near-100%-accurate approximation. No need to be perfectly precise.
"""
current_year = (today or date.today()).year

if season == current_year:
dynamic = LEAGUE_DYNAMIC_THRESHOLDS[league].get(season, {})
if category in dynamic:
return dynamic[category]

return LEAGUE_DEFAULT_THRESHOLDS[league][category]
Loading
Loading