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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 31 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,32 +148,39 @@ only those opens from disk. Retrying the tool that timed out means pressing **Re
which forces every dataset it loads — including the ones already cached — to download
again.

**A season nflverse has not published.** The season label rolls over on 1 September,
while nflverse publishes a season's play-by-play, snap counts, weekly stats and injuries
only once its games have been played — so for the opening days of September every tool asks
for a season the source answers `HTTP Error 404` for. That season contributes no rows and
the seasons beside it render: each loader resolves its season list one season at a time, so
an unpublished season costs only itself.

The skip reaches the interface, because every screen builds its season filter from the
seasons its data carries rows for. The unpublished season is absent from the
filter, and the filter opens on the newest season that has data. Start/Sit and Trade Value
read their season options off the results for the same reason, not off the schedule:
nflverse publishes a schedule months before the season opens, so a schedule alone would
offer a season nothing can be projected or ranked for.

Nothing negative is cached, so the season joins the filter on the first load after nflverse
publishes it. The price is one failed request per unpublished asset per load.

**`Failed to load data: nflverse has not published snap counts for season 2026`.** Every
season the tool asked for is unpublished, so the load has nothing to render. The message
names the dataset and the seasons. Loads whose datasets are published are unaffected: a tool
fails only on the dataset it could not resolve at all.
**A season a load does not obtain.** The season label rolls over on 1 September, while
nflverse publishes a season's play-by-play, snap counts, weekly stats and injuries only
once its games have been played — so for the opening days of September every tool asks for
a season the source answers `HTTP Error 404` for. That season contributes no rows and the
seasons beside it render: each loader resolves its season list one season at a time, so a
season that does not resolve costs only itself.

A transport failure costs the same season the same way, and reaches the loaders as the same
exception. Nothing below the loaders distinguishes the two, so nothing reports which one
happened; a season that disappears on one load and returns on the next was a failed
transfer, and one that stays absent is waiting on the source.

The drop reaches the interface, because every screen builds its season filter from the
seasons its data carries rows for. The absent season is absent from the filter, and the
filter opens on the newest season that has data. Start/Sit reads its week options off the
results for the same reason, not off the schedule: nflverse publishes a schedule months
before the season opens, so a schedule alone would offer a week nothing can be projected
for. Trade Value offers a season as soon as one of its weeks has been played, which is
earlier than it can rank one — the season being played is the season a trade is made in,
and the chart says what it is still short of rather than falling back to a finished season.

Nothing negative is cached, so the season joins the filter on the first load that obtains
it. The price is one failed request per absent asset per load.

**`Failed to load data: No snap counts obtained for season 2026`.** Every season the tool
asked for dropped, so the load has nothing to render. The message names the dataset and the
seasons, and no cause. Loads whose datasets resolve are unaffected: a tool fails only on the
dataset it could not resolve at all.

**Any other `Failed to load data:` toast** carries the message the download itself raised. A
season nflverse has not published a weekly-stats asset for is not an error on its own: those
weeks are derived from play-by-play, at the cost of a play-by-play download, and the season
drops out only when neither asset covers it.
season with no weekly-stats asset is not an error on its own: those weeks are derived from
play-by-play, at the cost of a play-by-play download, and the season drops out only when
neither asset covers it.

**A table shows a sentence where rows would be.** An empty result renders as a message
naming which emptiness it is — a filter combination that matches nothing, a season and week
Expand Down
77 changes: 52 additions & 25 deletions src/ffb/data/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
— play-by-play, snap counts, weekly stats, injuries — appear only once games have been
played. A season the calendar names can therefore have no asset to read.

Every loader taking a season list resolves it one season at a time, so a season with no
asset contributes no rows while the seasons beside it render. A request whose every
season is unpublished raises rather than yielding an empty frame: a frame with no
columns has no `season` column for a screen to derive its filter options from, and
raises `ColumnNotFoundError` the moment an engine filters it.
Every loader taking a season list resolves it one season at a time, so a season the
request does not obtain contributes no rows while the seasons beside it render. A
request that obtains none of its seasons raises rather than yielding an empty frame: a
frame with no columns has no `season` column for a screen to derive its filter options
from, and raises `ColumnNotFoundError` the moment an engine filters it.

What a request does not obtain, it cannot explain. A missing asset and a failed
transfer reach these loaders as the same exception, so nothing below names a cause.
"""

import logging
Expand All @@ -27,7 +30,7 @@


class _SeasonUnavailable(Exception):
"""Signals one season the source holds no asset for."""
"""Signals one season a request did not obtain."""


def _download(
Expand Down Expand Up @@ -65,43 +68,66 @@ def run() -> None:
return outcome["value"]


def _nflverse(importer: str, *args: Any) -> pl.DataFrame:
def _nflverse(importer: str, *args: Any, **options: Any) -> pl.DataFrame:
"""Fetch a frame from the named nfl_data_py importer, under the download timeout.

`options` are keyword arguments for the importer. Binding them onto the callable
here rather than forwarding them through `_download` leaves that function one
keyword, its own timeout, and no way for an importer argument to shadow it.

nfl_data_py is imported here rather than at module scope because it pulls in
pandas and numpy, and the TUI reaches its first frame without paying that cost.
"""
import nfl_data_py as nfl # type: ignore[import-untyped]

pdf = _download(getattr(nfl, importer), *args)
pdf = _download(partial(getattr(nfl, importer), **options), *args)
return pl.from_pandas(pdf)


def _nflverse_season(importer: str, season: int) -> pl.DataFrame:
def _nflverse_season(importer: str, season: int, **options: Any) -> pl.DataFrame:
"""Fetch one season from the named importer, or raise `_SeasonUnavailable`.

A season with no release asset takes one of two shapes. nfl_data_py reads the asset
URL directly, so a missing asset arrives as the 404 urllib raises: `HTTPError`,
which derives from `OSError`.
A read that does not return a season takes one of two shapes. nfl_data_py reads the
asset URL directly, so a missing asset arrives as the 404 urllib raises:
`HTTPError`, which derives from `OSError`. A transport failure — a reset connection,
a DNS failure — derives from `OSError` too, and arrives the same way.

`import_pbp_data` cannot report a failed download at all. Its download handler
reads `except Error as e`, and `Error` is bound nowhere in that module, so reaching
the handler raises `NameError` in place of the failure that reached it — run
`inspect.getsource(nfl.import_pbp_data)` to read the handler. `NameError` is
therefore the only signal that importer emits for a season it cannot download.
therefore the only signal that importer emits for a season it cannot download,
whatever stopped the download.

Neither shape distinguishes an asset that does not exist from one that could not be
read, which is why nothing here reports why a season dropped.

`TimeoutError` derives from `OSError` too, and a stalled transfer says nothing
about what nflverse holds, so it passes through as itself.
`TimeoutError` derives from `OSError` too, and the caller set that deadline, so it
passes through as itself.

Narrowing the catch to this one call into nfl_data_py keeps a `NameError` raised
anywhere in this repository a `NameError`.
"""
try:
return _nflverse(importer, [season])
return _nflverse(importer, [season], **options)
except TimeoutError:
raise
except (OSError, NameError) as e:
raise _SeasonUnavailable(f"{importer} holds no {season} data: {e}") from e
raise _SeasonUnavailable(f"{importer} returned no {season} data: {e}") from e


def _nflverse_pbp(season: int) -> pl.DataFrame:
"""One season of play-by-play, without the participation sidecar.

`import_pbp_data` reads the play-by-play asset and a per-season participation
sidecar under one `try`, and the nflverse releases carry the two independently:
a season can hold play-by-play and no sidecar. Reading both together means the
sidecar's absence discards the play-by-play beside it, so the merge is declined
rather than depended on. No column this repository reads comes from the sidecar —
`import_pbp_data` merges it on `play_id` and `old_game_id` and contributes only
columns named nowhere in `src`.
"""
return _nflverse_season("import_pbp_data", season, include_participation=False)


def _cached(
Expand Down Expand Up @@ -150,23 +176,24 @@ def _by_season(
served from a partial the first stored.

Raising on a request that resolves nothing keeps an empty frame out of the engines.
The message names the seasons and the source, so the error a screen reports says
which season to wait on.
The message names the seasons it obtained nothing for. What it does not name is a
cause: a season drops on a missing asset and on a failed transfer alike, and the
two are indistinguishable at this layer.
"""
if not seasons:
raise RuntimeError(f"No {label} available: no seasons requested (got {seasons})")

frames: list[pl.DataFrame] = []
unpublished: list[int] = []
dropped: list[int] = []
for season in sorted(set(seasons)):
try:
frames.append(_cached(f"{prefix}_{season}", fetch, season, force_refresh=force_refresh))
except _SeasonUnavailable as e:
log.warning("skipping %d, nflverse has published no %s for it: %s", season, label, e)
unpublished.append(season)
log.warning("skipping %d, no %s obtained for it: %s", season, label, e)
dropped.append(season)

if not frames:
raise RuntimeError(f"nflverse has not published {label} for {_seasons_phrase(unpublished)}")
raise RuntimeError(f"No {label} obtained for {_seasons_phrase(dropped)}")

return pl.concat(frames, how="diagonal_relaxed")

Expand All @@ -193,7 +220,7 @@ def load_pbp(
return _by_season(
"play-by-play",
"pbp",
partial(_nflverse_season, "import_pbp_data"),
_nflverse_pbp,
seasons,
force_refresh=force_refresh,
)
Expand Down Expand Up @@ -229,7 +256,7 @@ def _fetch_weekly_stats(season: int) -> pl.DataFrame:
except _SeasonUnavailable as e:
log.info("weekly data unavailable for %d, deriving from PBP: %s", season, e)

pbp_df = _nflverse_season("import_pbp_data", season)
pbp_df = _nflverse_pbp(season)
roster_df: pl.DataFrame | None
try:
roster_df = _nflverse_season("import_seasonal_rosters", season)
Expand Down
57 changes: 23 additions & 34 deletions src/ffb/data/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,102 +28,91 @@ def compute_weekly_stats_from_pbp(
plays = pbp.filter(pl.col("play_type").is_in(["pass", "run"]))

# ── Receiving stats ──────────────────────────────────────
# The name is taken off the group rather than grouped on. nflverse spells a name
# from the source field of each play, so one id can carry two spellings inside a
# single role in one week; grouping on the spelling would split that week into two
# partial lines. The spelling kept is the one the week's first play carries.
rec = (
plays.filter((pl.col("pass_attempt") == 1) & pl.col("receiver_player_id").is_not_null())
.group_by(
"receiver_player_id",
"receiver_player_name",
"posteam",
"season",
"week",
"season_type",
)
.agg(
pl.col("receiver_player_name").first().alias("name_from_receiving"),
pl.len().alias("targets"),
pl.col("complete_pass").sum().alias("receptions"),
pl.col("receiving_yards").sum().alias("receiving_yards"),
pl.col("pass_touchdown").sum().alias("receiving_tds"),
)
.rename(
{
"receiver_player_id": "player_id",
"receiver_player_name": "name_from_receiving",
"posteam": "recent_team",
}
)
.rename({"receiver_player_id": "player_id", "posteam": "recent_team"})
)

# ── Rushing stats ────────────────────────────────────────
rush = (
plays.filter((pl.col("rush_attempt") == 1) & pl.col("rusher_player_id").is_not_null())
.group_by(
"rusher_player_id",
"rusher_player_name",
"posteam",
"season",
"week",
"season_type",
)
.agg(
pl.col("rusher_player_name").first().alias("name_from_rushing"),
pl.len().alias("carries"),
pl.col("rushing_yards").sum().alias("rushing_yards"),
pl.col("rush_touchdown").sum().alias("rushing_tds"),
)
.rename(
{
"rusher_player_id": "player_id",
"rusher_player_name": "name_from_rushing",
"posteam": "recent_team",
}
)
.rename({"rusher_player_id": "player_id", "posteam": "recent_team"})
)

# ── Passing stats ────────────────────────────────────────
passing = (
plays.filter((pl.col("pass_attempt") == 1) & pl.col("passer_player_id").is_not_null())
.group_by(
"passer_player_id",
"passer_player_name",
"posteam",
"season",
"week",
"season_type",
)
.agg(
pl.col("passer_player_name").first().alias("name_from_passing"),
pl.col("passing_yards").sum().alias("passing_yards"),
pl.col("pass_touchdown").sum().alias("passing_tds"),
pl.col("interception").sum().alias("interceptions"),
)
.rename(
{
"passer_player_id": "player_id",
"passer_player_name": "name_from_passing",
"posteam": "recent_team",
}
)
.rename({"passer_player_id": "player_id", "posteam": "recent_team"})
)

# ── Fumbles (attributed to the fumbler) ──────────────────
# Read from the unfiltered frame: a fumble lost on a kickoff or punt return
# carries the same -2 as one lost from scrimmage.
# The team is the fumbler's own, which on a return is the team without the ball:
# `posteam` names the kicking team there, and attributing the fumble to it would
# give the returner a second weekly row under a team they never played for.
fumbles = (
pbp.filter((pl.col("fumble_lost") == 1) & pl.col("fumbled_1_player_id").is_not_null())
pbp.filter(
(pl.col("fumble_lost") == 1)
& pl.col("fumbled_1_player_id").is_not_null()
& pl.col("fumbled_1_team").is_not_null()
)
.group_by(
"fumbled_1_player_id",
"fumbled_1_player_name",
"posteam",
"fumbled_1_team",
"season",
"week",
"season_type",
)
.agg(pl.col("fumble_lost").sum().alias("fumbles_lost"))
.rename(
{
"fumbled_1_player_id": "player_id",
"fumbled_1_player_name": "name_from_fumbles",
"posteam": "recent_team",
}
.agg(
pl.col("fumbled_1_player_name").first().alias("name_from_fumbles"),
pl.col("fumble_lost").sum().alias("fumbles_lost"),
)
.rename({"fumbled_1_player_id": "player_id", "fumbled_1_team": "recent_team"})
)

# ── Opponent mapping (one defteam per team per game) ────
Expand Down
Loading
Loading