diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..971ba35 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ +## Why + +The problem or the goal. What breaks, what is missing, or what this unlocks. A diff shows what +changed; only you can supply the reason it is worth changing. + +## What changed + +The shape of the change, not a file list. Name the boundaries it crosses: which engine, which +screen, which part of the data layer, and any behaviour a user of the TUI sees differently. + +## How tested + +The commands you ran and what they told you. Name the tests that cover the change, and say plainly +which parts you exercised by hand in the TUI and which parts nothing covers. + +--- + +`.github/workflows/ci.yml` runs ruff, mypy, pytest, `uv build`, and a pip-audit vulnerability scan +on every push and pull request. Report here only what those gates cannot check. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d617881 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: ci + +on: + push: + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + gates: + name: gates + runs-on: ubuntu-latest + + steps: + - name: Check out the commit under test + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install uv and select Python 3.12 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + python-version: "3.12" + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Install the locked dependency set, and reject a uv.lock that disagrees with pyproject.toml + run: uv sync --locked --all-groups + + - name: Reject lint violations + run: uv run ruff check . + + - name: Reject unformatted source + run: uv run ruff format --check . + + - name: Reject type errors under src + run: uv run mypy src + + - name: Reject a failing test, or coverage under the floor in pyproject.toml + run: uv run pytest --cov + + - name: Reject a package that does not build + run: uv build + + - name: Reject a known vulnerability in any locked dependency + run: | + uv export --frozen --no-emit-project --all-groups \ + --format requirements.txt -o "${RUNNER_TEMP}/requirements.txt" + uv run --with pip-audit pip-audit --strict --disable-pip \ + -r "${RUNNER_TEMP}/requirements.txt" diff --git a/.gitignore b/.gitignore index 51ae6f5..bc74989 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,7 @@ __pycache__/ *.pyc dist/ *.egg-info/ -.cache/ +.coverage +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ diff --git a/CLAUDE.md b/CLAUDE.md index 28ef2fd..0f75331 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,29 +12,36 @@ NFL data analytics and fantasy football tools. ## Data Source -All data comes from [nflverse](https://github.com/nflverse/nflverse-data) via `nfl_data_py`. Key imports: +All data comes from [nflverse](https://github.com/nflverse/nflverse-data) via `nfl_data_py`. +The loaders in `src/ffb/data/loader.py` call these importers: ```python import nfl_data_py as nfl -nfl.import_pbp_data([2024, 2025]) # play-by-play with EPA -nfl.import_snap_counts([2024, 2025]) # snap counts -nfl.import_player_stats([2024, 2025]) # player stats -nfl.import_weekly_rosters([2024, 2025]) # rosters -nfl.import_injuries([2024, 2025]) # injuries -nfl.import_schedules([2024, 2025]) # schedules -nfl.import_contracts() # contracts -nfl.import_ngs_data(stat_type="receiving") # Next Gen Stats +nfl.import_pbp_data([2024, 2025]) # play-by-play with EPA +nfl.import_snap_counts([2024, 2025]) # snap counts +nfl.import_weekly_data([2024, 2025]) # weekly player stats +nfl.import_seasonal_rosters([2024, 2025]) # rosters +nfl.import_injuries([2024, 2025]) # injury reports +nfl.import_schedules([2024, 2025]) # schedules +nfl.import_ids() # pfr_id ↔ gsis_id crosswalk ``` +`nfl_data_py` publishes further importers the loaders do not call, among them +`import_ngs_data(stat_type="receiving")` for Next Gen Stats and `import_contracts()`. + +nflverse publishes weekly player stats as one release asset per season, so a season in +progress can have no asset at all. `load_weekly_stats` derives those weeks from +play-by-play rather than dropping the season. + ## Repo - **GitHub:** stxkxs/ffb - **Project board:** https://github.com/users/stxkxs/projects/1 -- **Research:** see `nfl-data-api-research.md` for full API research and decision rationale +- **Architecture:** `docs/architecture.md` — bounded contexts, the engine/screen split, and how to add a tool -## Supplementary APIs (not in current stack, available if needed) +## Supplementary APIs (not used by this codebase) -- **ESPN:** undocumented JSON API for real-time scores/injuries — see research doc for endpoints +- **ESPN:** undocumented JSON API for real-time scores and injuries - **Sleeper:** free JSON API for fantasy league data, roster %, trending players - **The Odds API:** betting lines (500 free req/mo) diff --git a/README.md b/README.md index 90ee473..a546d76 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ uv sync uv run ffb ``` +The sidebar lists six tools. A tool downloads its data the first time it is opened; +`docs/architecture.md` describes how the tools, the engines and the data layer fit together. + ## Tools ### Snap Share @@ -32,12 +35,12 @@ Team and player red zone conversion analysis. Red zone behavior is the strongest - Filter by position, team, season ### Injury Impact -When a player gets injured, instantly see the historical fantasy impact on their teammates. Search by player name to see how teammate stats changed when that player was active vs inactive. +When a player gets injured, see the historical fantasy impact on their teammates. Search by player name to compare teammate stats across the weeks that player was active against the weeks they sat out. -- Search autocomplete across all offensive players +- Search autocomplete across every offensive player in the loaded snap counts - Fantasy points, targets, and touches with/without the player -- Delta and confidence indicators based on sample size -- Multi-season lookback (2023-2025) +- Delta per teammate, and a confidence tier keyed to the size of the without-sample: High at 7 games or more, Med at 4 or more, Low below that +- Three-season lookback ### Start/Sit Matchup-based start/sit projections. Cross-references player baselines against opponent defensive rankings by position to produce matchup-adjusted fantasy point projections. @@ -56,13 +59,31 @@ Ranks players by a composite usage score combining snap share, target share, and - Filter by position, team, season, week ### Trade Value -Rest-of-season trade value chart. Normalizes player value from multiple factors into a single 0-100 score for evaluating trades. - -- Production (40%): season PPG × games remaining -- Schedule strength (20%): avg opponent defensive ranking for remaining games -- Usage (20%): snap share percentage -- Health (10%): injury history discount from missed games -- Bye week (10%): penalty if bye is still upcoming +Rest-of-season trade value chart. Ranks the offensive players of one season through one chosen week and scales the field so the strongest player scores 100. + +A player is ranked when the chosen week is week 3 or later, the 18-week regular season +still has a week left to play, and the player has at least three games of results. Four +components carry the score: + +- **Production (50%)** — season PPG multiplied by games remaining, divided by the largest + such figure in the field. Games remaining is the weeks left in the regular season, less + one when the player's team still has its bye ahead. +- **Schedule strength (20%)** — average fantasy points the remaining opponents allow at the + player's position, divided by the league average allowed at that position, then scaled + across the field between its own minimum and maximum. A team whose remaining opponents + have no ranking takes a neutral 1.00 multiplier. +- **Usage (20%)** — average offensive snap share over the weeks played, as a fraction of + 100. A player with no snap-count row takes 50%. +- **Health (10%)** — one minus the fraction of the loaded seasons' regular-season weeks the + player was listed Out, that fraction capped at 0.5 so health never falls below 0.5. A + player with no Out week takes 1.0. + +A bye week is not a component of its own: it enters through games remaining, which is the +only term that separates two players with the same PPG. + +The weighted sum is rescaled so the field's best score reads 100.0. The table lists PPG, +games played, the schedule multiplier, snap share, health, bye week and the value, and +filters by position, team, season and as-of week. ## Stack @@ -70,6 +91,79 @@ Rest-of-season trade value chart. Normalizes player value from multiple factors - **Processing:** `polars` - **TUI:** `textual` -## Data Caching +## Configuration + +Every setting is a module constant; the application reads no environment variables and +takes no command-line flags. + +| Setting | Value | Defined in | +| --- | --- | --- | +| Cache directory | `~/.fantasy/cache` | `CACHE_DIR` in `src/ffb/data/cache.py` | +| Freshness window | 6 hours | `DEFAULT_TTL` in `src/ffb/data/cache.py` | +| Size ceiling | 2 GiB | `MAX_CACHE_BYTES` in `src/ffb/data/cache.py` | +| Download timeout | 120 seconds per download | `_DOWNLOAD_TIMEOUT` in `src/ffb/data/loader.py` | + +Each download lands as one Parquet file named for its cache key — `snap_counts_2024_2025.parquet`, +`pbp_2024_2025.parquet`, `player_ids.parquet` — beside a `_meta.json` holding the write and +last-access time of every entry. A read of an entry written more than six hours earlier +counts as a miss and triggers a fresh download. Writing an entry reaps records whose Parquet +file is gone and Parquet files no record claims, then evicts entries least recently read +until the directory fits under the ceiling. + +To clear the cache, delete the directory: + +```bash +rm -rf ~/.fantasy/cache +``` + +To replace only what one tool reads, press **Refresh** in that tool. Refresh skips the cache +read and overwrites every key the tool loads. + +## Troubleshooting + +**The first load takes minutes.** Opening a tool downloads whole-season Parquet assets from +nflverse. Red Zone pulls two seasons of play-by-play, and so does any tool whose season has +no published weekly-stats asset; `src/ffb/data/cache.py` sizes the cache around a two-season +play-by-play entry landing near 100 MB. Nothing is on disk before the first download +succeeds, so the wait falls once per dataset and then not again for six hours. + +**Watching a load.** The loading pane names what is downloading and counts elapsed time as +`MM:SS`, updated once a second. `nfl_data_py` downloads a release asset in one blocking call +and reports no progress, so elapsed time is the only honest readout — there is no percentage +or bar. + +**Stopping a load.** Press the **Cancel** button, or `escape` while the load is in flight — +the binding is offered in the footer only while there is something to cancel. Cancelling +discards the result and restores the previous content, and posts `Load cancelled.` A download +already under way cannot be interrupted, so it runs to completion or to its timeout on a +background daemon thread with nothing left to hand its result to. + +**`Failed to load data: Download timed out after 120s. Check your network connection.`** One +download did not finish inside its 120-second budget. The budget is per download, not per +tool: a tool that loads five datasets gives each its own 120 seconds, and the first to run +out ends the whole load. Datasets that completed are already cached, so a tool that reads +only those opens from disk. Retrying the tool that timed out means pressing **Refresh**, +which forces every dataset it loads — including the ones already cached — to download +again. + +**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: those weeks are +derived from play-by-play, at the cost of a play-by-play download. + +**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 +too early to rank, or a search with no split to report. + +## Development + +```bash +uv sync # install the project and the dev group +uv run ruff check . # lint +uv run mypy src # type-check +uv run pytest # unit tests +uv run pytest --cov # unit tests, and the coverage floor in pyproject.toml +uv run ffb # run the TUI +``` -Downloaded data is cached locally at `~/.fantasy/cache/` with a 6-hour TTL. Hit the Refresh button in any tool to force a fresh download. +Tests run the cache against a redirected cache directory, and the engines, the views and +the app against the synthetic league in `tests/conftest.py`. No test reaches the network. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..7a17267 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,170 @@ +# Architecture + +`ffb` is a single-user terminal application over public nflverse data. Six analytics tools +share one shell, one download path and one disk cache; each tool otherwise stands alone. + +## The shape + +``` +┌───────────────────────────────────────────────────────────────────────┐ +│ app.py — FFBApp │ +│ sidebar ListView ──▶ ContentSwitcher ──▶ activate() on first show │ +└─────────────────────────────────┬─────────────────────────────────────┘ + │ mounts one ToolView per tool +┌─────────────────────────────────▼─────────────────────────────────────┐ +│ ui/base.py — ToolView │ +│ loading pane · elapsed clock · cancel · thread worker · error toast │ +│ compose_filter_bar · position_select · fill_table │ +└─────────────────────────────────┬─────────────────────────────────────┘ + │ subclassed by + snap_share/ red_zone/ injury_impact/ waiver_wire/ start_sit/ trade_value/ + screen.py screen.py screen.py screen.py screen.py screen.py + │ │ │ │ │ │ + │ apply() calls the sibling engine on frames already in memory │ + │ ▼ ▼ ▼ ▼ ▼ + │ engine.py — pure polars: frames in, frames out, no I/O + │ + │ fetch() runs off the UI thread + ▼ +┌───────────────────────────────────────────────────────────────────────┐ +│ data/seasons.py — which seasons to ask for │ +│ data/loader.py — one _cached() call per dataset, 120 s per download │ +│ data/cache.py — TTL, atomic write, LRU ceiling, ~/.fantasy/cache │ +└───────────────────────────────────────────────────────────────────────┘ +``` + +## A package per tool + +Each tool is a Python package holding exactly two modules: `engine.py` and `screen.py`. +Nothing crosses between tools. Snap Share and Waiver Wire both window a usage series over +three weeks, and they do it twice, in their own code, because the two windows answer +different questions and are free to diverge — Snap Share windows raw snap share keyed on +`pfr_player_id`, Waiver Wire windows a weighted composite keyed on `gsis_id`. Extracting a +shared "trend" helper would couple the two tools' definitions of a trend to each other, and +the next tool that wants a fourth variant pays for the coupling. + +The boundary is what makes the tools cheap to reason about: reading `trade_value/` tells you +everything trade value does. The shared code is deliberately narrow — the view chrome in +`ui/base.py`, the data layer in `data/`, and the two pure helpers in `data/__init__.py` +(`OFFENSIVE_POSITIONS` and `build_id_crosswalk`) that every engine would otherwise restate. + +## Engines are pure + +An engine module imports `polars` and, at most, `ffb.data`. It imports neither +`ffb.data.loader` nor `textual`. Its functions take DataFrames and return a DataFrame. + +Three properties follow. + +**Tests need no network and no terminal.** `tests/conftest.py` builds a synthetic league — +four teams, one player per offensive position, two seasons — plus the play-by-play, roster +and injury frames the same league implies, and the engine tests call the engine directly. A +suite that had to download two seasons of play-by-play would fail for reasons that have +nothing to do with the code under test. + +**Filter changes cost no I/O.** A screen holds the frames one load produced. Changing the +position filter re-runs the engine, or just re-filters its output, against memory. Only the +Refresh button and the first activation reach the network. + +**The data source is replaceable.** Every nflverse quirk — an unpublished weekly-stats +asset, `pfr_id` versus `gsis_id`, snap share arriving as a fraction in one season and as +points in another — is absorbed by the loader or normalized at the top of the engine, not +threaded through the computation. + +An engine that can return nothing declares its result shape as a module-level `OUTPUT_SCHEMA` +and returns a zero-row frame carrying it. A caller filters and indexes the result by column +name; an empty frame with no columns raises on the first column reference, so the guard path +must be shaped like the success path. + +## What ToolView owns, and what a screen owns + +`ToolView` exists so that all six tools answer the same questions the same way. A user who +learns how to cancel a load in one tool has learned it in every tool. + +| `ToolView` (`src/ffb/ui/base.py`) | The screen | +| --- | --- | +| The loading pane, and the display toggle between it and the content pane | `compose_content()` — the filter bar, tabs and tables | +| Running `fetch()` on a thread worker, so a download never blocks the UI | `fetch()` — which loaders to call, and threading `force_refresh` through every one | +| Reporting a raised exception as an error toast | `apply()` — populating filter options and filling tables | +| The elapsed clock, ticking once a second under the load label | `LOAD_LABEL` — what the clock says is downloading | +| Cancel: a button, an `escape` binding, and `check_action` offering the binding only mid-load | Filter events, row formatting, empty-state wording | +| The Refresh button, and forcing a fresh download when it is pressed | `ID_PREFIX` — the namespace every widget id derives from | +| `fill_table()` — rows, or an empty-state message where the table would be | The `DataTable` columns, added in `on_mount` | +| Pairing every `DataTable` with the `{table_id}-empty` label `fill_table` looks for | | + +Two conventions hold this together. + +**Widget ids derive from `ID_PREFIX`.** `ToolView` queries `#{prefix}-loading`, +`#{prefix}-content`, `#{prefix}-load-status`, `#{prefix}-btn-cancel` and +`#{prefix}-btn-refresh` without knowing anything else about the subclass. + +**A screen's `on_mount` does not shadow the base's.** Textual dispatches an event handler +once per class in the MRO that defines it, subclass first. A screen adds its table columns +in `on_mount`; `ToolView.on_mount` then hides both panes and mounts an empty-state label +after each `DataTable` it finds. Neither needs to know about the other. + +`fetch()` returns one payload object, usually a frozen dataclass naming the frames, and +`ToolView` hands that straight to `apply()`. One return value keeps the thread boundary a +single hop: everything crossing it is in that object. + +## The data layer + +Three modules sit under every `fetch()`. + +`data/seasons.py` answers which seasons to ask for. nflverse labels a season by the calendar +year it opens in, so a date resolves to a season only once the month is known. +`current_season()` and `recent_seasons()` both accept an injected date, which is what makes +the arithmetic testable and keeps season lists out of the screens. + +`data/loader.py` is seven loaders over one `_cached(key, fetch, *args, force_refresh)` +helper. `force_refresh` skips the cache read, not the write, which is precisely what the +Refresh button promises. Each loader owns one cache key; a per-season key sorts its seasons +so that any ordering names the same entry. `_download` runs each fetch on its own daemon +thread and gives up after the timeout, so one stalled transfer neither delays a concurrent +download nor eats the next one's budget. A thread cannot be interrupted, so the abandoned +worker runs to completion with nothing left to hand its result to; being a daemon keeps it +from holding the interpreter open at exit. + +`data/cache.py` is a directory of Parquet files with a JSON metadata sidecar. Entries are +written to a temporary file and renamed into place, so a crash leaves one complete file — +the entry being replaced or its replacement — never a mix of the two. Reads past the TTL +count as misses. Writes reap records whose data file is gone and data files no record +claims, then evict least-recently-read entries until the directory fits the size ceiling. +Corruption is self-healing in both directions: unreadable metadata is reset, an unreadable +entry is invalidated and refetched. + +The layering is one-way. Engines know nothing of the loader; the loader knows nothing of the +cache's eviction policy beyond `get` and `put`; the cache knows nothing about seasons. + +## Adding a seventh tool + +Follow the shape rather than a checklist — the shape is what makes the tool feel like the +other six. + +1. **Create `src/ffb//` with `__init__.py`, `engine.py` and `screen.py`.** The package + name is the bounded context. + +2. **Write the engine first, against frames.** Decide what columns the result carries and + name them once. If any input can produce no rows, declare `OUTPUT_SCHEMA` at module level + and return `pl.DataFrame(schema=OUTPUT_SCHEMA)` from the guard path. Import `polars` and, + if the tool needs them, `OFFENSIVE_POSITIONS` and `build_id_crosswalk` from `ffb.data` — + nothing else. + +3. **Test the engine against synthetic frames.** `tests/conftest.py` fixtures snap counts, + weekly stats, player ids, schedules, rosters, injuries and play-by-play, each at the + scale its engines need; extend it rather than reaching for real data. + +4. **Subclass `ToolView` in `screen.py`.** Set `ID_PREFIX` to a short namespace and + `LOAD_LABEL` to what the load downloads. Build the filter bar with + `compose_filter_bar(...)`, which appends the Refresh button; use `position_select()` if + the tool filters by position. Add table columns in `on_mount`. Implement `fetch()` to call + loaders with `force_refresh` passed through every one, and `apply()` to populate filters + and call `fill_table()`. Write the empty-state message for each table: it is the only + thing distinguishing a filter that matched nothing from a load that has not happened. + +5. **Register it in `app.py`.** Add a `(view_id, label)` pair to `TOOLS` and yield the view + inside the `ContentSwitcher` with `id=view_id`. The sidebar entry, the switch and the + first-show activation all key off that id. + +A tool that needs a dataset no loader covers adds one loader to `data/loader.py`: a cache key +and a `_cached` call. Everything else — the timeout, the atomic write, the eviction — comes +with it. diff --git a/pyproject.toml b/pyproject.toml index 45eee04..0c8976e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dev = [ "ruff>=0.8", "mypy>=1.13", "pytest>=9.0.3", + "pytest-cov>=7.1.0", ] [tool.uv] @@ -42,7 +43,40 @@ ignore = ["SIM117"] # nested with statements are idiomatic in Textual compose() [tool.mypy] python_version = "3.12" -strict = false -warn_unused_imports = true -warn_return_any = true -disallow_untyped_defs = false +strict = true +disallow_any_unimported = true +disallow_redefinition_new = true +disallow_untyped_globals = true +local_partial_types = true +strict_bytes = true +warn_unreachable = true +enable_error_code = [ + "deprecated", + "exhaustive-match", + "ignore-without-code", + "narrowed-type-not-subtype", + "possibly-undefined", + "redundant-expr", + "redundant-self", + "truthy-bool", + "truthy-iterable", + "unimported-reveal", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = ["-ra", "--strict-markers", "--strict-config", "--showlocals"] + +[tool.coverage.run] +source = ["src/ffb"] +branch = true + +[tool.coverage.report] +show_missing = true +skip_covered = true +# Floor set from the measured total. `uv run pytest --cov` enforces it; a plain +# `uv run pytest` collects no coverage and so does not. +fail_under = 86 +exclude_also = [ + "raise NotImplementedError", +] diff --git a/src/ffb/app.py b/src/ffb/app.py index 9272b26..d212820 100644 --- a/src/ffb/app.py +++ b/src/ffb/app.py @@ -9,10 +9,11 @@ from ffb.snap_share.screen import SnapShareView from ffb.start_sit.screen import StartSitView from ffb.trade_value.screen import TradeValueView +from ffb.ui.base import ToolView from ffb.waiver_wire.screen import WaiverWireView -class FFBApp(App): +class FFBApp(App[None]): """Main application shell with sidebar navigation.""" TITLE = "FFB" @@ -74,16 +75,14 @@ def compose(self) -> ComposeResult: yield Footer() def on_mount(self) -> None: - self.query_one("#snap-share").activate() + self.query_one("#snap-share", ToolView).activate() def on_list_view_selected(self, event: ListView.Selected) -> None: tool_id = event.item.id if tool_id and tool_id.startswith("nav-"): view_id = tool_id.removeprefix("nav-") self.query_one(ContentSwitcher).current = view_id - view = self.query_one(f"#{view_id}") - if hasattr(view, "activate"): - view.activate() + self.query_one(f"#{view_id}", ToolView).activate() def main() -> None: diff --git a/src/ffb/data/__init__.py b/src/ffb/data/__init__.py index 17c7a9e..a1b2d71 100644 --- a/src/ffb/data/__init__.py +++ b/src/ffb/data/__init__.py @@ -8,7 +8,5 @@ def build_id_crosswalk(player_ids: pl.DataFrame) -> pl.DataFrame: """Build pfr_id → gsis_id lookup from player ID table.""" return ( - player_ids.select("pfr_id", "gsis_id") - .drop_nulls() - .unique(subset=["pfr_id"], keep="last") + player_ids.select("pfr_id", "gsis_id").drop_nulls().unique(subset=["pfr_id"], keep="last") ) diff --git a/src/ffb/data/cache.py b/src/ffb/data/cache.py index 267378e..e0b7a8c 100644 --- a/src/ffb/data/cache.py +++ b/src/ffb/data/cache.py @@ -1,10 +1,13 @@ -"""Local file cache for NFL data with TTL-based freshness.""" +"""Local file cache for NFL data with TTL-based freshness and an LRU size ceiling.""" import json import logging +import os import shutil +import tempfile import time from pathlib import Path +from typing import Any import polars as pl @@ -12,27 +15,117 @@ CACHE_DIR = Path.home() / ".fantasy" / "cache" DEFAULT_TTL = 6 * 3600 # 6 hours +# Play-by-play for a pair of seasons lands around 100 MB, so this holds a working +# set of many season tuples while bounding what the cache can take from the disk. +MAX_CACHE_BYTES = 2 * 1024**3 # 2 GiB + +# Partial writes carry this suffix, which keeps them out of the "*.parquet" glob +# that orphan reaping deletes from. +_TMP_SUFFIX = ".part" def _meta_path() -> Path: return CACHE_DIR / "_meta.json" -def _read_meta() -> dict[str, dict]: +def _entry_path(key: str) -> Path: + return CACHE_DIR / f"{key}.parquet" + + +def _accessed(entry: dict[str, Any]) -> float: + """Last-access time of an entry, falling back to its write time.""" + value = entry.get("accessed", entry.get("timestamp", 0.0)) + return value if isinstance(value, int | float) else 0.0 + + +def _read_meta() -> dict[str, dict[str, Any]]: path = _meta_path() if not path.exists(): return {} try: - return json.loads(path.read_text()) + raw = json.loads(path.read_text()) except (json.JSONDecodeError, OSError) as e: log.warning("corrupt cache metadata, resetting: %s", e) path.unlink(missing_ok=True) return {} + if not isinstance(raw, dict): + log.warning("corrupt cache metadata, resetting: root is not an object") + path.unlink(missing_ok=True) + return {} + return {key: record for key, record in raw.items() if isinstance(record, dict)} + + +def _replace_atomically(data: bytes, dest: Path) -> None: + """Rename a fully written temporary file over dest. + + A rename within one filesystem is atomic, so a crash or a concurrent process + leaves either the whole previous file or the whole new one, never a mix. + """ + dest.parent.mkdir(parents=True, exist_ok=True) + fd, name = tempfile.mkstemp(dir=dest.parent, prefix=".tmp-", suffix=_TMP_SUFFIX) + tmp = Path(name) + try: + with os.fdopen(fd, "wb") as fh: + fh.write(data) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, dest) + finally: + tmp.unlink(missing_ok=True) + + +def _write_parquet_atomically(df: pl.DataFrame, dest: Path) -> None: + """Serialize df to a temporary file and rename it over dest.""" + dest.parent.mkdir(parents=True, exist_ok=True) + fd, name = tempfile.mkstemp(dir=dest.parent, prefix=".tmp-", suffix=_TMP_SUFFIX) + os.close(fd) + tmp = Path(name) + try: + df.write_parquet(tmp) + os.replace(tmp, dest) + finally: + tmp.unlink(missing_ok=True) -def _write_meta(meta: dict[str, dict]) -> None: - CACHE_DIR.mkdir(parents=True, exist_ok=True) - _meta_path().write_text(json.dumps(meta, indent=2)) +def _write_meta(meta: dict[str, dict[str, Any]]) -> None: + _replace_atomically(json.dumps(meta, indent=2).encode(), _meta_path()) + + +def _reap_orphans(meta: dict[str, dict[str, Any]]) -> None: + """Drop records whose parquet is gone and parquet files that no record claims. + + Mutates meta in place; the caller persists it. + """ + for key in [key for key in meta if not _entry_path(key).exists()]: + log.debug("reaping cache record with no data file: %s", key) + del meta[key] + if not CACHE_DIR.exists(): + return + for path in CACHE_DIR.glob("*.parquet"): + if path.stem not in meta: + log.debug("reaping cache data file with no record: %s", path.name) + path.unlink(missing_ok=True) + + +def _evict_lru(meta: dict[str, dict[str, Any]]) -> None: + """Delete least-recently-accessed entries until the cache fits MAX_CACHE_BYTES. + + Mutates meta in place; the caller persists it. + """ + sizes: dict[str, int] = {} + for key in meta: + try: + sizes[key] = _entry_path(key).stat().st_size + except OSError: + sizes[key] = 0 + total = sum(sizes.values()) + for key in sorted(meta, key=lambda k: _accessed(meta[k])): + if total <= MAX_CACHE_BYTES: + return + log.debug("evicting cache entry '%s' (%d bytes)", key, sizes[key]) + _entry_path(key).unlink(missing_ok=True) + del meta[key] + total -= sizes[key] def get(key: str, ttl: int = DEFAULT_TTL) -> pl.DataFrame | None: @@ -43,24 +136,32 @@ def get(key: str, ttl: int = DEFAULT_TTL) -> pl.DataFrame | None: return None if time.time() - entry.get("timestamp", 0) > ttl: return None - path = CACHE_DIR / f"{key}.parquet" + path = _entry_path(key) if not path.exists(): return None try: - return pl.read_parquet(path) + df = pl.read_parquet(path) except Exception as e: log.warning("corrupt cache entry '%s', invalidating: %s", key, e) invalidate(key) return None + entry["accessed"] = time.time() + try: + _write_meta(meta) + except OSError as e: + # An unwritable cache directory costs eviction ordering accuracy, not the read. + log.debug("access time for '%s' not recorded: %s", key, e) + return df def put(key: str, df: pl.DataFrame) -> None: - """Write DataFrame to cache.""" - CACHE_DIR.mkdir(parents=True, exist_ok=True) - path = CACHE_DIR / f"{key}.parquet" - df.write_parquet(path) + """Write DataFrame to cache, reaping orphans and evicting down to the ceiling.""" + _write_parquet_atomically(df, _entry_path(key)) meta = _read_meta() - meta[key] = {"timestamp": time.time()} + written = time.time() + meta[key] = {"timestamp": written, "accessed": written} + _reap_orphans(meta) + _evict_lru(meta) _write_meta(meta) @@ -71,8 +172,6 @@ def invalidate(key: str | None = None) -> None: shutil.rmtree(CACHE_DIR) return meta = _read_meta() - meta.pop(key, None) - _write_meta(meta) - path = CACHE_DIR / f"{key}.parquet" - if path.exists(): - path.unlink() + if meta.pop(key, None) is not None: + _write_meta(meta) + _entry_path(key).unlink(missing_ok=True) diff --git a/src/ffb/data/loader.py b/src/ffb/data/loader.py index 2140312..a79d6fd 100644 --- a/src/ffb/data/loader.py +++ b/src/ffb/data/loader.py @@ -1,8 +1,9 @@ """Data loaders wrapping nfl_data_py with local caching.""" import logging -from concurrent.futures import ThreadPoolExecutor -from concurrent.futures import TimeoutError as FuturesTimeout +import threading +from collections.abc import Callable +from typing import Any import polars as pl @@ -11,154 +12,196 @@ log = logging.getLogger(__name__) _DOWNLOAD_TIMEOUT = 120 # seconds -_executor = ThreadPoolExecutor(max_workers=1) -def _fetch_with_timeout(fn, *args, timeout: int = _DOWNLOAD_TIMEOUT): - """Run a blocking download with a timeout.""" - future = _executor.submit(fn, *args) - try: - return future.result(timeout=timeout) - except FuturesTimeout: - raise TimeoutError( - f"Download timed out after {timeout}s. Check your network connection." - ) from None +def _download( + fetch: Callable[..., Any], + *args: Any, + timeout: int = _DOWNLOAD_TIMEOUT, +) -> Any: + """Run a blocking download in a worker thread, giving up after `timeout` seconds. + + Each call owns its worker, so one download spends only its own timeout budget: + a caller waiting on a stalled transfer neither delays a concurrent download nor + eats into the deadline of the next one. Giving up cannot stop a socket read that + is already under way, so the abandoned worker runs to completion with nothing + left to hand its result to; it is a daemon, which keeps a stalled transfer from + holding the interpreter open at exit. + """ + outcome: dict[str, Any] = {} + + def run() -> None: + # The caller sees whatever the fetch raised: holding it here and re-raising it + # on the calling thread keeps the traceback out of the thread excepthook. + try: + outcome["value"] = fetch(*args) + except BaseException as e: + outcome["error"] = e + worker = threading.Thread(target=run, name="ffb-download", daemon=True) + worker.start() + worker.join(timeout) -def load_snap_counts( - seasons: list[int], - force_refresh: bool = False, -) -> pl.DataFrame: - """Load snap count data for given seasons, with caching.""" - key = f"snap_counts_{'_'.join(str(s) for s in sorted(seasons))}" + if worker.is_alive(): + raise TimeoutError(f"Download timed out after {timeout}s. Check your network connection.") + if "error" in outcome: + raise outcome["error"] + return outcome["value"] - if not force_refresh: - cached = cache.get(key) - if cached is not None: - return cached - import nfl_data_py as nfl +def _nflverse(importer: str, *args: Any) -> pl.DataFrame: + """Fetch a frame from the named nfl_data_py importer, under the download timeout. - pdf = _fetch_with_timeout(nfl.import_snap_counts, seasons) - df = pl.from_pandas(pdf) + 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] - cache.put(key, df) - return df + pdf = _download(getattr(nfl, importer), *args) + return pl.from_pandas(pdf) -def load_pbp( - seasons: list[int], - force_refresh: bool = False, +def _season_key(prefix: str, seasons: list[int]) -> str: + """Cache key for a per-season dataset, identical for any ordering of `seasons`.""" + return f"{prefix}_{'_'.join(str(s) for s in sorted(seasons))}" + + +def _cached( + key: str, + fetch: Callable[..., pl.DataFrame], + *args: Any, + force_refresh: bool, ) -> pl.DataFrame: - """Load play-by-play data for given seasons, with caching.""" - key = f"pbp_{'_'.join(str(s) for s in sorted(seasons))}" + """Return the frame stored under `key`, fetching and storing it on a miss. + `force_refresh` skips the cache read, not the write: the fetched frame replaces + whatever the key held. + """ if not force_refresh: cached = cache.get(key) if cached is not None: return cached - import nfl_data_py as nfl - - pdf = _fetch_with_timeout(nfl.import_pbp_data, seasons) - df = pl.from_pandas(pdf) - + df = fetch(*args) cache.put(key, df) return df -def load_rosters( +def load_snap_counts( seasons: list[int], force_refresh: bool = False, ) -> pl.DataFrame: - """Load seasonal roster data for given seasons, with caching.""" - key = f"rosters_{'_'.join(str(s) for s in sorted(seasons))}" - - if not force_refresh: - cached = cache.get(key) - if cached is not None: - return cached - - import nfl_data_py as nfl - - pdf = _fetch_with_timeout(nfl.import_seasonal_rosters, seasons) - df = pl.from_pandas(pdf) - - cache.put(key, df) - return df + """Load snap count data for given seasons, with caching.""" + return _cached( + _season_key("snap_counts", seasons), + _nflverse, + "import_snap_counts", + seasons, + force_refresh=force_refresh, + ) -def load_weekly_stats( +def load_pbp( seasons: list[int], force_refresh: bool = False, ) -> pl.DataFrame: - """Load weekly player stats for given seasons, with caching.""" - key = f"weekly_stats_{'_'.join(str(s) for s in sorted(seasons))}" - - if not force_refresh: - cached = cache.get(key) - if cached is not None: - return cached + """Load play-by-play data for given seasons, with caching.""" + return _cached( + _season_key("pbp", seasons), + _nflverse, + "import_pbp_data", + seasons, + force_refresh=force_refresh, + ) - import nfl_data_py as nfl +def load_rosters( + seasons: list[int], + force_refresh: bool = False, +) -> pl.DataFrame: + """Load seasonal roster data for given seasons, with caching.""" + return _cached( + _season_key("rosters", seasons), + _nflverse, + "import_seasonal_rosters", + seasons, + force_refresh=force_refresh, + ) + + +def _fetch_weekly_stats(seasons: list[int]) -> pl.DataFrame: + """Weekly player stats per season, derived from play-by-play where nflverse has none. + + nflverse publishes player_stats as one release asset per season, so a season in + progress can have no asset at all while its play-by-play updates every week. + Play-by-play carries what the weekly columns are built from, so a season missing + the asset is reconstructed rather than dropped. + """ from ffb.data.stats import compute_weekly_stats_from_pbp - # Load each season: try nflverse weekly data first, fall back to PBP derivation frames: list[pl.DataFrame] = [] pbp_fallback_seasons: list[int] = [] for season in seasons: try: - pdf = _fetch_with_timeout(nfl.import_weekly_data, [season]) - frames.append(pl.from_pandas(pdf)) - except Exception: - log.info("weekly data unavailable for %d, will derive from PBP", season) + frames.append(_nflverse("import_weekly_data", [season])) + except TimeoutError: + # A transfer that stalls carries no information about what nflverse holds. + raise + except OSError as e: + # An unpublished season has no release asset, so the request 404s. + log.info("weekly data unavailable for %d, deriving from PBP: %s", season, e) pbp_fallback_seasons.append(season) - # Derive stats from PBP for seasons missing weekly data if pbp_fallback_seasons: log.info("deriving weekly stats from PBP for seasons %s", pbp_fallback_seasons) - pbp_pdf = _fetch_with_timeout(nfl.import_pbp_data, pbp_fallback_seasons) - pbp_df = pl.from_pandas(pbp_pdf) + pbp_df = _nflverse("import_pbp_data", pbp_fallback_seasons) + roster_df: pl.DataFrame | None try: - roster_pdf = _fetch_with_timeout(nfl.import_seasonal_rosters, pbp_fallback_seasons) - roster_df = pl.from_pandas(roster_pdf) - except Exception: - log.warning("roster data unavailable for PBP fallback seasons, positions will be null") + roster_df = _nflverse("import_seasonal_rosters", pbp_fallback_seasons) + except TimeoutError: + # A stalled transfer is not a missing roster asset. + raise + except OSError as e: + log.warning( + "roster data unavailable for seasons %s, positions will be null: %s", + pbp_fallback_seasons, + e, + ) roster_df = None frames.append(compute_weekly_stats_from_pbp(pbp_df, roster_df)) if not frames: - raise RuntimeError( - f"No weekly stats available for seasons {seasons} " - "(nflverse and PBP fallback both failed)" - ) + # Every requested season lands in either the published set or the PBP + # fallback, so an empty frame list means the request named no seasons. + raise RuntimeError(f"No weekly stats available: no seasons requested (got {seasons})") - df = pl.concat(frames, how="diagonal_relaxed") + return pl.concat(frames, how="diagonal_relaxed") - cache.put(key, df) - return df + +def load_weekly_stats( + seasons: list[int], + force_refresh: bool = False, +) -> pl.DataFrame: + """Load weekly player stats for given seasons, with caching.""" + return _cached( + _season_key("weekly_stats", seasons), + _fetch_weekly_stats, + seasons, + force_refresh=force_refresh, + ) def load_player_ids( force_refresh: bool = False, ) -> pl.DataFrame: """Load player ID crosswalk table (pfr_id <-> gsis_id), with caching.""" - key = "player_ids" - - if not force_refresh: - cached = cache.get(key) - if cached is not None: - return cached - - import nfl_data_py as nfl - - pdf = _fetch_with_timeout(nfl.import_ids) - df = pl.from_pandas(pdf) - - cache.put(key, df) - return df + return _cached( + "player_ids", + _nflverse, + "import_ids", + force_refresh=force_refresh, + ) def load_schedules( @@ -166,20 +209,13 @@ def load_schedules( force_refresh: bool = False, ) -> pl.DataFrame: """Load schedule data for given seasons, with caching.""" - key = f"schedules_{'_'.join(str(s) for s in sorted(seasons))}" - - if not force_refresh: - cached = cache.get(key) - if cached is not None: - return cached - - import nfl_data_py as nfl - - pdf = _fetch_with_timeout(nfl.import_schedules, seasons) - df = pl.from_pandas(pdf) - - cache.put(key, df) - return df + return _cached( + _season_key("schedules", seasons), + _nflverse, + "import_schedules", + seasons, + force_refresh=force_refresh, + ) def load_injuries( @@ -187,17 +223,10 @@ def load_injuries( force_refresh: bool = False, ) -> pl.DataFrame: """Load injury report data for given seasons, with caching.""" - key = f"injuries_{'_'.join(str(s) for s in sorted(seasons))}" - - if not force_refresh: - cached = cache.get(key) - if cached is not None: - return cached - - import nfl_data_py as nfl - - pdf = _fetch_with_timeout(nfl.import_injuries, seasons) - df = pl.from_pandas(pdf) - - cache.put(key, df) - return df + return _cached( + _season_key("injuries", seasons), + _nflverse, + "import_injuries", + seasons, + force_refresh=force_refresh, + ) diff --git a/src/ffb/data/seasons.py b/src/ffb/data/seasons.py new file mode 100644 index 0000000..712e764 --- /dev/null +++ b/src/ffb/data/seasons.py @@ -0,0 +1,43 @@ +"""Season arithmetic for the nflverse season label. + +nflverse labels a season by the calendar year it opens in: season 2025 runs from +September 2025 through the playoffs in February 2026. A date therefore names a +season only once the month is known, which is what this module resolves. +""" + +from datetime import date + +# Week 1 falls in September, so a date earlier in the year belongs to the season +# labelled with the previous year. +SEASON_START_MONTH = 9 + + +def current_season(today: date | None = None) -> int: + """Return the label of the most recent season to have played games. + + Parameters + ---------- + today : date, optional + Date to resolve against. Defaults to the system date. + """ + day = date.today() if today is None else today + return day.year if day.month >= SEASON_START_MONTH else day.year - 1 + + +def recent_seasons(count: int = 2, today: date | None = None) -> list[int]: + """Return the `count` most recent season labels, oldest first. + + The list ends at `current_season`, so a loader given these seasons asks nflverse + only for data it has begun publishing. + + Parameters + ---------- + count : int + How many seasons to return. Must be at least 1. + today : date, optional + Date to resolve against. Defaults to the system date. + """ + if count < 1: + raise ValueError(f"count must be at least 1, got {count}") + latest = current_season(today) + return list(range(latest - count + 1, latest + 1)) diff --git a/src/ffb/data/stats.py b/src/ffb/data/stats.py index d2d94e9..0fea5e9 100644 --- a/src/ffb/data/stats.py +++ b/src/ffb/data/stats.py @@ -1,7 +1,8 @@ """Derive weekly player stats from play-by-play data. -Fallback for seasons where nflverse hasn't published the player_stats -parquet file yet. Produces the same columns the injury_impact engine needs. +Stands in for a season nflverse publishes no player_stats asset for, carrying the +columns the tools read off that asset: identity, team, opponent, season type, the +counting stats, and the PPR total derived from them. """ import polars as pl @@ -13,27 +14,29 @@ def compute_weekly_stats_from_pbp( ) -> pl.DataFrame: """Aggregate PBP into per-player weekly fantasy stats. + One row per player, team, season, week and season type. + Parameters ---------- pbp : pl.DataFrame Play-by-play data. rosters : pl.DataFrame, optional - Seasonal roster data for position lookup. If None, position will be null. + Seasonal roster data for position lookup. When None, the frame carries the + position column with every value null. """ # Filter to actual plays - plays = pbp.filter( - pl.col("play_type").is_in(["pass", "run"]) - ) + plays = pbp.filter(pl.col("play_type").is_in(["pass", "run"])) # ── Receiving stats ────────────────────────────────────── rec = ( - plays.filter( - (pl.col("pass_attempt") == 1) - & pl.col("receiver_player_id").is_not_null() - ) + 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", + "receiver_player_id", + "receiver_player_name", + "posteam", + "season", + "week", + "season_type", ) .agg( pl.len().alias("targets"), @@ -41,73 +44,86 @@ def compute_weekly_stats_from_pbp( 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": "player_display_name", - "posteam": "recent_team", - }) + .rename( + { + "receiver_player_id": "player_id", + "receiver_player_name": "name_from_receiving", + "posteam": "recent_team", + } + ) ) # ── Rushing stats ──────────────────────────────────────── rush = ( - plays.filter( - (pl.col("rush_attempt") == 1) - & pl.col("rusher_player_id").is_not_null() - ) + 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", + "rusher_player_id", + "rusher_player_name", + "posteam", + "season", + "week", + "season_type", ) .agg( 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": "player_display_name", - "posteam": "recent_team", - }) + .rename( + { + "rusher_player_id": "player_id", + "rusher_player_name": "name_from_rushing", + "posteam": "recent_team", + } + ) ) # ── Passing stats ──────────────────────────────────────── passing = ( - plays.filter( - (pl.col("pass_attempt") == 1) - & pl.col("passer_player_id").is_not_null() - ) + 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", + "passer_player_id", + "passer_player_name", + "posteam", + "season", + "week", + "season_type", ) .agg( 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": "player_display_name", - "posteam": "recent_team", - }) + .rename( + { + "passer_player_id": "player_id", + "passer_player_name": "name_from_passing", + "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. fumbles = ( - plays.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()) .group_by( - "fumbled_1_player_id", "fumbled_1_player_name", - "posteam", "season", "week", "season_type", + "fumbled_1_player_id", + "fumbled_1_player_name", + "posteam", + "season", + "week", + "season_type", ) .agg(pl.col("fumble_lost").sum().alias("fumbles_lost")) - .rename({ - "fumbled_1_player_id": "player_id", - "fumbled_1_player_name": "player_display_name", - "posteam": "recent_team", - }) + .rename( + { + "fumbled_1_player_id": "player_id", + "fumbled_1_player_name": "name_from_fumbles", + "posteam": "recent_team", + } + ) ) # ── Opponent mapping (one defteam per team per game) ──── @@ -119,17 +135,36 @@ def compute_weekly_stats_from_pbp( ) # ── Merge all stat lines ───────────────────────────────── - join_keys = ["player_id", "player_display_name", "recent_team", "season", "week", "season_type"] + # Identity is the player id: nflverse fills the four role name columns from + # separate source fields, so one id can carry different spellings across + # roles in a single week. Joining on the name as well would emit one partial + # row per spelling; the display name is coalesced across roles instead. + join_keys = ["player_id", "recent_team", "season", "week", "season_type"] + name_cols = [ + "name_from_receiving", + "name_from_rushing", + "name_from_passing", + "name_from_fumbles", + ] df = rec.join(rush, on=join_keys, how="full", coalesce=True) df = df.join(passing, on=join_keys, how="full", coalesce=True) df = df.join(fumbles, on=join_keys, how="full", coalesce=True) + df = df.with_columns(pl.coalesce(*name_cols).alias("player_display_name")).drop(name_cols) # Fill nulls for numeric columns stat_cols = [ - "targets", "receptions", "receiving_yards", "receiving_tds", - "carries", "rushing_yards", "rushing_tds", - "passing_yards", "passing_tds", "interceptions", "fumbles_lost", + "targets", + "receptions", + "receiving_yards", + "receiving_tds", + "carries", + "rushing_yards", + "rushing_tds", + "passing_yards", + "passing_tds", + "interceptions", + "fumbles_lost", ] df = df.with_columns([pl.col(c).fill_null(0).cast(pl.Float32) for c in stat_cols]) @@ -151,7 +186,10 @@ def compute_weekly_stats_from_pbp( # Add opponent_team df = df.join(opponent_map, on=["recent_team", "season", "week"], how="left") - # Add position from rosters + # ── Position ───────────────────────────────────────────── + # The column belongs to the frame's shape whether or not rosters resolve: the + # engines filter on position, and a null value filters to nothing where an + # absent column raises. if rosters is not None: pos_map = ( rosters.select("player_id", "position") @@ -159,5 +197,7 @@ def compute_weekly_stats_from_pbp( .unique(subset=["player_id"], keep="last") ) df = df.join(pos_map, on="player_id", how="left") + else: + df = df.with_columns(pl.lit(None, dtype=pl.String).alias("position")) return df.drop_nulls(subset=["player_id"]) diff --git a/src/ffb/injury_impact/engine.py b/src/ffb/injury_impact/engine.py index 64791a0..46a4a25 100644 --- a/src/ffb/injury_impact/engine.py +++ b/src/ffb/injury_impact/engine.py @@ -4,18 +4,27 @@ is active vs inactive. """ +from typing import TypedDict + import polars as pl from ffb.data import OFFENSIVE_POSITIONS, build_id_crosswalk +class PlayerInfo(TypedDict): + """The searched player, described by one team-season of absences.""" + + name: str + position: str + team: str + season: int + games_missed: int + + def get_searchable_players(snaps: pl.DataFrame) -> list[str]: """Return sorted unique player names for search autocomplete.""" return ( - snaps.filter( - pl.col("position").is_in(OFFENSIVE_POSITIONS) - & (pl.col("game_type") == "REG") - ) + snaps.filter(pl.col("position").is_in(OFFENSIVE_POSITIONS) & (pl.col("game_type") == "REG")) .select("player") .unique() .sort("player") @@ -30,9 +39,13 @@ def compute_injury_impact( weekly_stats: pl.DataFrame, player_ids: pl.DataFrame, min_games_missed: int = 2, -) -> tuple[dict | None, pl.DataFrame]: +) -> tuple[PlayerInfo | None, pl.DataFrame]: """Compute teammate fantasy impact when a player misses games. + A teammate's name and position come from their last week of snaps with the team; + two listings inside that week resolve on the position label, then the name, in + ascending order. + Returns (player_info, teammates_df). player_info is None if the player isn't found or has insufficient data. """ @@ -40,8 +53,7 @@ def compute_injury_impact( # Filter to regular season offensive snaps reg_snaps = snaps.filter( - (pl.col("game_type") == "REG") - & pl.col("position").is_in(OFFENSIVE_POSITIONS) + (pl.col("game_type") == "REG") & pl.col("position").is_in(OFFENSIVE_POSITIONS) ) # Find the target player @@ -49,13 +61,19 @@ def compute_injury_impact( if player_rows.is_empty(): return None, pl.DataFrame() - # Get player's team-seasons + # Get player's team-seasons. `unique` does not preserve input order, so + # sort explicitly to keep the concatenated result order reproducible. player_team_seasons = ( player_rows.select("pfr_player_id", "position", "team", "season") .unique() + .sort( + ["season", "team", "position", "pfr_player_id"], + descending=[True, False, False, False], + ) ) all_results = [] + info_candidates: list[PlayerInfo] = [] for row in player_team_seasons.iter_rows(named=True): pfr_id = row["pfr_player_id"] @@ -65,9 +83,7 @@ def compute_injury_impact( # All weeks this team played team_weeks = ( - reg_snaps.filter( - (pl.col("team") == team) & (pl.col("season") == season) - ) + reg_snaps.filter((pl.col("team") == team) & (pl.col("season") == season)) .select("week") .unique() .to_series() @@ -84,20 +100,24 @@ def compute_injury_impact( .to_series() ) - inactive_weeks = team_weeks.filter(~team_weeks.is_in(active_weeks)) + inactive_weeks = team_weeks.filter(~team_weeks.is_in(active_weeks.implode())) if len(inactive_weeks) < min_games_missed: continue - # Get all teammates on this team-season (excluding the target player) + # Every teammate on this team-season, the target player aside. The sort + # keys order the rows of a teammate completely, so the one kept — and the + # name and position it carries — follows from the data, not from row order. teammates = ( reg_snaps.filter( (pl.col("team") == team) & (pl.col("season") == season) & (pl.col("pfr_player_id") != pfr_id) ) - .select("pfr_player_id", "player", "position") - .unique(subset=["pfr_player_id"]) + .select("pfr_player_id", "player", "position", "week") + .sort(["week", "position", "player"], descending=[True, False, False]) + .unique(subset=["pfr_player_id"], keep="first", maintain_order=True) + .drop("week") ) # Map teammate pfr_id → gsis_id @@ -111,41 +131,34 @@ def compute_injury_impact( # Get weekly stats for these teammates in this season teammate_gsis_ids = teammates_with_gsis.select("gsis_id").to_series() tm_stats = weekly_stats.filter( - (pl.col("player_id").is_in(teammate_gsis_ids)) + (pl.col("player_id").is_in(teammate_gsis_ids.implode())) & (pl.col("season") == season) & (pl.col("season_type") == "REG") - ).select( - "player_id", "week", "fantasy_points_ppr", "targets", "carries" - ) + ).select("player_id", "week", "fantasy_points_ppr", "targets", "carries") if tm_stats.is_empty(): continue # Tag each week as active or inactive tm_stats = tm_stats.with_columns( - pl.col("week").is_in(active_weeks).alias("player_active") + pl.col("week").is_in(active_weeks.implode()).alias("player_active") ).with_columns( (pl.col("targets").fill_null(0) + pl.col("carries").fill_null(0)).alias("touches") ) # Aggregate: avg stats per teammate per split - agg = ( - tm_stats.group_by("player_id", "player_active") - .agg( - pl.len().alias("games"), - pl.col("fantasy_points_ppr").mean().alias("avg_fpts"), - pl.col("targets").mean().alias("avg_tgt"), - pl.col("touches").mean().alias("avg_touches"), - ) + agg = tm_stats.group_by("player_id", "player_active").agg( + pl.len().alias("games"), + pl.col("fantasy_points_ppr").mean().alias("avg_fpts"), + pl.col("targets").mean().alias("avg_tgt"), + pl.col("touches").mean().alias("avg_touches"), ) # Pivot active/inactive into separate columns with_stats = agg.filter(pl.col("player_active")).drop("player_active") without_stats = agg.filter(~pl.col("player_active")).drop("player_active") - merged = with_stats.join( - without_stats, on="player_id", how="inner", suffix="_wo" - ) + merged = with_stats.join(without_stats, on="player_id", how="inner", suffix="_wo") if merged.is_empty(): continue @@ -196,18 +209,24 @@ def compute_injury_impact( all_results.append(result) - # Use the most recent team-season for player_info - player_info = { - "name": player_name, - "position": position, - "team": team, - "season": season, - "games_missed": len(inactive_weeks), - } + info_candidates.append( + { + "name": player_name, + "position": position, + "team": team, + "season": season, + "games_missed": len(inactive_weeks), + } + ) if not all_results: return None, pl.DataFrame() + # Describe the player by the most recent team-season that produced + # teammate splits; equal seasons break on team name so the same input + # always yields the same description. + player_info = min(info_candidates, key=lambda c: (-c["season"], c["team"])) + combined = pl.concat(all_results) combined = combined.sort(pl.col("delta_fpts").abs(), descending=True) diff --git a/src/ffb/injury_impact/screen.py b/src/ffb/injury_impact/screen.py index 6e798fc..c99e02e 100644 --- a/src/ffb/injury_impact/screen.py +++ b/src/ffb/injury_impact/screen.py @@ -2,72 +2,88 @@ from __future__ import annotations +from dataclasses import dataclass +from typing import Any + import polars as pl -from textual import work from textual.app import ComposeResult -from textual.containers import Horizontal, Vertical from textual.suggester import SuggestFromList -from textual.widget import Widget -from textual.widgets import ( - Button, - DataTable, - Input, - LoadingIndicator, - Select, - Static, -) +from textual.widgets import DataTable, Input, Select, Static from ffb.data.loader import load_player_ids, load_snap_counts, load_weekly_stats -from ffb.injury_impact.engine import compute_injury_impact, get_searchable_players +from ffb.data.seasons import recent_seasons +from ffb.injury_impact.engine import PlayerInfo, compute_injury_impact, get_searchable_players +from ffb.ui.base import ALL, ToolView COLUMNS = ( - "Teammate", "Pos", "Tm", "Szn", - "Gm W/", "Gm W/O", - "FPts W/", "FPts W/O", "Δ FPts", - "Tgt W/", "Tgt W/O", - "Tch W/", "Tch W/O", + "Teammate", + "Pos", + "Tm", + "Szn", + "Gm W/", + "Gm W/O", + "FPts W/", + "FPts W/O", + "Δ FPts", + "Tgt W/", + "Tgt W/O", + "Tch W/", + "Tch W/O", "Conf", ) +#: How many seasons the splits draw on. A star misses games rarely, so a window +#: shorter than this leaves most searches with no absence to compare against. +SEASON_WINDOW = 3 -class InjuryImpactView(Widget): - """Teammate usage shifts when stars miss games.""" +#: Stands in for the table until a search runs. +PROMPT_MESSAGE = "Search a player to see how their teammates' usage shifted while they sat out." - DEFAULT_CSS = """ - InjuryImpactView { - height: 1fr; - width: 1fr; - } - #ii-loading { - height: 1fr; - } +@dataclass(frozen=True) +class BaseData: + """The frames every search runs against, plus the names the search suggests.""" - #ii-content { - height: 1fr; - } + snaps: pl.DataFrame + weekly_stats: pl.DataFrame + player_ids: pl.DataFrame + players: list[str] - .filter-bar { - height: auto; - max-height: 5; - padding: 1; - background: $surface-darken-1; - } - .filter-bar Input { - width: 2fr; - margin-right: 1; - } +def _fixed(value: float | None, *, signed: bool = False) -> str: + """Format a per-game average to one decimal, or mark it absent.""" + if value is None: + return "—" + return f"{value:+.1f}" if signed else f"{value:.1f}" - .filter-bar Select { - width: 1fr; - margin-right: 1; - } - .filter-bar #ii-btn-refresh { - margin-left: 1; - } +def _row_cells(row: dict[str, Any]) -> tuple[str, ...]: + """Map one teammate split to its table cells, in `COLUMNS` order.""" + return ( + row["teammate"] or "—", + row["position"] or "—", + row["team"], + str(row["season"]), + str(row["games_with"]), + str(row["games_without"]), + _fixed(row["fpts_with"]), + _fixed(row["fpts_without"]), + _fixed(row["delta_fpts"], signed=True), + _fixed(row["tgt_with"]), + _fixed(row["tgt_without"]), + _fixed(row["touches_with"]), + _fixed(row["touches_without"]), + row["confidence"], + ) + + +class InjuryImpactView(ToolView): + """Teammate usage shifts when stars miss games.""" + + ID_PREFIX = "ii" + LOAD_LABEL = "Downloading snap counts, weekly stats and player IDs" + DEFAULT_CSS = """ #ii-player-info { height: auto; padding: 0 1; @@ -76,129 +92,116 @@ class InjuryImpactView(Widget): } """ - def __init__(self, **kwargs) -> None: + def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) - self._snaps: pl.DataFrame | None = None - self._weekly_stats: pl.DataFrame | None = None - self._player_ids: pl.DataFrame | None = None - self._activated = False - self._initializing = True - - def compose(self) -> ComposeResult: - yield LoadingIndicator(id="ii-loading") - with Vertical(id="ii-content"): - with Horizontal(classes="filter-bar"): - yield Input( - placeholder="Search player...", - id="ii-search", - ) - yield Select[str]([], prompt="Season", id="ii-filter-season") - yield Button("Refresh", id="ii-btn-refresh", variant="primary") - yield Static("", id="ii-player-info") - yield DataTable(id="ii-table") + self._data: BaseData | None = None + + def compose_content(self) -> ComposeResult: + yield from self.compose_filter_bar( + Input(placeholder="Search player...", id="ii-search"), + Select[str]([], prompt="Season", id="ii-filter-season"), + ) + yield Static("", id="ii-player-info") + yield DataTable(id="ii-table") def on_mount(self) -> None: - self.query_one("#ii-content").display = False table = self.query_one("#ii-table", DataTable) table.add_columns(*COLUMNS) table.cursor_type = "row" - def activate(self) -> None: - """Load base data on first activation.""" - if not self._activated: - self._activated = True - self._fetch_base_data() - - @work(thread=True, exclusive=True) - def _fetch_base_data(self, force_refresh: bool = False) -> None: - try: - snaps = load_snap_counts([2023, 2024, 2025], force_refresh=force_refresh) - weekly = load_weekly_stats([2023, 2024, 2025], force_refresh=force_refresh) - ids = load_player_ids(force_refresh=force_refresh) - players = get_searchable_players(snaps) - self.app.call_from_thread( - self._on_base_data_loaded, snaps, weekly, ids, players - ) - except Exception as e: - self.app.call_from_thread(self._on_data_error, str(e)) + # ── load ───────────────────────────────────────────────── - def _on_base_data_loaded( - self, - snaps: pl.DataFrame, - weekly: pl.DataFrame, - ids: pl.DataFrame, - players: list[str], - ) -> None: - self._snaps = snaps - self._weekly_stats = weekly - self._player_ids = ids + def fetch(self, force_refresh: bool) -> BaseData: + seasons = recent_seasons(SEASON_WINDOW) + snaps = load_snap_counts(seasons, force_refresh=force_refresh) + weekly = load_weekly_stats(seasons, force_refresh=force_refresh) + ids = load_player_ids(force_refresh=force_refresh) + return BaseData(snaps, weekly, ids, get_searchable_players(snaps)) + + def apply(self, payload: BaseData) -> None: + self._data = payload - # Set up search autocomplete search = self.query_one("#ii-search", Input) - search.suggester = SuggestFromList(players, case_sensitive=False) + search.suggester = SuggestFromList(payload.players, case_sensitive=False) - # Populate season filter - self._initializing = True - reg_snaps = snaps.filter(snaps["game_type"] == "REG") + reg_snaps = payload.snaps.filter(pl.col("game_type") == "REG") seasons = sorted(reg_snaps["season"].unique().drop_nulls().to_list()) season_select = self.query_one("#ii-filter-season", Select) - season_select.set_options([("All", "All")] + [(str(s), str(s)) for s in seasons]) - season_select.value = "All" - self._initializing = False - - self.query_one("#ii-loading").display = False - self.query_one("#ii-content").display = True + season_select.set_options(self.all_options(str(season) for season in seasons)) + season_select.value = ALL - def _on_data_error(self, error: str) -> None: - self.query_one("#ii-loading").display = False - self.notify(f"Failed to load data: {error}", severity="error", timeout=10) + self.query_one("#ii-player-info", Static).update("") + self.fill_table("ii-table", pl.DataFrame(), _row_cells, PROMPT_MESSAGE) # ── search ─────────────────────────────────────────────── def on_input_submitted(self, event: Input.Submitted) -> None: - if event.input.id == "ii-search" and event.value.strip(): - self._run_search(event.value.strip()) - - @work(thread=True, exclusive=True) - def _run_search(self, player_name: str) -> None: - if self._snaps is None or self._weekly_stats is None or self._player_ids is None: + if event.input.id != "ii-search": return - - snaps = self._snaps - weekly = self._weekly_stats - - # Apply season filter - season_val = self.app.call_from_thread(self._get_season_filter) - if season_val and season_val != "All": - season_int = int(season_val) - snaps = snaps.filter(pl.col("season") == season_int) - weekly = weekly.filter(pl.col("season") == season_int) - - info, df = compute_injury_impact( - player_name, snaps, weekly, self._player_ids + player_name = event.value.strip() + if player_name: + self._search(player_name) + + def _search(self, player_name: str) -> None: + """Start a search, or say why the data it needs is not there. + + The tool is reachable with no data after a cancelled or failed load, so the + absence gets an answer rather than a search that does nothing. + """ + data = self._data + if data is None: + self.notify( + "No data loaded. Press Refresh to download snap counts, " + "weekly stats and player IDs.", + severity="warning", + timeout=5, + ) + return + season = self.narrowing("ii-filter-season") + # A search started while an earlier one runs renders its own result: the shared + # worker drops the result of the search it supersedes, so the table answers the + # term in the search box. + self.run_off_thread( + lambda: self._splits(player_name, data, season), + lambda result: self._on_search_complete(player_name, *result), + self._on_search_error, + group="ii-search", ) - self.app.call_from_thread(self._on_search_complete, player_name, info, df) - def _get_season_filter(self) -> str: - val = self.query_one("#ii-filter-season", Select).value - return str(val) if val is not Select.BLANK else "All" + def _splits( + self, + player_name: str, + data: BaseData, + season: str | None, + ) -> tuple[PlayerInfo | None, pl.DataFrame]: + """Compute one player's teammate splits, off the UI thread. + + `season` narrows both frames to one season; None reads every season loaded. + """ + snaps = data.snaps + weekly = data.weekly_stats + if season is not None: + snaps = snaps.filter(pl.col("season") == int(season)) + weekly = weekly.filter(pl.col("season") == int(season)) + return compute_injury_impact(player_name, snaps, weekly, data.player_ids) def _on_search_complete( self, player_name: str, - info: dict | None, + info: PlayerInfo | None, df: pl.DataFrame, ) -> None: info_widget = self.query_one("#ii-player-info", Static) - table = self.query_one("#ii-table", DataTable) - table.clear() if info is None: info_widget.update("") - self.notify( - f"No missed games found for {player_name}", - severity="warning", - timeout=5, + self.fill_table( + "ii-table", + pl.DataFrame(), + _row_cells, + f"No missed-game splits for {player_name}. Either no player of that " + f"name took an offensive snap in the selected seasons, or they missed " + f"too few games to compare.", ) return @@ -206,37 +209,21 @@ def _on_search_complete( f"{info['name']} | {info['team']} | {info['position']} | " f"{info['games_missed']} games missed in {info['season']}" ) + self.fill_table( + "ii-table", + df, + _row_cells, + f"{player_name} missed games, but no teammate recorded stats both with " + f"and without them.", + ) - for row in df.iter_rows(named=True): - table.add_row( - row["teammate"] or "—", - row["position"] or "—", - row["team"], - str(row["season"]), - str(row["games_with"]), - str(row["games_without"]), - f"{row['fpts_with']:.1f}" if row["fpts_with"] is not None else "—", - f"{row['fpts_without']:.1f}" if row["fpts_without"] is not None else "—", - f"{row['delta_fpts']:+.1f}" if row["delta_fpts"] is not None else "—", - f"{row['tgt_with']:.1f}" if row["tgt_with"] is not None else "—", - f"{row['tgt_without']:.1f}" if row["tgt_without"] is not None else "—", - f"{row['touches_with']:.1f}" if row["touches_with"] is not None else "—", - f"{row['touches_without']:.1f}" if row["touches_without"] is not None else "—", - row["confidence"], - ) + def _on_search_error(self, error: str) -> None: + self.report_error(f"Search failed: {error}") # ── events ─────────────────────────────────────────────── - def on_select_changed(self, event: Select.Changed) -> None: - if self._initializing: - return - # Re-run search if one is active - search = self.query_one("#ii-search", Input) - if search.value.strip(): - self._run_search(search.value.strip()) - - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "ii-btn-refresh": - self.query_one("#ii-content").display = False - self.query_one("#ii-loading").display = True - self._fetch_base_data(force_refresh=True) + def filter_changed(self, filter_id: str | None) -> None: + """Run the search box's term again against the season the filter reads.""" + search = self.query_one("#ii-search", Input).value.strip() + if search: + self._search(search) diff --git a/src/ffb/red_zone/engine.py b/src/ffb/red_zone/engine.py index 0e45f15..9eabd35 100644 --- a/src/ffb/red_zone/engine.py +++ b/src/ffb/red_zone/engine.py @@ -15,16 +15,16 @@ def _rz_plays(pbp: pl.DataFrame) -> pl.DataFrame: def compute_team_rz(pbp: pl.DataFrame) -> pl.DataFrame: """Team-level red zone stats per season. - Returns: team, season, rz_trips, rz_tds, conv_pct, pass_pct, rush_pct, rz_epa. + Returns: team, season, rz_trips, rz_tds, conv_pct, pass_pct, rush_pct, rz_epa, + ordered by conversion rate. Team and season break a tie, so identical plays + give identical row order whatever order the plays arrive in. """ rz = _rz_plays(pbp) df = ( rz.group_by("posteam", "season") .agg( - pl.concat_str(["game_id", "fixed_drive"], separator="_") - .n_unique() - .alias("rz_trips"), + pl.concat_str(["game_id", "fixed_drive"], separator="_").n_unique().alias("rz_trips"), pl.col("touchdown").sum().cast(pl.Int32).alias("rz_tds"), pl.col("pass_attempt").sum().cast(pl.Int32).alias("rz_passes"), pl.col("rush_attempt").sum().cast(pl.Int32).alias("rz_rushes"), @@ -37,7 +37,18 @@ def compute_team_rz(pbp: pl.DataFrame) -> pl.DataFrame: (pl.col("rz_rushes") / pl.col("rz_plays") * 100).alias("rush_pct"), ) .rename({"posteam": "team"}) - .sort("conv_pct", descending=True) + .select( + "team", + "season", + "rz_trips", + "rz_tds", + "conv_pct", + "pass_pct", + "rush_pct", + "rz_epa", + ) + # Team and season key a row uniquely, so the order is total. + .sort(["conv_pct", "team", "season"], descending=[True, False, False]) ) return df @@ -59,7 +70,9 @@ def compute_player_rz( """Player-level red zone stats per season. Returns: player, position, team, season, rz_targets, rz_tgt_share, - rz_carries, rz_touches, rz_tds, td_pct. + rz_carries, rz_touches, rz_tds, td_pct, ordered by touchdowns then touches. + Player name, season, team and GSIS id break a tie, so identical plays give + identical row order whatever order the plays arrive in. """ rz = _rz_plays(pbp) pos_map = _player_position_map(rosters) @@ -73,17 +86,18 @@ def compute_player_rz( pl.len().alias("rz_targets"), pl.col("pass_touchdown").sum().cast(pl.Int32).alias("rz_rec_tds"), ) - .rename({ - "receiver_player_id": "player_id", - "receiver_player_name": "player", - "posteam": "team", - }) + .rename( + { + "receiver_player_id": "player_id", + "receiver_player_name": "name_from_receiving", + "posteam": "team", + } + ) ) # Team RZ target totals for target share - team_rz_targets = ( - rz_rec.group_by("team", "season") - .agg(pl.col("rz_targets").sum().alias("team_rz_targets")) + team_rz_targets = rz_rec.group_by("team", "season").agg( + pl.col("rz_targets").sum().alias("team_rz_targets") ) rz_rec = rz_rec.join(team_rz_targets, on=["team", "season"], how="left") rz_rec = rz_rec.with_columns( @@ -99,20 +113,27 @@ def compute_player_rz( pl.len().alias("rz_carries"), pl.col("rush_touchdown").sum().cast(pl.Int32).alias("rz_rush_tds"), ) - .rename({ - "rusher_player_id": "player_id", - "rusher_player_name": "player", - "posteam": "team", - }) + .rename( + { + "rusher_player_id": "player_id", + "rusher_player_name": "name_from_rushing", + "posteam": "team", + } + ) ) # Merge receiving + rushing + # Identity is the player id. The two name columns are display text drawn + # from separate source fields, so a player who both catches and runs inside + # the twenty can carry two spellings; the halves land on one row and the + # receiving spelling wins wherever both sides carry a name. df = rz_rec.join( rz_rush, - on=["player_id", "player", "team", "season"], + on=["player_id", "team", "season"], how="full", coalesce=True, ).with_columns( + pl.coalesce("name_from_receiving", "name_from_rushing").alias("player"), pl.col("rz_targets").fill_null(0), pl.col("rz_rec_tds").fill_null(0), pl.col("rz_tgt_share").fill_null(0.0), @@ -125,16 +146,29 @@ def compute_player_rz( (pl.col("rz_targets") + pl.col("rz_carries")).alias("rz_touches"), (pl.col("rz_rec_tds") + pl.col("rz_rush_tds")).alias("rz_tds"), ).with_columns( - pl.when(pl.col("rz_touches") > 0) - .then(pl.col("rz_tds") / pl.col("rz_touches") * 100) - .otherwise(0.0) - .alias("td_pct"), + # Every row descends from a group_by over red zone targets or carries, so + # rz_touches is at least one and the ratio is defined on all of them. + (pl.col("rz_tds") / pl.col("rz_touches") * 100).alias("td_pct"), ) # Join position from rosters df = df.join(pos_map, on="player_id", how="left") - return df.select( - "player", "position", "team", "season", - "rz_targets", "rz_tgt_share", "rz_carries", "rz_touches", "rz_tds", "td_pct", - ).sort("rz_tds", descending=True) + # Player id, team and season key a row uniquely, so the order is total. + ordered = df.sort( + ["rz_tds", "rz_touches", "player", "season", "team", "player_id"], + descending=[True, True, False, False, False, False], + ) + + return ordered.select( + "player", + "position", + "team", + "season", + "rz_targets", + "rz_tgt_share", + "rz_carries", + "rz_touches", + "rz_tds", + "td_pct", + ) diff --git a/src/ffb/red_zone/screen.py b/src/ffb/red_zone/screen.py index 30aacfc..d96ea8f 100644 --- a/src/ffb/red_zone/screen.py +++ b/src/ffb/red_zone/screen.py @@ -2,207 +2,168 @@ from __future__ import annotations +from dataclasses import dataclass +from typing import Any + import polars as pl -from textual import work from textual.app import ComposeResult -from textual.containers import Horizontal, Vertical -from textual.widget import Widget -from textual.widgets import ( - Button, - DataTable, - LoadingIndicator, - Select, - TabbedContent, - TabPane, -) +from textual.widgets import DataTable, Select, TabbedContent, TabPane from ffb.data.loader import load_pbp, load_rosters +from ffb.data.seasons import recent_seasons from ffb.red_zone.engine import compute_player_rz, compute_team_rz +from ffb.ui.base import ALL, ToolView TEAM_COLS = ("Team", "RZ Trips", "RZ TD", "Conv%", "Pass%", "Rush%", "EPA/Play") PLAYER_COLS = ("Player", "Pos", "Team", "RZ Tgt", "Tgt%", "RZ Rush", "Touches", "RZ TD", "TD%") +#: Seasons loaded together, so a red zone rate is read against the season before it. +SEASON_COUNT = 2 + + +@dataclass(frozen=True) +class RedZoneData: + """Both tables of one load, in the order they are rendered.""" + + team: pl.DataFrame + player: pl.DataFrame + + +def _team_row(row: dict[str, Any]) -> tuple[str, ...]: + """Map a team red zone row to its table cells.""" + return ( + row["team"], + str(row["rz_trips"]), + str(row["rz_tds"]), + f"{row['conv_pct']:.1f}%", + f"{row['pass_pct']:.1f}%", + f"{row['rush_pct']:.1f}%", + f"{row['rz_epa']:.3f}" if row["rz_epa"] is not None else "—", + ) + -class RedZoneView(Widget): +def _player_row(row: dict[str, Any]) -> tuple[str, ...]: + """Map a player red zone row to its table cells.""" + return ( + row["player"], + row["position"] or "—", + row["team"], + str(row["rz_targets"]), + f"{row['rz_tgt_share']:.1f}%", + str(row["rz_carries"]), + str(row["rz_touches"]), + str(row["rz_tds"]), + f"{row['td_pct']:.1f}%", + ) + + +class RedZoneView(ToolView): """Red zone efficiency analyzer with team and player views.""" - DEFAULT_CSS = """ - RedZoneView { - height: 1fr; - width: 1fr; - } - - #rz-loading { - height: 1fr; - } - - #rz-content { - height: 1fr; - } - - .filter-bar { - height: auto; - max-height: 5; - padding: 1; - background: $surface-darken-1; - } - - .filter-bar Select { - width: 1fr; - margin-right: 1; - } - - .filter-bar #rz-btn-refresh { - margin-left: 1; - } - """ - - def __init__(self, **kwargs) -> None: + ID_PREFIX = "rz" + LOAD_LABEL = f"Downloading {SEASON_COUNT} seasons of play-by-play and rosters" + + def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) - self._team_data: pl.DataFrame | None = None - self._player_data: pl.DataFrame | None = None - self._initializing = True - self._activated = False - - def compose(self) -> ComposeResult: - yield LoadingIndicator(id="rz-loading") - with Vertical(id="rz-content"): - with Horizontal(classes="filter-bar"): - yield Select[str]( - [("All", "All"), ("QB", "QB"), ("RB", "RB"), ("WR", "WR"), ("TE", "TE")], - value="All", - id="rz-filter-position", - ) - yield Select[str]([], prompt="Team", id="rz-filter-team") - yield Select[int]([], prompt="Season", id="rz-filter-season") - yield Button("Refresh", id="rz-btn-refresh", variant="primary") - with TabbedContent(initial="rz-tab-players"): - with TabPane("Players", id="rz-tab-players"): - yield DataTable(id="rz-player-table") - with TabPane("Teams", id="rz-tab-teams"): - yield DataTable(id="rz-team-table") + self._data: RedZoneData | None = None + + def compose_content(self) -> ComposeResult: + yield from self.compose_filter_bar( + self.position_select(), + Select[str]([], prompt="Team", id="rz-filter-team"), + Select[int]([], prompt="Season", id="rz-filter-season"), + ) + with TabbedContent(initial="rz-tab-players"): + with TabPane("Players", id="rz-tab-players"): + yield DataTable(id="rz-player-table") + with TabPane("Teams", id="rz-tab-teams"): + yield DataTable(id="rz-team-table") def on_mount(self) -> None: - self.query_one("#rz-content").display = False - team_table = self.query_one("#rz-team-table", DataTable) - team_table.add_columns(*TEAM_COLS) - team_table.cursor_type = "row" - player_table = self.query_one("#rz-player-table", DataTable) - player_table.add_columns(*PLAYER_COLS) - player_table.cursor_type = "row" - - def activate(self) -> None: - """Load data on first activation (called when view becomes visible).""" - if not self._activated: - self._activated = True - self._fetch_data() - - @work(thread=True, exclusive=True) - def _fetch_data(self, force_refresh: bool = False) -> None: - try: - pbp = load_pbp([2024, 2025], force_refresh=force_refresh) - rosters = load_rosters([2024, 2025], force_refresh=force_refresh) - team = compute_team_rz(pbp) - player = compute_player_rz(pbp, rosters) - self.app.call_from_thread(self._on_data_loaded, team, player) - except Exception as e: - self.app.call_from_thread(self._on_data_error, str(e)) - - def _on_data_loaded(self, team: pl.DataFrame, player: pl.DataFrame) -> None: - self._team_data = team - self._player_data = player - self._initializing = True + for table_id, columns in ( + ("rz-team-table", TEAM_COLS), + ("rz-player-table", PLAYER_COLS), + ): + table = self.query_one(f"#{table_id}", DataTable) + table.add_columns(*columns) + table.cursor_type = "row" + + # ── load ───────────────────────────────────────────────── + + def fetch(self, force_refresh: bool) -> RedZoneData: + """Reduce play-by-play and rosters to the team and player red zone tables. + + Both frames come back in display order, so the filters narrow them without + having to re-establish the ranking each table is read by. + """ + seasons = recent_seasons(SEASON_COUNT) + pbp = load_pbp(seasons, force_refresh=force_refresh) + rosters = load_rosters(seasons, force_refresh=force_refresh) + return RedZoneData( + team=compute_team_rz(pbp).sort("conv_pct", descending=True), + player=compute_player_rz(pbp, rosters).sort("rz_tds", descending=True), + ) + + def apply(self, payload: RedZoneData) -> None: + self._data = payload self._populate_filters() - self._initializing = False self._apply_filters() - self.query_one("#rz-loading").display = False - self.query_one("#rz-content").display = True - - def _on_data_error(self, error: str) -> None: - self.query_one("#rz-loading").display = False - self.notify(f"Failed to load data: {error}", severity="error", timeout=10) # ── filters ────────────────────────────────────────────── def _populate_filters(self) -> None: - if self._team_data is None: + if self._data is None: return - teams = sorted(self._team_data["team"].unique().drop_nulls().to_list()) + teams = sorted(self._data.team["team"].unique().drop_nulls().to_list()) team_select = self.query_one("#rz-filter-team", Select) - team_select.set_options([("All", "All")] + [(t, t) for t in teams]) - team_select.value = "All" + team_select.set_options(self.all_options(teams)) + team_select.value = ALL - seasons = sorted(self._team_data["season"].unique().drop_nulls().to_list()) + seasons = sorted(self._data.team["season"].unique().drop_nulls().to_list()) season_select = self.query_one("#rz-filter-season", Select) season_select.set_options([(str(s), s) for s in seasons]) - season_select.value = seasons[-1] + # A load that reached no played games leaves the season filter blank rather + # than selecting a season the data does not carry. + if seasons: + season_select.value = seasons[-1] def _apply_filters(self) -> None: - if self._team_data is None or self._player_data is None: + if self._data is None: return - team_df = self._team_data - player_df = self._player_data - - team_val = self.query_one("#rz-filter-team", Select).value - if team_val not in ("All", Select.BLANK): - team_df = team_df.filter(pl.col("team") == team_val) - player_df = player_df.filter(pl.col("team") == team_val) - - season_val = self.query_one("#rz-filter-season", Select).value - if season_val is not Select.BLANK: - team_df = team_df.filter(pl.col("season") == season_val) - player_df = player_df.filter(pl.col("season") == season_val) - - pos_val = self.query_one("#rz-filter-position", Select).value - if pos_val not in ("All", Select.BLANK): - player_df = player_df.filter(pl.col("position") == pos_val) - - self._fill_team_table(team_df) - self._fill_player_table(player_df) - - # ── tables ─────────────────────────────────────────────── - - def _fill_team_table(self, df: pl.DataFrame) -> None: - table = self.query_one("#rz-team-table", DataTable) - table.clear() - for row in df.sort("conv_pct", descending=True).iter_rows(named=True): - table.add_row( - row["team"], - str(row["rz_trips"]), - str(row["rz_tds"]), - f"{row['conv_pct']:.1f}%", - f"{row['pass_pct']:.1f}%", - f"{row['rush_pct']:.1f}%", - f"{row['rz_epa']:.3f}" if row["rz_epa"] is not None else "—", - ) - - def _fill_player_table(self, df: pl.DataFrame) -> None: - table = self.query_one("#rz-player-table", DataTable) - table.clear() - for row in df.sort("rz_tds", descending=True).iter_rows(named=True): - table.add_row( - row["player"], - row["position"] or "—", - row["team"], - str(row["rz_targets"]), - f"{row['rz_tgt_share']:.1f}%", - str(row["rz_carries"]), - str(row["rz_touches"]), - str(row["rz_tds"]), - f"{row['td_pct']:.1f}%", - ) + team_df = self._data.team + player_df = self._data.player + + team = self.narrowing("rz-filter-team") + if team is not None: + team_df = team_df.filter(pl.col("team") == team) + player_df = player_df.filter(pl.col("team") == team) + + season = self.selected("rz-filter-season") + if season is not None: + team_df = team_df.filter(pl.col("season") == season) + player_df = player_df.filter(pl.col("season") == season) + + position = self.narrowing("rz-filter-position") + if position is not None: + player_df = player_df.filter(pl.col("position") == position) + + self.fill_table( + "rz-team-table", + team_df, + _team_row, + "No red zone trips for the selected team and season.", + ) + self.fill_table( + "rz-player-table", + player_df, + _player_row, + "No red zone touches for the selected position, team and season.", + ) # ── events ─────────────────────────────────────────────── - def on_select_changed(self, event: Select.Changed) -> None: - if self._initializing: - return + def filter_changed(self, filter_id: str | None) -> None: + """Narrow both tables to the position, team and season the filters read.""" self._apply_filters() - - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "rz-btn-refresh": - self.query_one("#rz-content").display = False - self.query_one("#rz-loading").display = True - self._fetch_data(force_refresh=True) diff --git a/src/ffb/snap_share/engine.py b/src/ffb/snap_share/engine.py index 5b8e283..168018c 100644 --- a/src/ffb/snap_share/engine.py +++ b/src/ffb/snap_share/engine.py @@ -12,25 +12,28 @@ def compute_trends( ) -> pl.DataFrame: """Compute snap share trends per player per season. - Returns a DataFrame with columns: player, position, team, season, week, - snap_pct, rolling_avg, delta, velocity, trend. + Returns a DataFrame with columns: pfr_player_id, player, position, team, + season, week, snap_pct, rolling_avg, delta, velocity, trend. + + Every rolling window groups by ``pfr_player_id``. Display names collide + across the league — multiple active players share a name in any given + season — so a name-keyed window splices two players into one series. + A snap row carrying no ``pfr_player_id`` cannot be attributed to a player + and is excluded. """ if window < 2: raise ValueError(f"window must be >= 2, got {window}") df = ( - snaps.filter( - pl.col("position").is_in(OFFENSIVE_POSITIONS) - & (pl.col("game_type") == "REG") - ) - .select("player", "position", "team", "season", "week", "offense_pct") - .drop_nulls(subset=["offense_pct"]) - .sort("player", "season", "week") + snaps.filter(pl.col("position").is_in(OFFENSIVE_POSITIONS) & (pl.col("game_type") == "REG")) + .select("pfr_player_id", "player", "position", "team", "season", "week", "offense_pct") + .drop_nulls(subset=["offense_pct", "pfr_player_id"]) + .sort("pfr_player_id", "season", "week") ) # Normalize to 0-100 if stored as 0-1 fraction max_pct = df["offense_pct"].max() - if max_pct is not None and max_pct <= 1.0: + if isinstance(max_pct, (int, float)) and max_pct <= 1.0: df = df.with_columns(pl.col("offense_pct") * 100) df = df.rename({"offense_pct": "snap_pct"}) @@ -40,7 +43,7 @@ def compute_trends( pl.col("snap_pct") .shift(1) .rolling_mean(window_size=window, min_samples=1) - .over("player", "season") + .over("pfr_player_id", "season") .alias("rolling_avg") ) @@ -52,7 +55,7 @@ def compute_trends( ( ( pl.col("snap_pct") - - pl.col("snap_pct").shift(window - 1).over("player", "season") + - pl.col("snap_pct").shift(window - 1).over("pfr_player_id", "season") ) / (window - 1) ).alias("velocity") @@ -60,7 +63,7 @@ def compute_trends( # Breakout: crossed from <50% to >60% within the window breakout = (pl.col("snap_pct") > 60) & ( - pl.col("snap_pct").shift(window - 1).over("player", "season") < 50 + pl.col("snap_pct").shift(window - 1).over("pfr_player_id", "season") < 50 ) # Classify trend diff --git a/src/ffb/snap_share/screen.py b/src/ffb/snap_share/screen.py index 5acedc4..1d0162a 100644 --- a/src/ffb/snap_share/screen.py +++ b/src/ffb/snap_share/screen.py @@ -2,147 +2,98 @@ from __future__ import annotations +from typing import Any + import polars as pl -from textual import work from textual.app import ComposeResult from textual.containers import Horizontal, Vertical -from textual.widget import Widget -from textual.widgets import ( - Button, - DataTable, - LoadingIndicator, - Select, - Static, - TabbedContent, - TabPane, -) +from textual.widgets import DataTable, Select, Static, TabbedContent, TabPane from ffb.data.loader import load_snap_counts +from ffb.data.seasons import recent_seasons from ffb.snap_share.engine import compute_trends - -COLUMNS = ("Player", "Pos", "Team", "Wk", "Snap%", "Avg", "Δ", "Vel", "Trend") - - -class SnapShareView(Widget): +from ffb.ui.base import ALL, ToolView + +#: "vs Avg" holds the week's snap share minus the rolling average of the weeks before +#: it, and "Vel" the per-week slope across the window. +COLUMNS = ("Player", "Pos", "Team", "Wk", "Snap%", "Avg", "vs Avg", "Vel", "Trend") + +#: Seasons the filter offers, counting back from the most recent. Rolling windows group +#: by season, so each season is trended from its own week 1. +SEASON_COUNT = 2 + +#: Trends that count as an upward alert. +RISING_TRENDS = ("rising", "breakout") + +#: Tables that share the trend column layout. +TABLE_IDS = ("sn-rising-table", "sn-falling-table", "sn-all-table") + + +def _trend_row(row: dict[str, Any]) -> tuple[str, ...]: + """Format one trend row as table cells, rendering an absent measure as an em dash.""" + snap = row["snap_pct"] + avg = row["rolling_avg"] + delta = row["delta"] + velocity = row["velocity"] + return ( + row["player"], + row["position"], + row["team"], + str(row["week"]), + f"{snap:.1f}%" if snap is not None else "—", + f"{avg:.1f}%" if avg is not None else "—", + f"{delta:+.1f}" if delta is not None else "—", + f"{velocity:+.1f}" if velocity is not None else "—", + row["trend"], + ) + + +class SnapShareView(ToolView): """Snap count trend tracker with alerts and full player table.""" - DEFAULT_CSS = """ - SnapShareView { - height: 1fr; - width: 1fr; - } - - #sn-loading { - height: 1fr; - } - - #sn-content { - height: 1fr; - } - - .filter-bar { - height: auto; - max-height: 5; - padding: 1; - background: $surface-darken-1; - } - - .filter-bar Select { - width: 1fr; - margin-right: 1; - } - - .filter-bar #sn-btn-refresh { - margin-left: 1; - } - - .alert-panels { - height: 1fr; - } - - .alert-panel { - width: 1fr; - margin: 0 1; - } - - .panel-header-rising { - color: $success; - text-style: bold; - padding: 1 0 0 1; - } - - .panel-header-falling { - color: $error; - text-style: bold; - padding: 1 0 0 1; - } - """ - - def __init__(self, **kwargs) -> None: + ID_PREFIX = "sn" + LOAD_LABEL = "Downloading snap counts" + + def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) self._trends: pl.DataFrame | None = None - self._activated = False - self._initializing = True - - def compose(self) -> ComposeResult: - yield LoadingIndicator(id="sn-loading") - with Vertical(id="sn-content"): - with Horizontal(classes="filter-bar"): - yield Select[str]( - [("All", "All"), ("QB", "QB"), ("RB", "RB"), ("WR", "WR"), ("TE", "TE")], - value="All", - id="sn-filter-position", - ) - yield Select[str]([], prompt="Team", id="sn-filter-team") - yield Select[int]([], prompt="Season", id="sn-filter-season") - yield Select[int]([], prompt="Week", id="sn-filter-week") - yield Button("Refresh", id="sn-btn-refresh", variant="primary") - with TabbedContent(): - with TabPane("Alerts", id="sn-tab-alerts"): - with Horizontal(classes="alert-panels"): - with Vertical(classes="alert-panel"): - yield Static("▲ Rising", classes="panel-header-rising") - yield DataTable(id="sn-rising-table") - with Vertical(classes="alert-panel"): - yield Static("▼ Falling", classes="panel-header-falling") - yield DataTable(id="sn-falling-table") - with TabPane("All Players", id="sn-tab-all"): - yield DataTable(id="sn-all-table") + + def compose_content(self) -> ComposeResult: + yield from self.compose_filter_bar( + self.position_select(), + Select[str]([], prompt="Team", id="sn-filter-team"), + Select[int]([], prompt="Season", id="sn-filter-season"), + Select[int]([], prompt="Week", id="sn-filter-week"), + ) + with TabbedContent(): + with TabPane("Alerts", id="sn-tab-alerts"): + with Horizontal(classes="alert-panels"): + with Vertical(classes="alert-panel"): + yield Static("▲ Rising", classes="panel-header-rising") + yield DataTable(id="sn-rising-table") + with Vertical(classes="alert-panel"): + yield Static("▼ Falling", classes="panel-header-falling") + yield DataTable(id="sn-falling-table") + with TabPane("All Players", id="sn-tab-all"): + yield DataTable(id="sn-all-table") def on_mount(self) -> None: - self.query_one("#sn-content").display = False - for tid in ("sn-rising-table", "sn-falling-table", "sn-all-table"): - table = self.query_one(f"#{tid}", DataTable) + for table_id in TABLE_IDS: + table = self.query_one(f"#{table_id}", DataTable) table.add_columns(*COLUMNS) table.cursor_type = "row" - def activate(self) -> None: - """Load data on first activation.""" - if not self._activated: - self._activated = True - self._fetch_data() - - @work(thread=True, exclusive=True) - def _fetch_data(self, force_refresh: bool = False) -> None: - try: - snaps = load_snap_counts([2024, 2025], force_refresh=force_refresh) - trends = compute_trends(snaps) - self.app.call_from_thread(self._on_data_loaded, trends) - except Exception as e: - self.app.call_from_thread(self._on_data_error, str(e)) - - def _on_data_loaded(self, trends: pl.DataFrame) -> None: + # ── data ───────────────────────────────────────────────── + + def fetch(self, force_refresh: bool) -> pl.DataFrame: + """Download snap counts and trend every player's share of them.""" + snaps = load_snap_counts(recent_seasons(SEASON_COUNT), force_refresh=force_refresh) + return compute_trends(snaps) + + def apply(self, trends: pl.DataFrame) -> None: self._trends = trends - self._initializing = True self._populate_filters() - self._initializing = False self._apply_filters() - self.query_one("#sn-loading").display = False - self.query_one("#sn-content").display = True - - def _on_data_error(self, error: str) -> None: - self.query_one("#sn-loading").display = False - self.notify(f"Failed to load data: {error}", severity="error", timeout=10) # ── filters ────────────────────────────────────────────── @@ -152,33 +103,40 @@ def _populate_filters(self) -> None: teams = sorted(self._trends["team"].unique().drop_nulls().to_list()) team_select = self.query_one("#sn-filter-team", Select) - team_select.set_options([("All", "All")] + [(t, t) for t in teams]) - team_select.value = "All" + team_select.set_options(self.all_options(teams)) + team_select.value = ALL seasons = sorted(self._trends["season"].unique().drop_nulls().to_list()) season_select = self.query_one("#sn-filter-season", Select) - season_select.set_options([(str(s), s) for s in seasons]) - season_select.value = seasons[-1] + season_select.set_options([(str(season), season) for season in seasons]) + if seasons: + season_select.value = seasons[-1] self._update_week_options() def _update_week_options(self) -> None: + """Offer the weeks the selected season carries, defaulting to the latest. + + Rebuilding the option list on every call keeps the filter from advertising a + week the selected season has no rows for. + """ if self._trends is None: return - season = self.query_one("#sn-filter-season", Select).value - if season is Select.BLANK: - return - weeks = sorted( - self._trends.filter(pl.col("season") == season)["week"] - .unique() - .drop_nulls() - .to_list() + season = self.selected("sn-filter-season") + weeks: list[int] = ( + [] + if season is None + else sorted( + self._trends.filter(pl.col("season") == season)["week"] + .unique() + .drop_nulls() + .to_list() + ) ) - if not weeks: - return week_select = self.query_one("#sn-filter-week", Select) - week_select.set_options([(f"Week {w}", w) for w in weeks]) - week_select.value = weeks[-1] + week_select.set_options([(f"Week {week}", week) for week in weeks]) + if weeks: + week_select.value = weeks[-1] def _apply_filters(self) -> None: if self._trends is None: @@ -186,20 +144,20 @@ def _apply_filters(self) -> None: df = self._trends - pos = self.query_one("#sn-filter-position", Select).value - if pos not in ("All", Select.BLANK): + pos = self.narrowing("sn-filter-position") + if pos is not None: df = df.filter(pl.col("position") == pos) - team = self.query_one("#sn-filter-team", Select).value - if team not in ("All", Select.BLANK): + team = self.narrowing("sn-filter-team") + if team is not None: df = df.filter(pl.col("team") == team) - season = self.query_one("#sn-filter-season", Select).value - if season is not Select.BLANK: + season = self.selected("sn-filter-season") + if season is not None: df = df.filter(pl.col("season") == season) - week = self.query_one("#sn-filter-week", Select).value - if week is not Select.BLANK: + week = self.selected("sn-filter-week") + if week is not None: df = df.filter(pl.col("week") == week) self._update_tables(df) @@ -207,49 +165,33 @@ def _apply_filters(self) -> None: # ── tables ─────────────────────────────────────────────── def _update_tables(self, df: pl.DataFrame) -> None: - rising = df.filter( - pl.col("trend").is_in(["rising", "breakout"]) - ).sort("velocity", descending=True) - self._fill_table("sn-rising-table", rising) + rising = df.filter(pl.col("trend").is_in(RISING_TRENDS)).sort("velocity", descending=True) + self.fill_table( + "sn-rising-table", + rising, + _trend_row, + "No rising or breakout players for the selected season, week, position and team.", + ) falling = df.filter(pl.col("trend") == "falling").sort("velocity") - self._fill_table("sn-falling-table", falling) - - self._fill_table("sn-all-table", df.sort("delta", descending=True)) - - def _fill_table(self, table_id: str, df: pl.DataFrame) -> None: - table = self.query_one(f"#{table_id}", DataTable) - table.clear() - for row in df.iter_rows(named=True): - snap = row["snap_pct"] - avg = row["rolling_avg"] - delta = row["delta"] - vel = row["velocity"] - table.add_row( - row["player"], - row["position"], - row["team"], - str(row["week"]), - f"{snap:.1f}%" if snap is not None else "—", - f"{avg:.1f}%" if avg is not None else "—", - f"{delta:+.1f}" if delta is not None else "—", - f"{vel:+.1f}" if vel is not None else "—", - row["trend"], - ) + self.fill_table( + "sn-falling-table", + falling, + _trend_row, + "No falling players for the selected season, week, position and team.", + ) + + self.fill_table( + "sn-all-table", + df.sort("delta", descending=True), + _trend_row, + "No snap counts for the selected season, week, position and team.", + ) # ── events ─────────────────────────────────────────────── - def on_select_changed(self, event: Select.Changed) -> None: - if self._initializing: - return - if event.select.id == "sn-filter-season": - self._initializing = True + def filter_changed(self, filter_id: str | None) -> None: + """Re-filter, rebuilding the week options first where the season moved.""" + if filter_id == "sn-filter-season": self._update_week_options() - self._initializing = False self._apply_filters() - - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "sn-btn-refresh": - self.query_one("#sn-content").display = False - self.query_one("#sn-loading").display = True - self._fetch_data(force_refresh=True) diff --git a/src/ffb/start_sit/engine.py b/src/ffb/start_sit/engine.py index 4bc3782..a64f008 100644 --- a/src/ffb/start_sit/engine.py +++ b/src/ffb/start_sit/engine.py @@ -8,6 +8,37 @@ from ffb.data import OFFENSIVE_POSITIONS +# Weeks of data a defense needs before its points-allowed average can scale a +# projection; a one-game sample swings the multiplier by more than the matchup +# it claims to describe. +MIN_DEFENSIVE_GAMES = 3 + +# Games a player needs before their per-game average is treated as a baseline. +MIN_BASELINE_GAMES = 3 + +# Columns and dtypes every compute_start_sit result carries, in order. Callers +# filter and index the result by name, so an empty result must be shaped +# identically to a populated one. +OUTPUT_SCHEMA = pl.Schema( + { + "player": pl.String(), + "position": pl.String(), + "team": pl.String(), + "opponent": pl.String(), + "baseline_fpts": pl.Float64(), + "fpts_allowed": pl.Float64(), + "league_avg": pl.Float64(), + "matchup_mult": pl.Float64(), + "projected_fpts": pl.Float64(), + "confidence": pl.String(), + } +) + + +def _empty_result() -> pl.DataFrame: + """Zero-row frame carrying OUTPUT_SCHEMA.""" + return pl.DataFrame(schema=OUTPUT_SCHEMA) + def _defensive_rankings( weekly_stats: pl.DataFrame, @@ -16,6 +47,9 @@ def _defensive_rankings( ) -> pl.DataFrame: """Avg fantasy points allowed per game by defense and position. + Defenses with fewer than MIN_DEFENSIVE_GAMES games of data are excluded: + they distort the league average and any projection scaled against them. + Returns: opponent_team, position, fpts_allowed (avg per game). """ df = weekly_stats.filter( @@ -27,27 +61,25 @@ def _defensive_rankings( ) # Sum fantasy points per (opponent defense, position, week) - per_game = ( - df.group_by("opponent_team", "position", "week") - .agg(pl.col("fantasy_points_ppr").sum().alias("fpts_scored")) + per_game = df.group_by("opponent_team", "position", "week").agg( + pl.col("fantasy_points_ppr").sum().alias("fpts_scored") ) - # Average across weeks + # Average across weeks, holding each defense to a minimum sample return ( per_game.group_by("opponent_team", "position") .agg( pl.col("fpts_scored").mean().alias("fpts_allowed"), pl.col("fpts_scored").len().alias("games"), ) + .filter(pl.col("games") >= MIN_DEFENSIVE_GAMES) + .select("opponent_team", "position", "fpts_allowed") ) def _league_avg_by_position(def_ranks: pl.DataFrame) -> pl.DataFrame: """League-wide average fantasy points per position per game.""" - return ( - def_ranks.group_by("position") - .agg(pl.col("fpts_allowed").mean().alias("league_avg")) - ) + return def_ranks.group_by("position").agg(pl.col("fpts_allowed").mean().alias("league_avg")) def _player_baselines( @@ -55,7 +87,11 @@ def _player_baselines( season: int, through_week: int, ) -> pl.DataFrame: - """Per-player season average fantasy points per game.""" + """Per-player season average fantasy points per game. + + Players with fewer than `MIN_BASELINE_GAMES` regular-season games are + dropped, so a single outlier week never stands in for a season average. + """ df = weekly_stats.filter( (pl.col("season") == season) & (pl.col("season_type") == "REG") @@ -69,7 +105,7 @@ def _player_baselines( pl.col("fantasy_points_ppr").mean().alias("baseline_fpts"), pl.col("fantasy_points_ppr").len().alias("games_played"), ) - .filter(pl.col("games_played") >= 3) + .filter(pl.col("games_played") >= MIN_BASELINE_GAMES) .rename({"player_display_name": "player", "recent_team": "team"}) ) @@ -84,9 +120,7 @@ def _matchups_for_week( Returns: team, opponent. """ games = schedules.filter( - (pl.col("season") == season) - & (pl.col("week") == week) - & (pl.col("game_type") == "REG") + (pl.col("season") == season) & (pl.col("week") == week) & (pl.col("game_type") == "REG") ) # Each game produces two rows: home vs away and away vs home @@ -110,12 +144,17 @@ def compute_start_sit( ) -> pl.DataFrame: """Produce matchup-adjusted projections for a given week. - Returns: player, position, team, opponent, baseline_fpts, fpts_allowed, - league_avg, matchup_mult, projected_fpts, confidence. + Returns a frame carrying OUTPUT_SCHEMA, empty when fewer than + MIN_BASELINE_GAMES weeks precede the requested week or no regular-season + games are scheduled for it. """ through_week = week - 1 - if through_week < 3: - return pl.DataFrame() + # No player clears MIN_BASELINE_GAMES games with fewer weeks than that behind + # them, so the opening weeks of a season carry no projection. Answering at the + # boundary holds that contract whatever thresholds the baseline and defensive + # sample floors below are set to. + if through_week < MIN_BASELINE_GAMES: + return _empty_result() # Core computations baselines = _player_baselines(weekly_stats, season, through_week) @@ -124,9 +163,12 @@ def compute_start_sit( matchups = _matchups_for_week(schedules, season, week) if matchups.is_empty(): - return pl.DataFrame() + return _empty_result() - # Join: player → their matchup opponent + # Join: player → their matchup opponent. A team on a bye holds no matchup + # row, so an inner join leaves its starters out of the projection here. The + # null drop at the end answers a separate state: an opponent whose defense + # carries no ranking. df = baselines.join(matchups, on="team", how="inner") # Join: opponent defensive ranking for this position @@ -143,26 +185,25 @@ def compute_start_sit( # Matchup multiplier and projection df = df.with_columns( (pl.col("fpts_allowed") / pl.col("league_avg")).alias("matchup_mult") - ).with_columns( - (pl.col("baseline_fpts") * pl.col("matchup_mult")).alias("projected_fpts") - ) + ).with_columns((pl.col("baseline_fpts") * pl.col("matchup_mult")).alias("projected_fpts")) # Confidence tier (check from highest threshold down) df = df.with_columns( - pl.when(pl.col("matchup_mult") >= 1.20).then(pl.lit("Strong Start")) - .when(pl.col("matchup_mult") >= 1.08).then(pl.lit("Lean Start")) - .when(pl.col("matchup_mult") >= 0.92).then(pl.lit("Neutral")) - .when(pl.col("matchup_mult") >= 0.80).then(pl.lit("Lean Sit")) + pl.when(pl.col("matchup_mult") >= 1.20) + .then(pl.lit("Strong Start")) + .when(pl.col("matchup_mult") >= 1.08) + .then(pl.lit("Lean Start")) + .when(pl.col("matchup_mult") >= 0.92) + .then(pl.lit("Neutral")) + .when(pl.col("matchup_mult") >= 0.80) + .then(pl.lit("Lean Sit")) .otherwise(pl.lit("Strong Sit")) .alias("confidence") ) return ( - df.select( - "player", "position", "team", "opponent", - "baseline_fpts", "fpts_allowed", "league_avg", - "matchup_mult", "projected_fpts", "confidence", - ) + df.select(*OUTPUT_SCHEMA) + .cast(OUTPUT_SCHEMA) .drop_nulls(subset=["projected_fpts"]) .sort("projected_fpts", descending=True) ) diff --git a/src/ffb/start_sit/screen.py b/src/ffb/start_sit/screen.py index 8319130..2fd5509 100644 --- a/src/ffb/start_sit/screen.py +++ b/src/ffb/start_sit/screen.py @@ -2,118 +2,119 @@ from __future__ import annotations +from dataclasses import dataclass +from typing import Any + import polars as pl -from textual import work from textual.app import ComposeResult -from textual.containers import Horizontal, Vertical -from textual.widget import Widget -from textual.widgets import ( - Button, - DataTable, - LoadingIndicator, - Select, -) +from textual.widgets import DataTable, Select from ffb.data.loader import load_schedules, load_weekly_stats +from ffb.data.seasons import recent_seasons from ffb.start_sit.engine import compute_start_sit +from ffb.ui.base import ALL, ToolView +#: Table headers, in the order `_projection_row` emits cells. COLUMNS = ( - "Player", "Pos", "Team", "Opp", - "Baseline", "Opp Allows", "Lg Avg", - "Mult", "Projected", "Verdict", + "Player", + "Pos", + "Team", + "Opp", + "Baseline", + "Opp Allows", + "Lg Avg", + "Mult", + "Projected", + "Verdict", ) +#: Seasons loaded. The season filter offers one entry per loaded season, so this +#: window sets how far back a lineup decision can be reviewed. +SEASON_WINDOW = 2 + +#: Stands in for the table when the engine projects nobody for the selected week. +NO_PROJECTIONS = ( + "No projections for this week. A matchup multiplier needs three completed weeks " + "of defensive results behind it, so week 4 is the earliest week that projects." +) -class StartSitView(Widget): - """Matchup-based start/sit projection engine.""" +#: Stands in for the table when projections exist but the filters exclude all of them. +NO_MATCHES = ( + "No players match the position and team filters. " + "Widen either filter to see this week's projections." +) - DEFAULT_CSS = """ - StartSitView { - height: 1fr; - width: 1fr; - } - #ss-loading { - height: 1fr; - } +@dataclass(frozen=True) +class StartSitData: + """The two frames every projection in this view is computed from.""" - #ss-content { - height: 1fr; - } + weekly_stats: pl.DataFrame + schedules: pl.DataFrame - .filter-bar { - height: auto; - max-height: 5; - padding: 1; - background: $surface-darken-1; - } - .filter-bar Select { - width: 1fr; - margin-right: 1; - } +def _projection_row(row: dict[str, Any]) -> tuple[str, ...]: + """Format one projection as table cells, in COLUMNS order. - .filter-bar #ss-btn-refresh { - margin-left: 1; - } + An em dash stands in for an absent points-allowed average, league average or + multiplier, so a missing defensive sample reads as missing rather than as zero. """ - - def __init__(self, **kwargs) -> None: + return ( + row["player"], + row["position"], + row["team"], + row["opponent"], + f"{row['baseline_fpts']:.1f}", + f"{row['fpts_allowed']:.1f}" if row["fpts_allowed"] is not None else "—", + f"{row['league_avg']:.1f}" if row["league_avg"] is not None else "—", + f"{row['matchup_mult']:.2f}x" if row["matchup_mult"] is not None else "—", + f"{row['projected_fpts']:.1f}", + row["confidence"], + ) + + +class StartSitView(ToolView): + """Matchup-adjusted start/sit projections for one week.""" + + ID_PREFIX = "ss" + LOAD_LABEL = "Loading weekly stats and schedules" + + def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) self._weekly_stats: pl.DataFrame | None = None self._schedules: pl.DataFrame | None = None self._projections: pl.DataFrame | None = None - self._activated = False - self._initializing = True - - def compose(self) -> ComposeResult: - yield LoadingIndicator(id="ss-loading") - with Vertical(id="ss-content"): - with Horizontal(classes="filter-bar"): - yield Select[str]( - [("All", "All"), ("QB", "QB"), ("RB", "RB"), ("WR", "WR"), ("TE", "TE")], - value="All", - id="ss-filter-position", - ) - yield Select[str]([], prompt="Team", id="ss-filter-team") - yield Select[int]([], prompt="Season", id="ss-filter-season") - yield Select[int]([], prompt="Week", id="ss-filter-week") - yield Button("Refresh", id="ss-btn-refresh", variant="primary") - yield DataTable(id="ss-table") + + def compose_content(self) -> ComposeResult: + yield from self.compose_filter_bar( + self.position_select(), + Select[str]([], prompt="Team", id="ss-filter-team"), + Select[int]([], prompt="Season", id="ss-filter-season"), + Select[int]([], prompt="Week", id="ss-filter-week"), + ) + yield DataTable(id="ss-table") def on_mount(self) -> None: - self.query_one("#ss-content").display = False + # Textual dispatches every on_mount along the MRO, so the base class hides the + # panes and pairs the table with its empty-state label without a super() call. table = self.query_one("#ss-table", DataTable) table.add_columns(*COLUMNS) table.cursor_type = "row" - def activate(self) -> None: - if not self._activated: - self._activated = True - self._fetch_data() - - @work(thread=True, exclusive=True) - def _fetch_data(self, force_refresh: bool = False) -> None: - try: - weekly = load_weekly_stats([2024, 2025], force_refresh=force_refresh) - schedules = load_schedules([2024, 2025], force_refresh=force_refresh) - self.app.call_from_thread(self._on_data_loaded, weekly, schedules) - except Exception as e: - self.app.call_from_thread(self._on_data_error, str(e)) - - def _on_data_loaded(self, weekly: pl.DataFrame, schedules: pl.DataFrame) -> None: - self._weekly_stats = weekly - self._schedules = schedules - self._initializing = True + # ── load ───────────────────────────────────────────────── + + def fetch(self, force_refresh: bool) -> StartSitData: + seasons = recent_seasons(SEASON_WINDOW) + return StartSitData( + weekly_stats=load_weekly_stats(seasons, force_refresh=force_refresh), + schedules=load_schedules(seasons, force_refresh=force_refresh), + ) + + def apply(self, payload: StartSitData) -> None: + self._weekly_stats = payload.weekly_stats + self._schedules = payload.schedules self._populate_filters() - self._initializing = False self._compute_projections() - self.query_one("#ss-loading").display = False - self.query_one("#ss-content").display = True - - def _on_data_error(self, error: str) -> None: - self.query_one("#ss-loading").display = False - self.notify(f"Failed to load data: {error}", severity="error", timeout=10) # ── filters ────────────────────────────────────────────── @@ -126,101 +127,89 @@ def _populate_filters(self) -> None: seasons = sorted(reg["season"].unique().drop_nulls().to_list()) season_select = self.query_one("#ss-filter-season", Select) season_select.set_options([(str(s), s) for s in seasons]) - season_select.value = seasons[-1] + if seasons: + season_select.value = seasons[-1] self._update_week_and_team_options() def _update_week_and_team_options(self) -> None: if self._schedules is None: return - season = self.query_one("#ss-filter-season", Select).value - if season is Select.BLANK: + season = self.selected("ss-filter-season") + if season is None: return - reg = self._schedules.filter( - (pl.col("season") == season) & (pl.col("game_type") == "REG") - ) + reg = self._schedules.filter((pl.col("season") == season) & (pl.col("game_type") == "REG")) weeks = sorted(reg["week"].unique().drop_nulls().to_list()) week_select = self.query_one("#ss-filter-week", Select) week_select.set_options([(f"Week {w}", w) for w in weeks]) - # Default to latest week with games - week_select.value = weeks[-1] if weeks else Select.BLANK + # The week with the most recent results is the one a lineup decision is about. + if weeks: + week_select.value = weeks[-1] teams = sorted( set(reg["home_team"].unique().drop_nulls().to_list()) | set(reg["away_team"].unique().drop_nulls().to_list()) ) team_select = self.query_one("#ss-filter-team", Select) - team_select.set_options([("All", "All")] + [(t, t) for t in teams]) - team_select.value = "All" + team_select.set_options(self.all_options(teams)) + team_select.value = ALL def _compute_projections(self) -> None: + """Re-run the engine for the selected season and week. + + Projections are a function of the week, not of the position and team filters, + so a season or week change recomputes rather than re-filtering. + """ if self._weekly_stats is None or self._schedules is None: return - season = self.query_one("#ss-filter-season", Select).value - week = self.query_one("#ss-filter-week", Select).value - if season is Select.BLANK or week is Select.BLANK: + season = self.selected("ss-filter-season") + week = self.selected("ss-filter-week") + # Both selects carry the season and week integers taken from the schedule until + # a load fills their options, and a filter holding no selection projects nothing. + if not isinstance(season, int) or not isinstance(week, int): return - proj = compute_start_sit(self._weekly_stats, self._schedules, int(season), int(week)) - self._projections = proj + self._projections = compute_start_sit(self._weekly_stats, self._schedules, season, week) self._apply_filters() def _apply_filters(self) -> None: - if self._projections is None: + df = self._projections + if df is None: return - df = self._projections + # Filter expressions name columns, and a result with no rows is not required to + # declare any. Routing an empty result straight to the table keeps this view + # correct whatever shape the engine gives an empty frame, and filtering zero + # rows could only ever yield zero rows. + if df.height == 0: + self.fill_table("ss-table", df, _projection_row, NO_PROJECTIONS) + return - pos = self.query_one("#ss-filter-position", Select).value - if pos not in ("All", Select.BLANK): + pos = self.narrowing("ss-filter-position") + if pos is not None: df = df.filter(pl.col("position") == pos) - team = self.query_one("#ss-filter-team", Select).value - if team not in ("All", Select.BLANK): + team = self.narrowing("ss-filter-team") + if team is not None: df = df.filter(pl.col("team") == team) - self._fill_table(df) - - # ── table ──────────────────────────────────────────────── - - def _fill_table(self, df: pl.DataFrame) -> None: - table = self.query_one("#ss-table", DataTable) - table.clear() - for row in df.iter_rows(named=True): - table.add_row( - row["player"], - row["position"], - row["team"], - row["opponent"], - f"{row['baseline_fpts']:.1f}", - f"{row['fpts_allowed']:.1f}" if row["fpts_allowed"] is not None else "—", - f"{row['league_avg']:.1f}" if row["league_avg"] is not None else "—", - f"{row['matchup_mult']:.2f}x" if row["matchup_mult"] is not None else "—", - f"{row['projected_fpts']:.1f}", - row["confidence"], - ) + self.fill_table("ss-table", df, _projection_row, NO_MATCHES) # ── events ─────────────────────────────────────────────── - def on_select_changed(self, event: Select.Changed) -> None: - if self._initializing: - return - if event.select.id == "ss-filter-season": - self._initializing = True + def filter_changed(self, filter_id: str | None) -> None: + """Project again for a season or week change; re-filter for the rest. + + A season change rebuilds the week and team options, which reaches the engine + through the week the rebuild selects. + """ + if filter_id == "ss-filter-season": self._update_week_and_team_options() - self._initializing = False self._compute_projections() - elif event.select.id == "ss-filter-week": + elif filter_id == "ss-filter-week": self._compute_projections() else: self._apply_filters() - - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "ss-btn-refresh": - self.query_one("#ss-content").display = False - self.query_one("#ss-loading").display = True - self._activated = False - self.activate() diff --git a/src/ffb/trade_value/engine.py b/src/ffb/trade_value/engine.py index 80a4977..96fddcd 100644 --- a/src/ffb/trade_value/engine.py +++ b/src/ffb/trade_value/engine.py @@ -1,7 +1,8 @@ """Trade value computation — pure polars, no I/O. -Normalizes player value from production, remaining schedule strength, -usage, injury history, and bye weeks into a 0-100 trade value score. +Normalizes player value from production, remaining schedule strength, usage, +and injury history into a 0-100 trade value score. A bye week enters through +the count of games a player has left to play, not as a separate penalty. """ import polars as pl @@ -10,12 +11,34 @@ REGULAR_SEASON_WEEKS = 18 +# Columns and dtypes every compute_trade_values result carries, in order. +# Callers filter and index the result by name, so an empty result must be +# shaped identically to a populated one. +OUTPUT_SCHEMA = pl.Schema( + { + "player": pl.String(), + "position": pl.String(), + "team": pl.String(), + "ppg": pl.Float64(), + "games_played": pl.UInt32(), + "production_raw": pl.Float64(), + "sched_mult": pl.Float64(), + "avg_snap_pct": pl.Float64(), + "health": pl.Float64(), + "bye_week": pl.Int64(), + "trade_value": pl.Float64(), + } +) + + +def _empty_result() -> pl.DataFrame: + """Zero-row frame carrying OUTPUT_SCHEMA.""" + return pl.DataFrame(schema=OUTPUT_SCHEMA) + def _team_bye_weeks(schedules: pl.DataFrame, season: int) -> pl.DataFrame: """Determine bye week for each team in a season.""" - reg = schedules.filter( - (pl.col("season") == season) & (pl.col("game_type") == "REG") - ) + reg = schedules.filter((pl.col("season") == season) & (pl.col("game_type") == "REG")) all_weeks = set(range(1, REGULAR_SEASON_WEEKS + 1)) rows = [] @@ -23,9 +46,7 @@ def _team_bye_weeks(schedules: pl.DataFrame, season: int) -> pl.DataFrame: team_weeks = reg.select(pl.col(team_col).alias("team"), "week") rows.append(team_weeks) - played = pl.concat(rows).group_by("team").agg( - pl.col("week").unique().alias("weeks_played") - ) + played = pl.concat(rows).group_by("team").agg(pl.col("week").unique().alias("weeks_played")) bye_rows = [] for row in played.iter_rows(named=True): @@ -34,7 +55,7 @@ def _team_bye_weeks(schedules: pl.DataFrame, season: int) -> pl.DataFrame: bye_rows.append({"team": row["team"], "bye_week": min(missing)}) if not bye_rows: - return pl.DataFrame({"team": [], "bye_week": []}).cast({"bye_week": pl.Int64}) + return pl.DataFrame(schema={"team": pl.String(), "bye_week": pl.Int64()}) return pl.DataFrame(bye_rows) @@ -53,14 +74,12 @@ def _defensive_strength( & pl.col("position").is_in(OFFENSIVE_POSITIONS) ) - per_game = ( - df.group_by("opponent_team", "position", "week") - .agg(pl.col("fantasy_points_ppr").sum().alias("fpts")) + per_game = df.group_by("opponent_team", "position", "week").agg( + pl.col("fantasy_points_ppr").sum().alias("fpts") ) - return ( - per_game.group_by("opponent_team", "position") - .agg(pl.col("fpts").mean().alias("fpts_allowed")) + return per_game.group_by("opponent_team", "position").agg( + pl.col("fpts").mean().alias("fpts_allowed") ) @@ -94,9 +113,8 @@ def _remaining_schedule_factor( ) # Average opponent fpts_allowed per position per team - return ( - remaining.group_by("team", "position") - .agg(pl.col("fpts_allowed").mean().alias("sched_factor")) + return remaining.group_by("team", "position").agg( + pl.col("fpts_allowed").mean().alias("sched_factor") ) @@ -105,29 +123,29 @@ def _health_discount( ) -> pl.DataFrame: """Injury risk factor per player. More 'Out' weeks = higher risk. + Weeks reported Out come off one slate of REGULAR_SEASON_WEEKS for each + regular season the frame carries. That slate is the denominator for every + player in the frame, so two players reported Out as many weeks as each + other score the same health however their reports fall across seasons. The + missed share is capped at half the slate, flooring health at 0.5. + Returns: gsis_id, health (0.0-1.0, where 1.0 = fully healthy history). """ - outs = injuries.filter( - (pl.col("report_status") == "Out") - & (pl.col("season_type") == "REG") - ) + regular = injuries.filter(pl.col("season_type") == "REG") + total_weeks = regular["season"].n_unique() * REGULAR_SEASON_WEEKS + if total_weeks == 0: + return pl.DataFrame(schema={"gsis_id": pl.String(), "health": pl.Float64()}) - out_counts = ( - outs.group_by("gsis_id") + return ( + regular.filter(pl.col("report_status") == "Out") + .group_by("gsis_id") .agg(pl.len().alias("weeks_out")) + .select( + "gsis_id", + (1.0 - (pl.col("weeks_out") / total_weeks).clip(0.0, 0.5)).alias("health"), + ) ) - # Total possible weeks per player - total = injuries.filter(pl.col("season_type") == "REG").group_by("gsis_id").agg( - (pl.col("season").n_unique() * REGULAR_SEASON_WEEKS).alias("total_weeks") - ) - - health = out_counts.join(total, on="gsis_id", how="left").with_columns( - (1.0 - (pl.col("weeks_out") / pl.col("total_weeks")).clip(0.0, 0.5)).alias("health") - ) - - return health.select("gsis_id", "health") - def compute_trade_values( weekly_stats: pl.DataFrame, @@ -141,15 +159,20 @@ def compute_trade_values( """Compute trade value scores (0-100) for all offensive players. Components (weighted): - - Production (40%): season PPG × games remaining - - Schedule (20%): avg opponent defensive weakness for remaining games - - Usage (20%): snap% × target/touch share - - Health (10%): injury history discount - - Bye (10%): penalty if bye week is still upcoming + - Production (50%): season PPG × games left, where games left is the weeks + remaining in the regular season minus one for a bye still ahead + - Schedule (20%): avg opponent defensive weakness over the remaining + schedule, relative to the league average allowed at the position + - Usage (20%): avg offensive snap share + - Health (10%): share of the weeks available across the regular seasons the + injury frame carries, floored at 0.5 + + Returns a frame carrying OUTPUT_SCHEMA, empty when the season has fewer + than three weeks of results or no weeks left to play. """ - games_remaining = REGULAR_SEASON_WEEKS - current_week - if games_remaining <= 0 or current_week < 3: - return pl.DataFrame() + weeks_left = REGULAR_SEASON_WEEKS - current_week + if weeks_left <= 0 or current_week < 3: + return _empty_result() # ── Player baselines ───────────────────────────────────── baselines = ( @@ -168,22 +191,36 @@ def compute_trade_values( .rename({"player_display_name": "player", "recent_team": "team"}) ) + # ── Games left, bye-aware ──────────────────────────────── + # A bye is the only thing that varies games left across players, and so the + # only thing that keeps the production component from cancelling out of the + # max-normalization below. + byes = _team_bye_weeks(schedules, season) + baselines = baselines.join(byes, on="team", how="left").with_columns( + ( + weeks_left + - pl.when(pl.col("bye_week").is_not_null() & (pl.col("bye_week") > current_week)) + .then(1) + .otherwise(0) + ).alias("games_left") + ) + # ── Production score (raw, not normalized yet) ─────────── baselines = baselines.with_columns( - (pl.col("ppg") * games_remaining).alias("production_raw") + (pl.col("ppg") * pl.col("games_left")).alias("production_raw") ) # ── Schedule factor ────────────────────────────────────── def_str = _defensive_strength(weekly_stats, season, current_week) - league_avg = def_str.group_by("position").agg( - pl.col("fpts_allowed").mean().alias("league_avg") - ) + league_avg = def_str.group_by("position").agg(pl.col("fpts_allowed").mean().alias("league_avg")) sched = _remaining_schedule_factor(schedules, def_str, season, current_week) # Normalize: sched_factor / league_avg → multiplier around 1.0 - sched = sched.join(league_avg, on="position", how="left").with_columns( - (pl.col("sched_factor") / pl.col("league_avg")).alias("sched_mult") - ).select("team", "position", "sched_mult") + sched = ( + sched.join(league_avg, on="position", how="left") + .with_columns((pl.col("sched_factor") / pl.col("league_avg")).alias("sched_mult")) + .select("team", "position", "sched_mult") + ) baselines = baselines.join(sched, on=["team", "position"], how="left").with_columns( pl.col("sched_mult").fill_null(1.0) @@ -203,16 +240,19 @@ def compute_trade_values( .agg(pl.col("offense_pct").mean().alias("avg_snap_pct")) ) - # Normalize snap_pct to 0-1 + # offense_pct arrives as either a 0-1 fraction or 0-100 points; scale + # fractions to points so the usage component divides by a fixed 100. max_snap = snap_usage["avg_snap_pct"].max() - if max_snap is not None and max_snap <= 1.0: + if isinstance(max_snap, (int, float)) and max_snap <= 1.0: snap_usage = snap_usage.with_columns(pl.col("avg_snap_pct") * 100) snap_usage = snap_usage.join(id_map, left_on="pfr_player_id", right_on="pfr_id", how="inner") baselines = baselines.join( snap_usage.select("gsis_id", "avg_snap_pct"), - left_on="player_id", right_on="gsis_id", how="left", + left_on="player_id", + right_on="gsis_id", + how="left", ).with_columns(pl.col("avg_snap_pct").fill_null(50.0)) # ── Health discount ────────────────────────────────────── @@ -221,51 +261,36 @@ def compute_trade_values( health, left_on="player_id", right_on="gsis_id", how="left" ).with_columns(pl.col("health").fill_null(1.0)) - # ── Bye penalty ────────────────────────────────────────── - byes = _team_bye_weeks(schedules, season) - baselines = baselines.join(byes, on="team", how="left").with_columns( - pl.when(pl.col("bye_week").is_not_null() & (pl.col("bye_week") > current_week)) - .then(pl.lit(0.0)) - .otherwise(pl.lit(1.0)) - .alias("bye_factor") - ) - # ── Composite trade value ──────────────────────────────── # Normalize each component to 0-1 range, then weight baselines = baselines.with_columns( # Production: normalize by max (pl.col("production_raw") / pl.col("production_raw").max()).alias("prod_norm"), # Schedule: already around 1.0, normalize to 0-1 - ((pl.col("sched_mult") - pl.col("sched_mult").min()) - / (pl.col("sched_mult").max() - pl.col("sched_mult").min() + 1e-9)).alias("sched_norm"), + ( + (pl.col("sched_mult") - pl.col("sched_mult").min()) + / (pl.col("sched_mult").max() - pl.col("sched_mult").min() + 1e-9) + ).alias("sched_norm"), # Usage: snap_pct / 100 (pl.col("avg_snap_pct") / 100).alias("usage_norm"), ) baselines = baselines.with_columns( ( - pl.col("prod_norm") * 0.40 + pl.col("prod_norm") * 0.50 + pl.col("sched_norm") * 0.20 + pl.col("usage_norm") * 0.20 + pl.col("health") * 0.10 - + pl.col("bye_factor") * 0.10 ).alias("trade_value_raw") ) # Scale to 0-100 tv_max = baselines["trade_value_raw"].max() - if tv_max is not None and tv_max > 0: + if isinstance(tv_max, (int, float)) and tv_max > 0: baselines = baselines.with_columns( (pl.col("trade_value_raw") / tv_max * 100).round(1).alias("trade_value") ) else: baselines = baselines.with_columns(pl.lit(0.0).alias("trade_value")) - return ( - baselines.select( - "player", "position", "team", "ppg", "games_played", - "production_raw", "sched_mult", "avg_snap_pct", "health", - "bye_week", "trade_value", - ) - .sort("trade_value", descending=True) - ) + return baselines.select(*OUTPUT_SCHEMA).cast(OUTPUT_SCHEMA).sort("trade_value", descending=True) diff --git a/src/ffb/trade_value/screen.py b/src/ffb/trade_value/screen.py index dbff1ca..201a91f 100644 --- a/src/ffb/trade_value/screen.py +++ b/src/ffb/trade_value/screen.py @@ -2,17 +2,12 @@ from __future__ import annotations +from dataclasses import dataclass +from typing import Any + import polars as pl -from textual import work from textual.app import ComposeResult -from textual.containers import Horizontal, Vertical -from textual.widget import Widget -from textual.widgets import ( - Button, - DataTable, - LoadingIndicator, - Select, -) +from textual.widgets import DataTable, Select from ffb.data.loader import ( load_injuries, @@ -21,230 +16,252 @@ load_snap_counts, load_weekly_stats, ) -from ffb.trade_value.engine import compute_trade_values +from ffb.data.seasons import recent_seasons +from ffb.trade_value.engine import REGULAR_SEASON_WEEKS, compute_trade_values +from ffb.ui.base import ALL, ToolView COLUMNS = ( - "Rank", "Player", "Pos", "Team", - "PPG", "GP", "Sched", "Snap%", - "Health", "Bye", "Value", + "Rank", + "Player", + "Pos", + "Team", + "PPG", + "GP", + "Sched", + "Snap%", + "Health", + "Bye", + "Value", ) - -class TradeValueView(Widget): +#: Seasons the load asks for, which is also what the season filter offers. Production, +#: schedule and usage read the one season selected; the health component reads injury +#: weeks across every season loaded, so a second season buys a longer availability +#: history. +SEASON_WINDOW = 2 + +#: Weeks of results a ranking rests on. The engine ranks nobody below this, so the week +#: filter offers no week below it either. +MIN_WEEKS_PLAYED = 3 + + +@dataclass(frozen=True, slots=True) +class TradeValueData: + """The frames one load hands to the view.""" + + weekly: pl.DataFrame + snaps: pl.DataFrame + schedules: pl.DataFrame + injuries: pl.DataFrame + ids: pl.DataFrame + + +def _value_row(row: dict[str, Any]) -> tuple[str, ...]: + """Map one ranked player to the cells of `COLUMNS`.""" + bye = row["bye_week"] + return ( + str(row["rank"]), + row["player"], + row["position"], + row["team"], + f"{row['ppg']:.1f}", + str(row["games_played"]), + f"{row['sched_mult']:.2f}x", + f"{row['avg_snap_pct']:.0f}%", + f"{row['health']:.0%}", + str(int(bye)) if bye is not None else "—", + f"{row['trade_value']:.1f}", + ) + + +class TradeValueView(ToolView): """Rest-of-season trade value chart.""" - DEFAULT_CSS = """ - TradeValueView { - height: 1fr; - width: 1fr; - } - - #tv-loading { - height: 1fr; - } - - #tv-content { - height: 1fr; - } - - .filter-bar { - height: auto; - max-height: 5; - padding: 1; - background: $surface-darken-1; - } - - .filter-bar Select { - width: 1fr; - margin-right: 1; - } - - .filter-bar #tv-btn-refresh { - margin-left: 1; - } - """ + ID_PREFIX = "tv" + LOAD_LABEL = "Loading weekly stats, snap counts, schedules and injuries" - def __init__(self, **kwargs) -> None: + def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) + self._data: TradeValueData | None = None self._values: pl.DataFrame | None = None - self._activated = False - self._initializing = True - - def compose(self) -> ComposeResult: - yield LoadingIndicator(id="tv-loading") - with Vertical(id="tv-content"): - with Horizontal(classes="filter-bar"): - yield Select[str]( - [("All", "All"), ("QB", "QB"), ("RB", "RB"), ("WR", "WR"), ("TE", "TE")], - value="All", - id="tv-filter-position", - ) - yield Select[str]([], prompt="Team", id="tv-filter-team") - yield Select[int]([], prompt="Season", id="tv-filter-season") - yield Select[int]([], prompt="As of Week", id="tv-filter-week") - yield Button("Refresh", id="tv-btn-refresh", variant="primary") - yield DataTable(id="tv-table") + + def compose_content(self) -> ComposeResult: + yield from self.compose_filter_bar( + self.position_select(), + Select[str]([], prompt="Team", id="tv-filter-team"), + Select[int]([], prompt="Season", id="tv-filter-season"), + Select[int]([], prompt="As of Week", id="tv-filter-week"), + ) + yield DataTable(id="tv-table") def on_mount(self) -> None: - self.query_one("#tv-content").display = False table = self.query_one("#tv-table", DataTable) table.add_columns(*COLUMNS) table.cursor_type = "row" - def activate(self) -> None: - if not self._activated: - self._activated = True - self._fetch_data() - - @work(thread=True, exclusive=True) - def _fetch_data(self, force_refresh: bool = False) -> None: - try: - weekly = load_weekly_stats([2024, 2025], force_refresh=force_refresh) - snaps = load_snap_counts([2024, 2025], force_refresh=force_refresh) - schedules = load_schedules([2024, 2025], force_refresh=force_refresh) - injuries = load_injuries([2024, 2025], force_refresh=force_refresh) - ids = load_player_ids(force_refresh=force_refresh) - self.app.call_from_thread( - self._on_data_loaded, weekly, snaps, schedules, injuries, ids - ) - except Exception as e: - self.app.call_from_thread(self._on_data_error, str(e)) - - def _on_data_loaded( - self, - weekly: pl.DataFrame, - snaps: pl.DataFrame, - schedules: pl.DataFrame, - injuries: pl.DataFrame, - ids: pl.DataFrame, - ) -> None: - self._weekly = weekly - self._snaps = snaps - self._schedules = schedules - self._injuries = injuries - self._ids = ids - - self._initializing = True + # ── load ───────────────────────────────────────────────── + + def fetch(self, force_refresh: bool) -> TradeValueData: + seasons = recent_seasons(SEASON_WINDOW) + return TradeValueData( + weekly=load_weekly_stats(seasons, force_refresh=force_refresh), + snaps=load_snap_counts(seasons, force_refresh=force_refresh), + schedules=load_schedules(seasons, force_refresh=force_refresh), + injuries=load_injuries(seasons, force_refresh=force_refresh), + ids=load_player_ids(force_refresh=force_refresh), + ) + + def apply(self, payload: TradeValueData) -> None: + self._data = payload self._populate_filters() - self._initializing = False self._compute_values() - self.query_one("#tv-loading").display = False - self.query_one("#tv-content").display = True - - def _on_data_error(self, error: str) -> None: - self.query_one("#tv-loading").display = False - self.notify(f"Failed to load data: {error}", severity="error", timeout=10) # ── filters ────────────────────────────────────────────── def _populate_filters(self) -> None: - if self._schedules is None: + data = self._data + if data is None: return - reg = self._schedules.filter(pl.col("game_type") == "REG") + reg = data.schedules.filter(pl.col("game_type") == "REG") seasons = sorted(reg["season"].unique().drop_nulls().to_list()) season_select = self.query_one("#tv-filter-season", Select) season_select.set_options([(str(s), s) for s in seasons]) - season_select.value = seasons[-1] + if seasons: + season_select.value = seasons[-1] self._update_week_and_team_options() def _update_week_and_team_options(self) -> None: - if self._weekly is None or self._schedules is None: + data = self._data + if data is None: return - season = self.query_one("#tv-filter-season", Select).value - if season is Select.BLANK: + season = self.selected("tv-filter-season") + if season is None: return - # Weeks with data - played = self._weekly.filter( - (pl.col("season") == season) & (pl.col("season_type") == "REG") - )["week"].unique().drop_nulls().to_list() - # Only show weeks where games remain (exclude final week) - weeks = sorted([w for w in played if 3 <= w < 18]) + # Weeks the season has results for. + played = ( + data.weekly.filter((pl.col("season") == season) & (pl.col("season_type") == "REG"))[ + "week" + ] + .unique() + .drop_nulls() + .to_list() + ) + # A trade value is worth what a player brings over the weeks left, so the last + # week of the regular season is out along with the weeks too early to rank. + weeks = sorted([w for w in played if MIN_WEEKS_PLAYED <= w < REGULAR_SEASON_WEEKS]) week_select = self.query_one("#tv-filter-week", Select) week_select.set_options([(f"Week {w}", w) for w in weeks]) - week_select.value = weeks[-1] if weeks else Select.BLANK + if weeks: + week_select.value = weeks[-1] - reg = self._schedules.filter( - (pl.col("season") == season) & (pl.col("game_type") == "REG") - ) + reg = data.schedules.filter((pl.col("season") == season) & (pl.col("game_type") == "REG")) teams = sorted( set(reg["home_team"].unique().drop_nulls().to_list()) | set(reg["away_team"].unique().drop_nulls().to_list()) ) team_select = self.query_one("#tv-filter-team", Select) - team_select.set_options([("All", "All")] + [(t, t) for t in teams]) - team_select.value = "All" + team_select.set_options(self.all_options(teams)) + team_select.value = ALL def _compute_values(self) -> None: - season = self.query_one("#tv-filter-season", Select).value - week = self.query_one("#tv-filter-week", Select).value - if season is Select.BLANK or week is Select.BLANK: + """Rank the selected season through the selected week.""" + data = self._data + if data is None: + return + + season = self.selected("tv-filter-season") + week = self.selected("tv-filter-week") + # Both filters hold no selection until a load fills their options. + if not isinstance(season, int) or not isinstance(week, int): + self._values = None + self.fill_table( + "tv-table", + pl.DataFrame(), + _value_row, + "Pick a season and a week to rank rest-of-season trade value.", + ) return self._values = compute_trade_values( - self._weekly, self._snaps, self._schedules, - self._injuries, self._ids, - season=int(season), current_week=int(week), + data.weekly, + data.snaps, + data.schedules, + data.injuries, + data.ids, + season=season, + current_week=week, ) self._apply_filters() def _apply_filters(self) -> None: - if self._values is None: + values = self._values + if values is None: return - df = self._values + season = self.selected("tv-filter-season") + week = self.selected("tv-filter-week") - pos = self.query_one("#tv-filter-position", Select).value - if pos not in ("All", Select.BLANK): - df = df.filter(pl.col("position") == pos) - - team = self.query_one("#tv-filter-team", Select).value - if team not in ("All", Select.BLANK): - df = df.filter(pl.col("team") == team) + # Emptiness is settled before any column is named: a row-less result owes the + # caller no columns, and naming one on such a frame raises. + if values.height == 0: + self.fill_table("tv-table", values, _value_row, _unranked_message(season, week)) + return - self._fill_table(df) + pos = self.narrowing("tv-filter-position") + team = self.narrowing("tv-filter-team") - # ── table ──────────────────────────────────────────────── + df = values + if pos is not None: + df = df.filter(pl.col("position") == pos) + if team is not None: + df = df.filter(pl.col("team") == team) - def _fill_table(self, df: pl.DataFrame) -> None: - table = self.query_one("#tv-table", DataTable) - table.clear() - for i, row in enumerate(df.iter_rows(named=True), 1): - bye = row["bye_week"] - table.add_row( - str(i), - row["player"], - row["position"], - row["team"], - f"{row['ppg']:.1f}", - str(row["games_played"]), - f"{row['sched_mult']:.2f}x", - f"{row['avg_snap_pct']:.0f}%", - f"{row['health']:.0%}", - str(int(bye)) if bye is not None else "—", - f"{row['trade_value']:.1f}", - ) + self.fill_table( + "tv-table", + df.with_row_index("rank", offset=1), + _value_row, + _no_match_message(pos, team, season, week), + ) # ── events ─────────────────────────────────────────────── - def on_select_changed(self, event: Select.Changed) -> None: - if self._initializing: - return - if event.select.id in ("tv-filter-season", "tv-filter-week"): - if event.select.id == "tv-filter-season": - self._initializing = True + def filter_changed(self, filter_id: str | None) -> None: + """Rank again for a season or week change; re-filter the ranking for the rest. + + A season change rebuilds the week and team options first, so the ranking is + computed for a week the selected season played. + """ + if filter_id in ("tv-filter-season", "tv-filter-week"): + if filter_id == "tv-filter-season": self._update_week_and_team_options() - self._initializing = False self._compute_values() else: self._apply_filters() - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "tv-btn-refresh": - self.query_one("#tv-content").display = False - self.query_one("#tv-loading").display = True - self._activated = False - self.activate() + +def _unranked_message(season: Any, week: Any) -> str: + """Say why a chosen season and week rank nobody.""" + return ( + f"No trade values for {season} week {week}. A ranking needs " + f"{MIN_WEEKS_PLAYED} weeks of results behind it and a week still to play." + ) + + +def _no_match_message(pos: Any, team: Any, season: Any, week: Any) -> str: + """Name the filters that narrowed a populated ranking down to nothing. + + `pos` and `team` are what those filters narrow to, so None names neither. + """ + if pos is not None and team is not None: + who = f"{pos} on {team}" + elif pos is not None: + who = f"{pos} players" + elif team is not None: + who = f"{team} players" + else: + who = "players" + return f"No {who} in the {season} week {week} rankings." diff --git a/src/ffb/ui/__init__.py b/src/ffb/ui/__init__.py new file mode 100644 index 0000000..f3359f2 --- /dev/null +++ b/src/ffb/ui/__init__.py @@ -0,0 +1,5 @@ +"""Shared Textual widgets for the tool views.""" + +from ffb.ui.base import POSITIONS, RowFn, ToolView + +__all__ = ["POSITIONS", "RowFn", "ToolView"] diff --git a/src/ffb/ui/base.py b/src/ffb/ui/base.py new file mode 100644 index 0000000..51d41a7 --- /dev/null +++ b/src/ffb/ui/base.py @@ -0,0 +1,455 @@ +"""Shared chrome and load lifecycle for the tool views.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Sequence +from time import monotonic +from typing import Any, ClassVar, TypeVar + +import polars as pl +from textual.app import ComposeResult +from textual.binding import BindingType +from textual.containers import Center, Horizontal, Vertical +from textual.timer import Timer +from textual.widget import Widget +from textual.widgets import Button, DataTable, LoadingIndicator, Select, Static +from textual.worker import Worker, get_current_worker + +#: Fantasy-relevant positions in depth-chart order, as the position filter lists them. +POSITIONS: tuple[str, ...] = ("QB", "RB", "WR", "TE") + +#: The option a filter carries for "every value qualifies". `all_options` puts it in +#: front of every list, and `narrowing` reads a filter holding it as narrowing nothing. +ALL = "All" + +#: Maps one row of a frame, keyed by column name, to the cells of one table row. +RowFn = Callable[[dict[str, Any]], Sequence[Any]] + +#: What one unit of off-thread work hands back to the UI thread. +Result = TypeVar("Result") + +#: One filter's id paired with the value it holds. +FilterState = tuple[tuple[str | None, Any], ...] + + +class ToolView(Widget): + """A tool: one download off the UI thread, one render on it, one refresh path. + + A subclass supplies `ID_PREFIX`, `LOAD_LABEL`, `compose_content`, `fetch`, `apply` + and, where it carries filters, `filter_changed`. Everything else — the loading pane, + the elapsed-time readout, cancel, refresh, filter reads, error reporting and + empty-result rendering — is handled here so that all six tools answer those + questions the same way. + + Widget ids are derived from `ID_PREFIX`: `{prefix}-loading`, `{prefix}-content`, + `{prefix}-load-status`, `{prefix}-btn-cancel` and `{prefix}-btn-refresh`. + """ + + #: Id namespace for this tool's widgets. + ID_PREFIX: ClassVar[str] + + #: What the load downloads, named on the loading pane next to the elapsed time. + LOAD_LABEL: ClassVar[str] + + BINDINGS: ClassVar[list[BindingType]] = [("escape", "cancel_load", "Cancel")] + + DEFAULT_CSS = """ + ToolView { + height: 1fr; + width: 1fr; + } + + .tool-loading { + height: 1fr; + width: 1fr; + align: center middle; + } + + .tool-loading LoadingIndicator { + height: 3; + } + + .load-status { + width: 1fr; + height: auto; + padding: 1 0; + color: $text-muted; + text-align: center; + } + + .load-cancel { + width: auto; + } + + .tool-content { + height: 1fr; + width: 1fr; + } + + .filter-bar { + height: auto; + max-height: 5; + padding: 1; + background: $surface-darken-1; + } + + .filter-bar Select { + width: 1fr; + margin-right: 1; + } + + .filter-bar Input { + width: 2fr; + margin-right: 1; + } + + .filter-bar Button { + margin-left: 1; + } + + .alert-panels { + height: 1fr; + } + + .alert-panel { + width: 1fr; + margin: 0 1; + } + + .panel-header-rising { + color: $success; + text-style: bold; + padding: 1 0 0 1; + } + + .panel-header-falling { + color: $error; + text-style: bold; + padding: 1 0 0 1; + } + + .empty-state { + display: none; + height: auto; + width: 1fr; + padding: 2 4; + color: $text-muted; + text-align: center; + } + """ + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._activated = False + self._loading = False + self._load_worker: Worker[None] | None = None + self._load_started = 0.0 + self._elapsed_timer: Timer | None = None + self._rendered_filters: FilterState = () + + # ── composition ────────────────────────────────────────── + + def compose(self) -> ComposeResult: + with Vertical(id=f"{self.ID_PREFIX}-loading", classes="tool-loading"): + yield LoadingIndicator() + yield Static("", id=f"{self.ID_PREFIX}-load-status", classes="load-status") + with Center(): + yield Button("Cancel", id=f"{self.ID_PREFIX}-btn-cancel", classes="load-cancel") + with Vertical(id=f"{self.ID_PREFIX}-content", classes="tool-content"): + yield from self.compose_content() + + def compose_content(self) -> ComposeResult: + """Yield the tool's own widgets: filter bar, tabs, tables. + + The result is wrapped in the content container, which stays hidden until a load + succeeds. Subclasses implement this rather than `compose` so that every tool + gets the same loading pane. + """ + raise NotImplementedError(f"{type(self).__name__} must implement compose_content()") + + def compose_filter_bar(self, *controls: Widget) -> ComposeResult: + """Yield the filter bar: `controls` followed by the refresh button.""" + with Horizontal(classes="filter-bar"): + yield from controls + yield Button("Refresh", id=f"{self.ID_PREFIX}-btn-refresh", variant="primary") + + def position_select(self) -> Select[str]: + """Build the position filter, defaulted to `ALL`.""" + return Select[str]( + self.all_options(POSITIONS), + value=ALL, + id=f"{self.ID_PREFIX}-filter-position", + ) + + def on_mount(self) -> None: + self.query_one(f"#{self.ID_PREFIX}-loading").display = False + self.query_one(f"#{self.ID_PREFIX}-content").display = False + for table in self.query(DataTable): + self._pair_empty_state(table) + # A filter built with a default value posts that value as a change once it is + # mounted; recording the filters here leaves that message nothing to re-render. + self._rendered_filters = self._filter_state() + + def _pair_empty_state(self, table: DataTable[Any]) -> None: + """Give `table` the label that stands in for it when a result has no rows. + + Pairing every table here rather than in each tool's `compose` keeps the id + convention `fill_table` relies on — `{table_id}-empty` — true by construction. + """ + table_id = table.id + parent = table.parent + if table_id is None or not isinstance(parent, Widget): + return + if self.query(f"#{table_id}-empty"): + return + parent.mount(Static("", id=f"{table_id}-empty", classes="empty-state"), after=table) + + # ── lifecycle ──────────────────────────────────────────── + + def activate(self) -> None: + """Load this tool's data the first time it is shown.""" + if not self._activated: + self._start_load(force_refresh=False) + + def fetch(self, force_refresh: bool) -> object: + """Download and compute everything this tool renders, off the UI thread. + + Returns one payload object, handed straight to `apply`. Raising here routes the + message to the error toast. Honour `force_refresh` by passing it to every + loader call: the refresh button promises a fresh download, not a cache hit. + """ + raise NotImplementedError(f"{type(self).__name__} must implement fetch()") + + def apply(self, payload: Any) -> None: + """Store the payload, populate the filters and fill the tables, on the UI thread. + + Setting filter options and values here costs no extra render: `filter_changed` + runs only for a filter state that differs from the one this method leaves + behind. The parameter is typed `Any` so a subclass can narrow it to the type its + `fetch` returns. + """ + raise NotImplementedError(f"{type(self).__name__} must implement apply()") + + def _start_load(self, force_refresh: bool) -> None: + self._activated = True + self._loading = True + self.query_one(f"#{self.ID_PREFIX}-content").display = False + self.query_one(f"#{self.ID_PREFIX}-loading").display = True + self._start_elapsed() + self._load_worker = self.run_off_thread( + lambda: self.fetch(force_refresh), + self._on_data_loaded, + self._on_data_error, + group="tool-load", + ) + # Escape reaches this view's bindings only from a focused descendant, and the + # cancel button is the one focusable widget on show during a load. + self.query_one(f"#{self.ID_PREFIX}-btn-cancel", Button).focus() + self.refresh_bindings() + + def run_off_thread( + self, + work: Callable[[], Result], + on_result: Callable[[Result], None], + on_error: Callable[[str], None], + *, + group: str, + ) -> Worker[None]: + """Run `work` on a worker thread and answer it back on the UI thread. + + `on_result` takes what `work` returned, `on_error` the message it raised. A + worker cancelled while `work` ran answers with neither: its result belongs to a + request the view has moved past, and rendering it would overwrite the answer to + the request that replaced it. Workers sharing a `group` are exclusive, so + starting one supersedes the one before it. + """ + + def run() -> None: + worker = get_current_worker() + try: + result = work() + except Exception as error: + if not worker.is_cancelled: + self.app.call_from_thread(on_error, str(error)) + return + if not worker.is_cancelled: + self.app.call_from_thread(on_result, result) + + return self.run_worker(run, thread=True, exclusive=True, group=group) + + def report_error(self, message: str) -> None: + """Show `message` as the error toast every failure in this view reports through.""" + self.notify(message, severity="error", timeout=10) + + def _on_data_loaded(self, payload: object) -> None: + self._loading = False + self._stop_elapsed() + try: + self.apply(payload) + finally: + self._rendered_filters = self._filter_state() + self._show_content() + self.refresh_bindings() + + def _on_data_error(self, error: str) -> None: + self._loading = False + self._stop_elapsed() + self._show_content() + self.refresh_bindings() + self.report_error(f"Failed to load data: {error}") + + def action_cancel_load(self) -> None: + """Stop waiting on the running load and show the tool again.""" + self._cancel_load() + + def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: + """Offer the cancel binding only while a load is in flight. + + `False` disables the binding and drops it from the footer, so escape advertises + cancelling exactly when there is something to cancel. + """ + if action == "cancel_load": + return self._loading + return True + + def _cancel_load(self) -> None: + """Discard the running load's result and restore the previous content. + + A thread worker cannot be interrupted, so the download runs to completion or to + its timeout in the background; cancelling marks its result stale, and + `run_off_thread` drops that result rather than rendering it. + """ + if not self._loading: + return + self._loading = False + if self._load_worker is not None: + self._load_worker.cancel() + self._stop_elapsed() + self._show_content() + self.refresh_bindings() + self.notify("Load cancelled.", severity="warning", timeout=5) + + def _show_content(self) -> None: + content = self.query_one(f"#{self.ID_PREFIX}-content") + loading = self.query_one(f"#{self.ID_PREFIX}-loading") + content.display = True + cancel = self.query_one(f"#{self.ID_PREFIX}-btn-cancel", Button) + if self.screen.focused is cancel: + self.screen.focus_next() + loading.display = False + + # ── elapsed time ───────────────────────────────────────── + + def _start_elapsed(self) -> None: + self._load_started = monotonic() + self._render_elapsed() + if self._elapsed_timer is None: + self._elapsed_timer = self.set_interval(1.0, self._render_elapsed) + + def _stop_elapsed(self) -> None: + if self._elapsed_timer is not None: + self._elapsed_timer.stop() + self._elapsed_timer = None + + def _render_elapsed(self) -> None: + """Report the load as a named target and the time spent on it. + + nfl_data_py downloads a release asset in one blocking call and reports no + progress, so a fraction or a bar would be invented. Elapsed time is measured. + """ + minutes, seconds = divmod(int(monotonic() - self._load_started), 60) + status = self.query_one(f"#{self.ID_PREFIX}-load-status", Static) + status.update(f"{self.LOAD_LABEL} — {minutes:02d}:{seconds:02d}") + + # ── filters ────────────────────────────────────────────── + + def all_options(self, values: Iterable[str]) -> list[tuple[str, str]]: + """Build a filter's options: `values`, behind the `ALL` entry that narrows nothing.""" + return [(ALL, ALL), *((value, value) for value in values)] + + def selected(self, filter_id: str) -> Any | None: + """Return what the named filter holds, or None where it holds no selection. + + A `Select` with nothing selected holds a sentinel object rather than a value, + and polars builds no expression from one. Every filter read goes through here, + so no caller has to know the sentinel exists. + """ + return self.query_one(f"#{filter_id}", Select).selection + + def narrowing(self, filter_id: str) -> Any | None: + """Return what the named filter narrows to, or None where it narrows nothing. + + `ALL` and no selection both leave every row in, so a caller filters on exactly + the values this returns and skips the filter for None. + """ + value = self.selected(filter_id) + return None if value == ALL else value + + def filter_changed(self, filter_id: str | None) -> None: + """Re-render for the filters as they read, `filter_id` naming the one that moved. + + Called once per change to a filter this view carries. Read the filters through + `selected` and `narrowing` rather than from the id: a change to one filter can + reset the options of another, and the render answers to all of them. + """ + raise NotImplementedError(f"{type(self).__name__} must implement filter_changed()") + + def _filter_state(self) -> FilterState: + """Every filter this view carries, paired with the value it holds.""" + return tuple((select.id, select.value) for select in self.query(Select)) + + # ── tables ─────────────────────────────────────────────── + + def fill_table( + self, + table_id: str, + df: pl.DataFrame, + row_fn: RowFn, + empty_message: str, + ) -> None: + """Render `df` into a table, or put `empty_message` where the table would be. + + A result with no rows is otherwise indistinguishable from one still loading or + one filtered down to nothing, so the message says which. `row_fn` maps a row to + its cells and is called only for rows that exist. + + Emptiness is read from the frame's shape, never from a column: a frame carrying + no columns at all answers `height`, and raises on any column reference. + """ + table = self.query_one(f"#{table_id}", DataTable) + label = self.query_one(f"#{table_id}-empty", Static) + table.clear() + if df.height == 0: + label.update(empty_message) + label.display = True + table.display = False + return + label.display = False + table.display = True + for row in df.iter_rows(named=True): + table.add_row(*row_fn(row)) + + # ── events ─────────────────────────────────────────────── + + def on_button_pressed(self, event: Button.Pressed) -> None: + button_id = event.button.id + if button_id == f"{self.ID_PREFIX}-btn-refresh": + self._start_load(force_refresh=True) + elif button_id == f"{self.ID_PREFIX}-btn-cancel": + self._cancel_load() + + def on_select_changed(self, event: Select.Changed) -> None: + """Hand a filter change to `filter_changed`, once per distinct set of values. + + Setting a `Select`'s options or its value posts `Changed` to the message queue + rather than raising it inline, so the messages a load's filter population raises + arrive after the render they would repeat, and so do the messages a + `filter_changed` raises while rebuilding one filter's options from another. + Reading the filters as they stand on delivery and comparing them against the + values behind the last render tells an echo from a change a user made: one pass + per change, none per echo. + """ + if self._filter_state() == self._rendered_filters: + return + self.filter_changed(event.select.id) + self._rendered_filters = self._filter_state() diff --git a/src/ffb/waiver_wire/engine.py b/src/ffb/waiver_wire/engine.py index 1bac843..4f7ea3c 100644 --- a/src/ffb/waiver_wire/engine.py +++ b/src/ffb/waiver_wire/engine.py @@ -17,24 +17,26 @@ def compute_usage_trends( ) -> pl.DataFrame: """Compute composite usage scores and trends per player per week. - Returns: player, position, team, season, week, snap_pct, tgt_share, - touch_share, usage_score, rolling_avg, delta, velocity, trend. + Returns: gsis_id, player, position, team, season, week, snap_pct, + tgt_share, touch_share, usage_score, rolling_avg, delta, velocity, trend. + + Every rolling window groups by ``gsis_id``. Display names collide across + the league — multiple active players share a name in any given season — + so a name-keyed window splices two players into one series. """ if window < 2: raise ValueError(f"window must be >= 2, got {window}") # ── Snap data ──────────────────────────────────────────── snap_df = ( - snaps.filter( - pl.col("position").is_in(OFFENSIVE_POSITIONS) - & (pl.col("game_type") == "REG") - ) + snaps.filter(pl.col("position").is_in(OFFENSIVE_POSITIONS) & (pl.col("game_type") == "REG")) .select("pfr_player_id", "player", "position", "team", "season", "week", "offense_pct") .drop_nulls(subset=["offense_pct", "pfr_player_id"]) ) # Normalize to 0-100 - if snap_df["offense_pct"].max() is not None and snap_df["offense_pct"].max() <= 1.0: + max_pct = snap_df["offense_pct"].max() + if isinstance(max_pct, (int, float)) and max_pct <= 1.0: snap_df = snap_df.with_columns(pl.col("offense_pct") * 100) # ── Map pfr_id → gsis_id ──────────────────────────────── @@ -50,9 +52,7 @@ def compute_usage_trends( pl.col("targets").fill_null(0), pl.col("carries").fill_null(0), ) - .with_columns( - (pl.col("targets") + pl.col("carries")).alias("touches") - ) + .with_columns((pl.col("targets") + pl.col("carries")).alias("touches")) ) # ── Join snap data with stats ──────────────────────────── @@ -64,12 +64,9 @@ def compute_usage_trends( ) # ── Team totals per week (for share calculations) ──────── - team_totals = ( - df.group_by("team", "season", "week") - .agg( - pl.col("targets").sum().alias("team_targets"), - pl.col("touches").sum().alias("team_touches"), - ) + team_totals = df.group_by("team", "season", "week").agg( + pl.col("targets").sum().alias("team_targets"), + pl.col("touches").sum().alias("team_touches"), ) df = df.join(team_totals, on=["team", "season", "week"], how="left") @@ -91,35 +88,31 @@ def compute_usage_trends( # ── Composite usage score ──────────────────────────────── df = df.with_columns( ( - pl.col("snap_pct") * 0.4 - + pl.col("tgt_share") * 0.35 - + pl.col("touch_share") * 0.25 + pl.col("snap_pct") * 0.4 + pl.col("tgt_share") * 0.35 + pl.col("touch_share") * 0.25 ).alias("usage_score") ) # ── Trend computation (same pattern as snap_share) ─────── - df = df.sort("player", "season", "week") + df = df.sort("gsis_id", "season", "week") # Rolling average of previous weeks df = df.with_columns( pl.col("usage_score") .shift(1) .rolling_mean(window_size=window, min_samples=1) - .over("player", "season") + .over("gsis_id", "season") .alias("rolling_avg") ) # Delta - df = df.with_columns( - (pl.col("usage_score") - pl.col("rolling_avg")).alias("delta") - ) + df = df.with_columns((pl.col("usage_score") - pl.col("rolling_avg")).alias("delta")) # Velocity (slope) df = df.with_columns( ( ( pl.col("usage_score") - - pl.col("usage_score").shift(window - 1).over("player", "season") + - pl.col("usage_score").shift(window - 1).over("gsis_id", "season") ) / (window - 1) ).alias("velocity") @@ -136,11 +129,19 @@ def compute_usage_trends( ) # Drop incomplete rows and select output columns - return ( - df.drop_nulls(subset=["rolling_avg", "velocity"]) - .select( - "player", "position", "team", "season", "week", - "snap_pct", "tgt_share", "touch_share", "usage_score", - "rolling_avg", "delta", "velocity", "trend", - ) + return df.drop_nulls(subset=["rolling_avg", "velocity"]).select( + "gsis_id", + "player", + "position", + "team", + "season", + "week", + "snap_pct", + "tgt_share", + "touch_share", + "usage_score", + "rolling_avg", + "delta", + "velocity", + "trend", ) diff --git a/src/ffb/waiver_wire/screen.py b/src/ffb/waiver_wire/screen.py index a0272b2..bf4a706 100644 --- a/src/ffb/waiver_wire/screen.py +++ b/src/ffb/waiver_wire/screen.py @@ -2,152 +2,119 @@ from __future__ import annotations +from typing import Any + import polars as pl -from textual import work from textual.app import ComposeResult from textual.containers import Horizontal, Vertical -from textual.widget import Widget -from textual.widgets import ( - Button, - DataTable, - LoadingIndicator, - Select, - Static, - TabbedContent, - TabPane, -) +from textual.widgets import DataTable, Select, Static, TabbedContent, TabPane from ffb.data.loader import load_player_ids, load_snap_counts, load_weekly_stats +from ffb.data.seasons import recent_seasons +from ffb.ui.base import ALL, ToolView from ffb.waiver_wire.engine import compute_usage_trends COLUMNS = ( - "Player", "Pos", "Team", "Wk", - "Snap%", "Tgt%", "Tch%", "Usage", - "Avg", "Δ", "Vel", "Trend", + "Player", + "Pos", + "Team", + "Wk", + "Snap%", + "Tgt%", + "Tch%", + "Usage", + "Avg", + "Δ", + "Vel", + "Trend", ) +#: How many seasons of weekly data the season filter offers. +SEASON_COUNT = 2 -class WaiverWireView(Widget): - """Free agent usage trend scanner.""" +#: Stands in for a measurement a player's week does not carry. +MISSING = "—" - DEFAULT_CSS = """ - WaiverWireView { - height: 1fr; - width: 1fr; - } - - #ww-loading { - height: 1fr; - } - - #ww-content { - height: 1fr; - } - - .filter-bar { - height: auto; - max-height: 5; - padding: 1; - background: $surface-darken-1; - } - - .filter-bar Select { - width: 1fr; - margin-right: 1; - } - - .filter-bar #ww-btn-refresh { - margin-left: 1; - } - - .alert-panels { - height: 1fr; - } - - .alert-panel { - width: 1fr; - margin: 0 1; - } - - .panel-header-rising { - color: $success; - text-style: bold; - padding: 1 0 0 1; - } - - .panel-header-falling { - color: $error; - text-style: bold; - padding: 1 0 0 1; - } + +def _fmt(value: float | None, spec: str, suffix: str = "") -> str: + """Render `value` under `spec` with `suffix`, or as an em dash where it is absent. + + A player can reach a week with no snap or touch data — an inactive week, or one + nflverse has not published — and a blank cell reads as a zero. """ + return f"{value:{spec}}{suffix}" if value is not None else MISSING + + +def _cells(row: dict[str, Any]) -> tuple[str, ...]: + """Map one trend row to the twelve cells of `COLUMNS`.""" + return ( + row["player"], + row["position"], + row["team"], + str(row["week"]), + _fmt(row["snap_pct"], ".1f", "%"), + _fmt(row["tgt_share"], ".1f", "%"), + _fmt(row["touch_share"], ".1f", "%"), + _fmt(row["usage_score"], ".1f"), + _fmt(row["rolling_avg"], ".1f"), + _fmt(row["delta"], "+.1f"), + _fmt(row["velocity"], "+.1f"), + row["trend"], + ) + + +class WaiverWireView(ToolView): + """Free agent usage trend scanner.""" - def __init__(self, **kwargs) -> None: + ID_PREFIX = "ww" + LOAD_LABEL = "Downloading snap counts, weekly stats and player ids" + + def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) self._trends: pl.DataFrame | None = None - self._activated = False - self._initializing = True - - def compose(self) -> ComposeResult: - yield LoadingIndicator(id="ww-loading") - with Vertical(id="ww-content"): - with Horizontal(classes="filter-bar"): - yield Select[str]( - [("All", "All"), ("QB", "QB"), ("RB", "RB"), ("WR", "WR"), ("TE", "TE")], - value="All", - id="ww-filter-position", - ) - yield Select[str]([], prompt="Team", id="ww-filter-team") - yield Select[int]([], prompt="Season", id="ww-filter-season") - yield Select[int]([], prompt="Week", id="ww-filter-week") - yield Button("Refresh", id="ww-btn-refresh", variant="primary") - with TabbedContent(initial="ww-tab-rising"): - with TabPane("Rising", id="ww-tab-rising"): - with Horizontal(classes="alert-panels"): - with Vertical(classes="alert-panel"): - yield Static("▲ Rising Usage", classes="panel-header-rising") - yield DataTable(id="ww-rising-table") - with Vertical(classes="alert-panel"): - yield Static("▼ Falling Usage", classes="panel-header-falling") - yield DataTable(id="ww-falling-table") - with TabPane("All Players", id="ww-tab-all"): - yield DataTable(id="ww-all-table") + + def compose_content(self) -> ComposeResult: + yield from self.compose_filter_bar( + self.position_select(), + Select[str]([], prompt="Team", id="ww-filter-team"), + Select[int]([], prompt="Season", id="ww-filter-season"), + Select[int]([], prompt="Week", id="ww-filter-week"), + ) + with TabbedContent(initial="ww-tab-rising"): + with TabPane("Rising", id="ww-tab-rising"): + with Horizontal(classes="alert-panels"): + with Vertical(classes="alert-panel"): + yield Static("▲ Rising Usage", classes="panel-header-rising") + yield DataTable(id="ww-rising-table") + with Vertical(classes="alert-panel"): + yield Static("▼ Falling Usage", classes="panel-header-falling") + yield DataTable(id="ww-falling-table") + with TabPane("All Players", id="ww-tab-all"): + yield DataTable(id="ww-all-table") def on_mount(self) -> None: - self.query_one("#ww-content").display = False for tid in ("ww-rising-table", "ww-falling-table", "ww-all-table"): table = self.query_one(f"#{tid}", DataTable) table.add_columns(*COLUMNS) table.cursor_type = "row" - def activate(self) -> None: - if not self._activated: - self._activated = True - self._fetch_data() - - @work(thread=True, exclusive=True) - def _fetch_data(self, force_refresh: bool = False) -> None: - try: - snaps = load_snap_counts([2024, 2025], force_refresh=force_refresh) - weekly = load_weekly_stats([2024, 2025], force_refresh=force_refresh) - ids = load_player_ids(force_refresh=force_refresh) - trends = compute_usage_trends(snaps, weekly, ids) - self.app.call_from_thread(self._on_data_loaded, trends) - except Exception as e: - self.app.call_from_thread(self._on_data_error, str(e)) - - def _on_data_loaded(self, trends: pl.DataFrame) -> None: - self._trends = trends - self._initializing = True + # ── load ───────────────────────────────────────────────── + + def fetch(self, force_refresh: bool) -> pl.DataFrame: + """Derive usage trends from snap counts, weekly stats and the id crosswalk. + + The season filter offers exactly the seasons loaded here. + """ + seasons = recent_seasons(SEASON_COUNT) + snaps = load_snap_counts(seasons, force_refresh=force_refresh) + weekly = load_weekly_stats(seasons, force_refresh=force_refresh) + ids = load_player_ids(force_refresh=force_refresh) + return compute_usage_trends(snaps, weekly, ids) + + def apply(self, payload: pl.DataFrame) -> None: + self._trends = payload self._populate_filters() - self._initializing = False self._apply_filters() - self.query_one("#ww-loading").display = False - self.query_one("#ww-content").display = True - - def _on_data_error(self, error: str) -> None: - self.query_one("#ww-loading").display = False - self.notify(f"Failed to load data: {error}", severity="error", timeout=10) # ── filters ────────────────────────────────────────────── @@ -157,10 +124,12 @@ def _populate_filters(self) -> None: teams = sorted(self._trends["team"].unique().drop_nulls().to_list()) team_select = self.query_one("#ww-filter-team", Select) - team_select.set_options([("All", "All")] + [(t, t) for t in teams]) - team_select.value = "All" + team_select.set_options(self.all_options(teams)) + team_select.value = ALL seasons = sorted(self._trends["season"].unique().drop_nulls().to_list()) + if not seasons: + return season_select = self.query_one("#ww-filter-season", Select) season_select.set_options([(str(s), s) for s in seasons]) season_select.value = seasons[-1] @@ -168,16 +137,14 @@ def _populate_filters(self) -> None: self._update_week_options() def _update_week_options(self) -> None: + """Offer the weeks the selected season carries, defaulted to its latest.""" if self._trends is None: return - season = self.query_one("#ww-filter-season", Select).value - if season is Select.BLANK: + season = self.selected("ww-filter-season") + if season is None: return weeks = sorted( - self._trends.filter(pl.col("season") == season)["week"] - .unique() - .drop_nulls() - .to_list() + self._trends.filter(pl.col("season") == season)["week"].unique().drop_nulls().to_list() ) if not weeks: return @@ -191,20 +158,20 @@ def _apply_filters(self) -> None: df = self._trends - pos = self.query_one("#ww-filter-position", Select).value - if pos not in ("All", Select.BLANK): + pos = self.narrowing("ww-filter-position") + if pos is not None: df = df.filter(pl.col("position") == pos) - team = self.query_one("#ww-filter-team", Select).value - if team not in ("All", Select.BLANK): + team = self.narrowing("ww-filter-team") + if team is not None: df = df.filter(pl.col("team") == team) - season = self.query_one("#ww-filter-season", Select).value - if season is not Select.BLANK: + season = self.selected("ww-filter-season") + if season is not None: df = df.filter(pl.col("season") == season) - week = self.query_one("#ww-filter-week", Select).value - if week is not Select.BLANK: + week = self.selected("ww-filter-week") + if week is not None: df = df.filter(pl.col("week") == week) self._update_tables(df) @@ -212,47 +179,38 @@ def _apply_filters(self) -> None: # ── tables ─────────────────────────────────────────────── def _update_tables(self, df: pl.DataFrame) -> None: + """Split the filtered frame into the two alert panels and the full listing. + + Rising sorts by steepest climb and falling by steepest drop, so the top row of + each panel is the player whose usage moved the most. + """ rising = df.filter(pl.col("trend") == "rising").sort("velocity", descending=True) - self._fill_table("ww-rising-table", rising) + self.fill_table( + "ww-rising-table", + rising, + _cells, + "No rising usage among the players these filters match.", + ) falling = df.filter(pl.col("trend") == "falling").sort("velocity") - self._fill_table("ww-falling-table", falling) - - self._fill_table("ww-all-table", df.sort("velocity", descending=True)) - - def _fill_table(self, table_id: str, df: pl.DataFrame) -> None: - table = self.query_one(f"#{table_id}", DataTable) - table.clear() - for row in df.iter_rows(named=True): - table.add_row( - row["player"], - row["position"], - row["team"], - str(row["week"]), - f"{row['snap_pct']:.1f}%" if row["snap_pct"] is not None else "—", - f"{row['tgt_share']:.1f}%" if row["tgt_share"] is not None else "—", - f"{row['touch_share']:.1f}%" if row["touch_share"] is not None else "—", - f"{row['usage_score']:.1f}" if row["usage_score"] is not None else "—", - f"{row['rolling_avg']:.1f}" if row["rolling_avg"] is not None else "—", - f"{row['delta']:+.1f}" if row["delta"] is not None else "—", - f"{row['velocity']:+.1f}" if row["velocity"] is not None else "—", - row["trend"], - ) + self.fill_table( + "ww-falling-table", + falling, + _cells, + "No falling usage among the players these filters match.", + ) + + self.fill_table( + "ww-all-table", + df.sort("velocity", descending=True), + _cells, + "No players match these filters. Widen the position, team or week filter.", + ) # ── events ─────────────────────────────────────────────── - def on_select_changed(self, event: Select.Changed) -> None: - if self._initializing: - return - if event.select.id == "ww-filter-season": - self._initializing = True + def filter_changed(self, filter_id: str | None) -> None: + """Re-filter, rebuilding the week options first where the season moved.""" + if filter_id == "ww-filter-season": self._update_week_options() - self._initializing = False self._apply_filters() - - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "ww-btn-refresh": - self.query_one("#ww-content").display = False - self.query_one("#ww-loading").display = True - self._activated = False - self.activate() diff --git a/tests/conftest.py b/tests/conftest.py index 2e4d3a5..b570411 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,127 +1,1093 @@ -"""Shared test fixtures — small synthetic DataFrames for engine tests.""" +"""Shared test fixtures — small synthetic DataFrames for engine tests. + +Three groups of fixtures live here. + +**The pair fixtures** — ``snap_counts``, ``weekly_stats``, ``schedules`` — describe +two players on one team over six weeks. They are the smallest input the snap-share +and waiver-wire engines accept. + +**The league fixtures** — ``league_snap_counts``, ``league_weekly_stats``, +``full_season_schedules``, ``pbp``, ``rosters``, ``injuries`` — describe a four-team +league, ``LEAGUE_TEAMS``, over the ``LEAGUE_SEASONS`` regular seasons, with one +starter per team at each of QB, RB, WR and TE. + +**The focused series** — ``colliding_name_snaps``, ``colliding_name_weekly_stats``, +``breakout_snaps``, ``breakout_snaps_points_scale`` — are single-property inputs for +the windowing engines. + +``player_ids`` is the one crosswalk covering every player in this file, so it pairs +with any snap fixture here. + +Every number in the league fixtures is derived from a module constant, so an expected +value is arithmetic on a constant rather than a literal copied out of a run: + +* ``PAIRINGS`` fixes the schedule. Week ``w`` plays ``PAIRINGS[(w - 1) % 3]``, so + across any three consecutive weeks holding no bye, each team faces each of the + other three exactly once. +* ``POINTS_ALLOWED[position][defense]`` is what a defense concedes, per game, to the + one opposing starter at that position. A player scores exactly what the defense + across from them allows, so a defense's points-allowed average equals its entry and + a player's per-game average is the mean of the entries for the defenses they faced. +* ``SEASON_BYES`` sits one pairing out of one week. Season 2025 byes PHI and WAS in + week 7 and plays NYG and DAL all 18; season 2024 byes nobody. +* ``SNAP_SHARE[position]`` is the offensive snap share every starter at that position + takes, as a 0–1 fraction. +* ``ABSENCE_SEASON`` carries the league's one injury absence. Season 2025 carries + none, so pass ``season=2025`` to any engine taking a season and every average reads + straight off ``POINTS_ALLOWED``. + +Identifiers are derived too: ``league_gsis_id``, ``league_pfr_id`` and +``league_player_name`` map a (team, position) pair to the ids and display name the +fixtures carry, and ``league_opponent`` and ``league_game_id`` answer who a team +played in a given week and under which game id. +""" import polars as pl import pytest +# ── The synthetic league ───────────────────────────────────────────────────── + +LEAGUE_TEAMS = ("NYG", "DAL", "PHI", "WAS") +LEAGUE_POSITIONS = ("QB", "RB", "WR", "TE") +LEAGUE_SEASONS = (2024, 2025) + +#: Weeks in a regular season, matching ``ffb.trade_value.engine.REGULAR_SEASON_WEEKS``. +REGULAR_SEASON_WEEKS = 18 + +#: Weekly pairings, cycled across the season: week ``w`` plays ``PAIRINGS[(w - 1) % 3]``. +#: Three consecutive weeks give every team one game against every other team. +PAIRINGS = ( + (("NYG", "DAL"), ("PHI", "WAS")), + (("NYG", "PHI"), ("DAL", "WAS")), + (("NYG", "WAS"), ("DAL", "PHI")), +) + +#: Season → (bye week, the pairing that sits it out). A season absent from this map +#: plays every team in every week and so has no bye at all. +SEASON_BYES: dict[int, tuple[int, tuple[str, str]]] = {2025: (7, ("PHI", "WAS"))} + +#: Fantasy points a defense concedes per game to the one opposing starter at a +#: position. Each position's four values are multiples of 3, so every mean over +#: three defenses is a whole number, and each set sums to an even number, so the +#: four-defense league average lands on a whole number or a half. +POINTS_ALLOWED = { + "QB": {"NYG": 9.0, "DAL": 18.0, "PHI": 21.0, "WAS": 24.0}, + "RB": {"NYG": 12.0, "DAL": 9.0, "PHI": 6.0, "WAS": 3.0}, + "WR": {"NYG": 6.0, "DAL": 12.0, "PHI": 18.0, "WAS": 24.0}, + "TE": {"NYG": 9.0, "DAL": 12.0, "PHI": 15.0, "WAS": 18.0}, +} + +#: Offensive snap share each starter takes, as the 0–1 fraction nflverse publishes. +SNAP_SHARE = {"QB": 1.00, "RB": 0.60, "WR": 0.80, "TE": 0.40} + +#: Points every postseason row is worth. Above every ``POINTS_ALLOWED`` value, so a +#: computation that drops the season-type filter reads visibly wrong. +POSTSEASON_POINTS = 30.0 + +#: The postseason week ``league_weekly_stats``, ``league_snap_counts`` and +#: ``full_season_schedules`` carry beyond the regular season. +POSTSEASON_WEEK = 19 + +# ── The league's one absence ───────────────────────────────────────────────── + +ABSENCE_SEASON = 2024 +ABSENCE_TEAM = "NYG" +ABSENCE_POSITION = "WR" + +#: Weeks the absent starter sits out. Nine weeks, and the weeks played and the weeks +#: missed each face DAL, PHI and WAS three times, so both sides of an active/inactive +#: split average the same opponents and a teammate's delta is exactly their bonus. +ABSENCE_WEEKS = frozenset(range(4, 13)) + +#: What each remaining starter adds to their box score while the receiver is out. +#: Fantasy points follow from the box score, so the deltas are +6.0 for the back, +#: +3.0 for the tight end and -1.0 for the quarterback. +ABSENCE_BONUS = { + "QB": {"passing_yards": -25.0}, + "RB": {"targets": 4.0, "carries": 5.0, "rushing_yards": 60.0}, + "TE": {"targets": 2.0, "receptions": 1.0, "receiving_yards": 20.0}, +} + +# ── Colliding display names ────────────────────────────────────────────────── + +#: One display name worn by two players, on different teams and at different +#: positions, whose snap shares mirror each other week for week. +COLLIDING_NAME = "Casey Rivers" + +COLLIDING_RISING_PFR_ID = "RIVE01" +COLLIDING_RISING_GSIS_ID = "00-0501" +COLLIDING_RISING_TEAM = "NYG" +COLLIDING_RISING_POSITION = "WR" +COLLIDING_RISING_PCT = (0.30, 0.40, 0.50, 0.60, 0.70, 0.80) + +COLLIDING_FALLING_PFR_ID = "RIVE02" +COLLIDING_FALLING_GSIS_ID = "00-0502" +COLLIDING_FALLING_TEAM = "DAL" +COLLIDING_FALLING_POSITION = "RB" +COLLIDING_FALLING_PCT = (0.80, 0.70, 0.60, 0.50, 0.40, 0.30) + +# ── Breakout series ────────────────────────────────────────────────────────── + +BREAKOUT_PFR_ID = "BRKO01" +BREAKOUT_GSIS_ID = "00-0601" +BREAKOUT_NAME = "Devon Ascent" +BREAKOUT_TEAM = "PHI" +BREAKOUT_POSITION = "WR" + +#: Eight weeks of offensive snap share that carry a three-week window through every +#: trend class: a rise, a crossing from under 50% to over 60%, a flat stretch, +#: another rise, and a two-week decline. +BREAKOUT_PCT = (0.40, 0.45, 0.48, 0.70, 0.55, 0.75, 0.50, 0.40) + +# ── Identities ─────────────────────────────────────────────────────────────── + +_TEAM_SLOT = {"NYG": "1", "DAL": "2", "PHI": "3", "WAS": "4"} +_POSITION_SLOT = {"QB": "1", "RB": "2", "WR": "3", "TE": "4"} +_POSITION_NOUN = {"QB": "Quarterback", "RB": "Runner", "WR": "Receiver", "TE": "Tight End"} + + +def league_gsis_id(team: str, position: str) -> str: + """GSIS id of the starter `team` fields at `position`.""" + return f"00-0{_TEAM_SLOT[team]}0{_POSITION_SLOT[position]}" + + +def league_pfr_id(team: str, position: str) -> str: + """Pro-Football-Reference id of the starter `team` fields at `position`.""" + return f"{team}{_POSITION_SLOT[position]}" + + +def league_player_name(team: str, position: str) -> str: + """Display name of the starter `team` fields at `position`.""" + return f"{team} {_POSITION_NOUN[position]}" + + +# ── Schedule ───────────────────────────────────────────────────────────────── + + +def _league_games(season: int) -> list[tuple[int, str, str]]: + """Every regular-season game of `season` as (week, home team, away team). + + The home side alternates with the parity of the week, so both the home and away + columns carry each team. + """ + bye = SEASON_BYES.get(season) + games = [] + for week in range(1, REGULAR_SEASON_WEEKS + 1): + for pair in PAIRINGS[(week - 1) % len(PAIRINGS)]: + if bye is not None and week == bye[0] and pair == bye[1]: + continue + first, second = pair + home, away = (first, second) if week % 2 else (second, first) + games.append((week, home, away)) + return games + + +def league_opponent(season: int, week: int, team: str) -> str | None: + """Team `team` faces in `week`, or None when `week` is their bye.""" + for game_week, home, away in _league_games(season): + if game_week != week: + continue + if team == home: + return away + if team == away: + return home + return None + + +def league_game_id(season: int, week: int, team: str) -> str: + """nflverse game id of the game `team` played in `week`.""" + for game_week, home, away in _league_games(season): + if game_week == week and team in (home, away): + return f"{season}_{week:02d}_{away}_{home}" + raise KeyError(f"{team} played no game in {season} week {week}") + + +# ── Box scores ─────────────────────────────────────────────────────────────── + +_STAT_COLUMNS = ( + "targets", + "carries", + "receptions", + "receiving_yards", + "receiving_tds", + "rushing_yards", + "rushing_tds", + "passing_yards", + "passing_tds", + "interceptions", + "fumbles_lost", +) + + +def _stat_line(position: str, points: float) -> dict[str, float]: + """Box score for one game at `position` worth exactly `points` PPR points.""" + line = dict.fromkeys(_STAT_COLUMNS, 0.0) + if position == "QB": + line["passing_tds"] = 1.0 + line["passing_yards"] = (points - 4.0) * 25.0 + elif position == "RB": + line["targets"] = 2.0 + line["carries"] = 20.0 + line["receptions"] = 1.0 + line["rushing_yards"] = (points - 1.0) * 10.0 + elif position == "WR": + line["targets"] = 10.0 + line["receptions"] = 5.0 + line["receiving_yards"] = (points - 5.0) * 10.0 + else: + line["targets"] = 6.0 + line["receptions"] = 4.0 + line["receiving_yards"] = (points - 4.0) * 10.0 + return line + + +def ppr_points(line: dict[str, float]) -> float: + """PPR fantasy points of a box score, scored as `ffb.data.stats` scores one.""" + return ( + line["receiving_yards"] / 10 + + line["receiving_tds"] * 6 + + line["receptions"] + + line["rushing_yards"] / 10 + + line["rushing_tds"] * 6 + + line["passing_yards"] / 25 + + line["passing_tds"] * 4 + - line["interceptions"] * 2 + - line["fumbles_lost"] * 2 + ) + + +# ── Pair fixtures ──────────────────────────────────────────────────────────── + @pytest.fixture() def snap_counts() -> pl.DataFrame: - """Minimal snap counts: 2 players, 6 weeks, 1 team.""" + """Minimal snap counts: 2 players, 6 weeks, 1 team. + + Shape: 12 rows, one per player per week, season 2025, game_type REG, + ``offense_pct`` as a 0–1 fraction. + + Property: Alpha Player's share climbs five points every week and Beta Player's + holds flat, so a rolling window separates a rising series from a stable one. + """ + rows = [] + for week in range(1, 7): + rows.append( + { + "pfr_player_id": "AAA01", + "player": "Alpha Player", + "position": "WR", + "team": "NYG", + "opponent": "DAL", + "season": 2025, + "week": week, + "game_type": "REG", + "offense_snaps": 50.0 + week * 5, + "offense_pct": 0.50 + week * 0.05, + "defense_snaps": 0.0, + "defense_pct": 0.0, + "st_snaps": 0.0, + "st_pct": 0.0, + "game_id": f"2025_{week:02d}_NYG_DAL", + "pfr_game_id": f"202509{week:02d}dal", + } + ) + rows.append( + { + "pfr_player_id": "BBB02", + "player": "Beta Player", + "position": "RB", + "team": "NYG", + "opponent": "DAL", + "season": 2025, + "week": week, + "game_type": "REG", + "offense_snaps": 40.0, + "offense_pct": 0.40, + "defense_snaps": 0.0, + "defense_pct": 0.0, + "st_snaps": 0.0, + "st_pct": 0.0, + "game_id": f"2025_{week:02d}_NYG_DAL", + "pfr_game_id": f"202509{week:02d}dal", + } + ) + return pl.DataFrame(rows) + + +@pytest.fixture() +def weekly_stats() -> pl.DataFrame: + """Minimal weekly stats matching snap_counts players. + + Shape: 12 rows, one per player per week, season 2025, season_type REG, every + week against DAL. + + Property: Alpha Player's points and targets climb every week while Beta Player's + hold flat, so the two carry the same trends the snap shares do. + """ rows = [] for week in range(1, 7): - rows.append({ - "pfr_player_id": "AAA01", - "player": "Alpha Player", - "position": "WR", - "team": "NYG", - "opponent": "DAL", + rows.append( + { + "player_id": "00-001", + "player_display_name": "Alpha Player", + "position": "WR", + "recent_team": "NYG", + "opponent_team": "DAL", + "season": 2025, + "week": week, + "season_type": "REG", + "fantasy_points_ppr": 12.0 + week * 2.0, + "targets": 6.0 + week, + "carries": 0.0, + "receptions": 4.0, + "receiving_yards": 60.0, + "receiving_tds": 0.0, + "rushing_yards": 0.0, + "rushing_tds": 0.0, + "passing_yards": 0.0, + "passing_tds": 0.0, + "interceptions": 0.0, + "fumbles_lost": 0.0, + } + ) + rows.append( + { + "player_id": "00-002", + "player_display_name": "Beta Player", + "position": "RB", + "recent_team": "NYG", + "opponent_team": "DAL", + "season": 2025, + "week": week, + "season_type": "REG", + "fantasy_points_ppr": 10.0, + "targets": 2.0, + "carries": 15.0, + "receptions": 1.0, + "receiving_yards": 10.0, + "receiving_tds": 0.0, + "rushing_yards": 70.0, + "rushing_tds": 0.0, + "passing_yards": 0.0, + "passing_tds": 0.0, + "interceptions": 0.0, + "fumbles_lost": 0.0, + } + ) + return pl.DataFrame(rows) + + +@pytest.fixture() +def schedules() -> pl.DataFrame: + """Minimal schedule: NYG vs DAL for 6 weeks. + + Shape: 6 rows, season 2025, game_type REG, NYG away at DAL every week. + + Property: every week of the pair fixtures has a game, so a week-keyed matchup + lookup resolves for any of the six. + """ + rows = [] + for week in range(1, 7): + rows.append( + { + "game_id": f"2025_{week:02d}_NYG_DAL", + "season": 2025, + "week": week, + "game_type": "REG", + "home_team": "DAL", + "away_team": "NYG", + } + ) + return pl.DataFrame(rows) + + +# ── Crosswalk ──────────────────────────────────────────────────────────────── + + +@pytest.fixture() +def player_ids() -> pl.DataFrame: + """Crosswalk mapping pfr_id → gsis_id for every player in this file. + + Shape: one row per player across the pair fixtures, the league fixtures, the + colliding-name pair and the breakout series, plus two rows a crosswalk build has + to reduce — a null pfr_id, and one pfr_id claimed twice where the later row wins. + + Property: any snap fixture here joins against this one frame, and the two players + sharing ``COLLIDING_NAME`` map to distinct GSIS ids. + """ + rows: list[dict[str, str | None]] = [ + {"pfr_id": "AAA01", "gsis_id": "00-001", "name": "Alpha Player", "position": "WR"}, + {"pfr_id": "BBB02", "gsis_id": "00-002", "name": "Beta Player", "position": "RB"}, + ] + for team in LEAGUE_TEAMS: + for position in LEAGUE_POSITIONS: + rows.append( + { + "pfr_id": league_pfr_id(team, position), + "gsis_id": league_gsis_id(team, position), + "name": league_player_name(team, position), + "position": position, + } + ) + rows.extend( + [ + { + "pfr_id": COLLIDING_RISING_PFR_ID, + "gsis_id": COLLIDING_RISING_GSIS_ID, + "name": COLLIDING_NAME, + "position": COLLIDING_RISING_POSITION, + }, + { + "pfr_id": COLLIDING_FALLING_PFR_ID, + "gsis_id": COLLIDING_FALLING_GSIS_ID, + "name": COLLIDING_NAME, + "position": COLLIDING_FALLING_POSITION, + }, + { + "pfr_id": BREAKOUT_PFR_ID, + "gsis_id": BREAKOUT_GSIS_ID, + "name": BREAKOUT_NAME, + "position": BREAKOUT_POSITION, + }, + {"pfr_id": None, "gsis_id": "00-0801", "name": "Unlinked Player", "position": "TE"}, + {"pfr_id": "DUPE1", "gsis_id": "00-0802", "name": "Twice Listed", "position": "TE"}, + {"pfr_id": "DUPE1", "gsis_id": "00-0803", "name": "Twice Listed", "position": "TE"}, + ] + ) + return pl.DataFrame(rows) + + +# ── League fixtures ────────────────────────────────────────────────────────── + + +def _absence_applies(season: int, team: str, week: int) -> bool: + """Whether the league's one absence covers this team-week.""" + return season == ABSENCE_SEASON and team == ABSENCE_TEAM and week in ABSENCE_WEEKS + + +@pytest.fixture() +def full_season_schedules() -> pl.DataFrame: + """Every game of both league seasons, plus one postseason game. + + Shape: 36 regular-season rows for 2024, 35 for 2025, one WC row for 2025 in week + ``POSTSEASON_WEEK``. Columns: game_id, season, week, game_type, home_team, + away_team. + + Property: PHI and WAS sit out week 7 of 2025 and NYG and DAL play all 18, so one + season yields a bye for half the league; 2024 yields none at all. The postseason + row is the only non-REG game_type, so it is what a regular-season filter drops. + """ + rows = [] + for season in LEAGUE_SEASONS: + for week, home, away in _league_games(season): + rows.append( + { + "game_id": f"{season}_{week:02d}_{away}_{home}", + "season": season, + "week": week, + "game_type": "REG", + "home_team": home, + "away_team": away, + } + ) + rows.append( + { + "game_id": f"2025_{POSTSEASON_WEEK}_NYG_DAL", "season": 2025, + "week": POSTSEASON_WEEK, + "game_type": "WC", + "home_team": "DAL", + "away_team": "NYG", + } + ) + return pl.DataFrame(rows) + + +@pytest.fixture() +def league_weekly_stats() -> pl.DataFrame: + """Weekly box scores for every league starter across both seasons. + + Shape: one row per starter per game played, season_type REG, plus eight + season_type POST rows for the NYG and DAL starters in week ``POSTSEASON_WEEK`` of + 2025. ``opponent_team`` carries the defense faced. Columns match the pair + fixture ``weekly_stats``. + + Property: a regular-season row's ``fantasy_points_ppr`` is + ``POINTS_ALLOWED[position][opponent]`` and its box score adds up to exactly that, + so a defense's points-allowed average equals its ``POINTS_ALLOWED`` entry and a + player's per-game average is the mean over the defenses they faced. Season 2025 + holds to that without exception; season ``ABSENCE_SEASON`` adds ``ABSENCE_BONUS`` + to the remaining NYG starters during ``ABSENCE_WEEKS`` and drops the absent + receiver's rows altogether. Every POST row is worth ``POSTSEASON_POINTS``, which + no regular-season row reaches. + """ + rows = [] + for season in LEAGUE_SEASONS: + for week, home, away in _league_games(season): + for team, opponent in ((home, away), (away, home)): + for position in LEAGUE_POSITIONS: + absent = _absence_applies(season, team, week) + if absent and position == ABSENCE_POSITION: + continue + line = _stat_line(position, POINTS_ALLOWED[position][opponent]) + if absent: + for column, bonus in ABSENCE_BONUS[position].items(): + line[column] += bonus + rows.append( + { + "player_id": league_gsis_id(team, position), + "player_display_name": league_player_name(team, position), + "position": position, + "recent_team": team, + "opponent_team": opponent, + "season": season, + "week": week, + "season_type": "REG", + "fantasy_points_ppr": ppr_points(line), + **line, + } + ) + for team, opponent in (("NYG", "DAL"), ("DAL", "NYG")): + for position in LEAGUE_POSITIONS: + line = _stat_line(position, POSTSEASON_POINTS) + rows.append( + { + "player_id": league_gsis_id(team, position), + "player_display_name": league_player_name(team, position), + "position": position, + "recent_team": team, + "opponent_team": opponent, + "season": 2025, + "week": POSTSEASON_WEEK, + "season_type": "POST", + "fantasy_points_ppr": ppr_points(line), + **line, + } + ) + return pl.DataFrame(rows) + + +@pytest.fixture() +def league_snap_counts() -> pl.DataFrame: + """Snap counts for every league starter across both seasons. + + Shape: one row per starter per game played, game_type REG, plus eight game_type + POST rows for the NYG and DAL starters in week ``POSTSEASON_WEEK`` of 2025. + Columns match the pair fixture ``snap_counts``; ``offense_pct`` is a 0–1 fraction. + + Property: a starter takes ``SNAP_SHARE[position]`` of the offensive snaps every + week, so a season average equals that entry. The absent receiver keeps a row in + each of ``ABSENCE_WEEKS`` carrying zero snaps, which is what marks those weeks + inactive rather than the row being missing. + """ + rows = [] + + def snap_row( + season: int, week: int, team: str, opponent: str, position: str, game_type: str + ) -> dict: + played = not (_absence_applies(season, team, week) and position == ABSENCE_POSITION) + pct = SNAP_SHARE[position] if played else 0.0 + return { + "pfr_player_id": league_pfr_id(team, position), + "player": league_player_name(team, position), + "position": position, + "team": team, + "opponent": opponent, + "season": season, "week": week, - "game_type": "REG", - "offense_snaps": 50.0 + week * 5, - "offense_pct": 0.50 + week * 0.05, + "game_type": game_type, + "offense_snaps": round(70 * pct, 1), + "offense_pct": pct, "defense_snaps": 0.0, "defense_pct": 0.0, "st_snaps": 0.0, "st_pct": 0.0, - "game_id": f"2025_{week:02d}_NYG_DAL", - "pfr_game_id": f"202509{week:02d}dal", - }) - rows.append({ - "pfr_player_id": "BBB02", - "player": "Beta Player", - "position": "RB", - "team": "NYG", - "opponent": "DAL", + "game_id": f"{season}_{week:02d}_{opponent}_{team}", + "pfr_game_id": f"{season}{week:02d}{team.lower()}", + } + + for season in LEAGUE_SEASONS: + for week, home, away in _league_games(season): + for team, opponent in ((home, away), (away, home)): + for position in LEAGUE_POSITIONS: + rows.append(snap_row(season, week, team, opponent, position, "REG")) + for team, opponent in (("NYG", "DAL"), ("DAL", "NYG")): + for position in LEAGUE_POSITIONS: + rows.append(snap_row(2025, POSTSEASON_WEEK, team, opponent, position, "POST")) + return pl.DataFrame(rows) + + +@pytest.fixture() +def rosters() -> pl.DataFrame: + """Seasonal rosters for both league seasons. + + Shape: one row per league starter per season, plus a player listed at RB in 2024 + and WR in 2025, plus a player carrying a null position. Columns: season, team, + position, player_name, player_id, status, years_exp. + + Property: a position lookup keyed on player_id resolves every league starter, + drops the null-position row, and resolves the reclassified player to WR — the + position on their later row. + """ + rows = [] + for season in LEAGUE_SEASONS: + for team in LEAGUE_TEAMS: + for position in LEAGUE_POSITIONS: + rows.append( + { + "season": season, + "team": team, + "position": position, + "player_name": league_player_name(team, position), + "player_id": league_gsis_id(team, position), + "status": "ACT", + "years_exp": 4, + } + ) + rows.extend( + [ + { + "season": 2024, + "team": "PHI", + "position": "RB", + "player_name": "Rey Flex", + "player_id": "00-0901", + "status": "ACT", + "years_exp": 1, + }, + { + "season": 2025, + "team": "PHI", + "position": "WR", + "player_name": "Rey Flex", + "player_id": "00-0901", + "status": "ACT", + "years_exp": 2, + }, + { + "season": 2025, + "team": "WAS", + "position": None, + "player_name": "Unlisted Position", + "player_id": "00-0902", + "status": "ACT", + "years_exp": 0, + }, + ] + ) + return pl.DataFrame(rows) + + +@pytest.fixture() +def injuries() -> pl.DataFrame: + """Injury reports for four league starters across both seasons. + + Shape: one row per player per reported week. Columns: season, season_type, team, + week, gsis_id, position, full_name, report_primary_injury, report_status, + practice_status. + + Property: each player's health discount is exact. Every player is measured + against the same slate, both seasons' regular-season weeks, so the denominator + is thirty-six throughout. The NYG receiver reports Out for the nine + ``ABSENCE_WEEKS``, nine of thirty-six, and lands on 0.75. The PHI tight end + reports Out for nine weeks of each season, eighteen of thirty-six, meeting the + half-the-slate cap on missed weeks exactly and landing on the 0.5 floor. The WAS + quarterback reports inside one of the two seasons, twelve of the same thirty-six, + and lands on two thirds. The DAL back reports only Questionable, so no discount + is computed for them at all. Three postseason Out rows sit outside the + regular-season filter. + """ + rows = [] + + def report( + season: int, week: int, team: str, position: str, status: str, season_type: str = "REG" + ) -> dict: + return { + "season": season, + "season_type": season_type, + "team": team, + "week": week, + "gsis_id": league_gsis_id(team, position), + "position": position, + "full_name": league_player_name(team, position), + "report_primary_injury": "Hamstring", + "report_status": status, + "practice_status": "Did Not Participate" if status == "Out" else "Limited", + } + + for week in sorted(ABSENCE_WEEKS): + rows.append(report(ABSENCE_SEASON, week, "NYG", "WR", "Out")) + for week in (1, 2, 3): + rows.append(report(ABSENCE_SEASON, week, "NYG", "WR", "Questionable")) + for week in (1, 2): + rows.append(report(2025, week, "NYG", "WR", "Questionable")) + for week in (POSTSEASON_WEEK, POSTSEASON_WEEK + 1, POSTSEASON_WEEK + 2): + rows.append(report(2025, week, "NYG", "WR", "Out", season_type="POST")) + + for season in LEAGUE_SEASONS: + for week in range(1, 10): + rows.append(report(season, week, "PHI", "TE", "Out")) + + for week in range(1, 13): + rows.append(report(2025, week, "WAS", "QB", "Out")) + + for week in range(1, 7): + rows.append(report(2025, week, "DAL", "RB", "Questionable")) + + return pl.DataFrame(rows) + + +# ── Colliding display names ────────────────────────────────────────────────── + + +def _series_snap_rows( + pfr_id: str, name: str, team: str, position: str, pcts: tuple[float, ...], opponent: str +) -> list[dict]: + """Snap rows for one player, one per entry of `pcts` starting at week 1.""" + return [ + { + "pfr_player_id": pfr_id, + "player": name, + "position": position, + "team": team, + "opponent": opponent, "season": 2025, "week": week, "game_type": "REG", - "offense_snaps": 40.0, - "offense_pct": 0.40, + "offense_snaps": round(70 * pct, 1), + "offense_pct": pct, "defense_snaps": 0.0, "defense_pct": 0.0, "st_snaps": 0.0, "st_pct": 0.0, - "game_id": f"2025_{week:02d}_NYG_DAL", - "pfr_game_id": f"202509{week:02d}dal", - }) + "game_id": f"2025_{week:02d}_{opponent}_{team}", + "pfr_game_id": f"2025{week:02d}{team.lower()}", + } + for week, pct in enumerate(pcts, start=1) + ] + + +@pytest.fixture() +def colliding_name_snaps() -> pl.DataFrame: + """Two players wearing ``COLLIDING_NAME`` on different teams. + + Shape: 12 rows, six weeks each, season 2025, game_type REG. + + Property: the two carry distinct ``pfr_player_id`` and distinct GSIS ids, and + their shares mirror — ``COLLIDING_RISING_PCT`` climbs ten points a week while + ``COLLIDING_FALLING_PCT`` drops ten, and the pair sums to 1.10 in every week. A + window keyed on the display name therefore reads flat, and only a window keyed on + the player id separates the rise from the fall. + """ + rows = _series_snap_rows( + COLLIDING_RISING_PFR_ID, + COLLIDING_NAME, + COLLIDING_RISING_TEAM, + COLLIDING_RISING_POSITION, + COLLIDING_RISING_PCT, + "PHI", + ) + rows += _series_snap_rows( + COLLIDING_FALLING_PFR_ID, + COLLIDING_NAME, + COLLIDING_FALLING_TEAM, + COLLIDING_FALLING_POSITION, + COLLIDING_FALLING_PCT, + "WAS", + ) return pl.DataFrame(rows) @pytest.fixture() -def weekly_stats() -> pl.DataFrame: - """Minimal weekly stats matching snap_counts players.""" +def colliding_name_weekly_stats() -> pl.DataFrame: + """Weekly stats for the two players wearing ``COLLIDING_NAME``. + + Shape: 12 rows, six weeks each, season 2025, season_type REG. Columns match the + pair fixture ``weekly_stats``. + + Property: each is the only player on their team, so target share and touch share + hold constant across the six weeks and every usage trend comes from the snap + share alone. The rising player takes 5 targets and no carries, giving both + shares 100. The falling player takes 10 carries and no targets, so their team's + weekly target total is zero, which is the branch a share takes on a zero + denominator. + """ rows = [] for week in range(1, 7): - rows.append({ - "player_id": "00-001", - "player_display_name": "Alpha Player", - "position": "WR", - "recent_team": "NYG", - "opponent_team": "DAL", - "season": 2025, - "week": week, - "season_type": "REG", - "fantasy_points_ppr": 12.0 + week * 2.0, - "targets": 6.0 + week, - "carries": 0.0, - "receptions": 4.0, - "receiving_yards": 60.0, - "receiving_tds": 0.0, - "rushing_yards": 0.0, - "rushing_tds": 0.0, - "passing_yards": 0.0, - "passing_tds": 0.0, - "interceptions": 0.0, - "fumbles_lost": 0.0, - }) - rows.append({ - "player_id": "00-002", - "player_display_name": "Beta Player", - "position": "RB", - "recent_team": "NYG", - "opponent_team": "DAL", - "season": 2025, - "week": week, - "season_type": "REG", - "fantasy_points_ppr": 10.0, - "targets": 2.0, - "carries": 15.0, - "receptions": 1.0, - "receiving_yards": 10.0, - "receiving_tds": 0.0, - "rushing_yards": 70.0, - "rushing_tds": 0.0, - "passing_yards": 0.0, - "passing_tds": 0.0, - "interceptions": 0.0, - "fumbles_lost": 0.0, - }) + rising = _stat_line("WR", 10.0) + rising["targets"] = 5.0 + rows.append( + { + "player_id": COLLIDING_RISING_GSIS_ID, + "player_display_name": COLLIDING_NAME, + "position": COLLIDING_RISING_POSITION, + "recent_team": COLLIDING_RISING_TEAM, + "opponent_team": "PHI", + "season": 2025, + "week": week, + "season_type": "REG", + "fantasy_points_ppr": ppr_points(rising), + **rising, + } + ) + falling = _stat_line("RB", 11.0) + falling["targets"] = 0.0 + falling["receptions"] = 0.0 + falling["carries"] = 10.0 + rows.append( + { + "player_id": COLLIDING_FALLING_GSIS_ID, + "player_display_name": COLLIDING_NAME, + "position": COLLIDING_FALLING_POSITION, + "recent_team": COLLIDING_FALLING_TEAM, + "opponent_team": "WAS", + "season": 2025, + "week": week, + "season_type": "REG", + "fantasy_points_ppr": ppr_points(falling), + **falling, + } + ) return pl.DataFrame(rows) +# ── Breakout series ────────────────────────────────────────────────────────── + + @pytest.fixture() -def player_ids() -> pl.DataFrame: - """Crosswalk mapping pfr_id → gsis_id for test players.""" - return pl.DataFrame({ - "pfr_id": ["AAA01", "BBB02"], - "gsis_id": ["00-001", "00-002"], - "name": ["Alpha Player", "Beta Player"], - "position": ["WR", "RB"], - }) +def breakout_snaps() -> pl.DataFrame: + """One player whose eight weeks of snap share cover every trend class. + + Shape: 8 rows, season 2025, game_type REG, ``offense_pct`` as a 0–1 fraction + taken from ``BREAKOUT_PCT``. + + Property: over a three-week window the series rises, crosses from under 50% to + over 60% in week 4, flattens, rises again, then falls for two weeks — so one + input classifies as breakout, rising, stable and falling. + """ + rows = _series_snap_rows( + BREAKOUT_PFR_ID, + BREAKOUT_NAME, + BREAKOUT_TEAM, + BREAKOUT_POSITION, + BREAKOUT_PCT, + "WAS", + ) + return pl.DataFrame(rows) @pytest.fixture() -def schedules() -> pl.DataFrame: - """Minimal schedule: NYG vs DAL for 6 weeks.""" - rows = [] - for week in range(1, 7): - rows.append({ - "game_id": f"2025_{week:02d}_NYG_DAL", +def breakout_snaps_points_scale(breakout_snaps: pl.DataFrame) -> pl.DataFrame: + """``breakout_snaps`` with ``offense_pct`` on a 0–100 scale. + + Shape: identical to ``breakout_snaps`` with every ``offense_pct`` multiplied by + 100. + + Property: nflverse publishes snap share as a fraction and this frame publishes + the same shares as points, so a trend computed from either yields the same + ``snap_pct`` — a fraction is scaled once and points are left alone. + """ + return breakout_snaps.with_columns(pl.col("offense_pct") * 100) + + +# ── Play-by-play ───────────────────────────────────────────────────────────── + +_PBP_SCHEMA = { + "game_id": pl.String, + "season": pl.Int64, + "week": pl.Int64, + "season_type": pl.String, + "posteam": pl.String, + "defteam": pl.String, + "fixed_drive": pl.Int64, + "yardline_100": pl.Float64, + "play_type": pl.String, + "pass_attempt": pl.Float64, + "rush_attempt": pl.Float64, + "complete_pass": pl.Float64, + "touchdown": pl.Float64, + "pass_touchdown": pl.Float64, + "rush_touchdown": pl.Float64, + "interception": pl.Float64, + "fumble_lost": pl.Float64, + "passing_yards": pl.Float64, + "receiving_yards": pl.Float64, + "rushing_yards": pl.Float64, + "epa": pl.Float64, + "passer_player_id": pl.String, + "passer_player_name": pl.String, + "receiver_player_id": pl.String, + "receiver_player_name": pl.String, + "rusher_player_id": pl.String, + "rusher_player_name": pl.String, + "fumbled_1_player_id": pl.String, + "fumbled_1_player_name": pl.String, +} + +_PBP_ZERO = { + "pass_attempt": 0.0, + "rush_attempt": 0.0, + "complete_pass": 0.0, + "touchdown": 0.0, + "pass_touchdown": 0.0, + "rush_touchdown": 0.0, + "interception": 0.0, + "fumble_lost": 0.0, + "passing_yards": 0.0, + "receiving_yards": 0.0, + "rushing_yards": 0.0, +} + + +def _throw( + team: str, + position: str, + *, + complete: float = 1.0, + yards: float = 0.0, + td: float = 0.0, + intercepted: float = 0.0, +) -> dict: + """One pass attempt from `team`'s quarterback to its starter at `position`.""" + return { + "play_type": "pass", + "pass_attempt": 1.0, + "complete_pass": complete, + "passing_yards": yards, + "receiving_yards": yards, + "pass_touchdown": td, + "touchdown": td, + "interception": intercepted, + "passer_player_id": league_gsis_id(team, "QB"), + "passer_player_name": league_player_name(team, "QB"), + "receiver_player_id": league_gsis_id(team, position), + "receiver_player_name": league_player_name(team, position), + } + + +def _carry(team: str, *, yards: float = 0.0, td: float = 0.0, fumbled: float = 0.0) -> dict: + """One rush by `team`'s starting back, optionally lost on a fumble.""" + fields = { + "play_type": "run", + "rush_attempt": 1.0, + "rushing_yards": yards, + "rush_touchdown": td, + "touchdown": td, + "rusher_player_id": league_gsis_id(team, "RB"), + "rusher_player_name": league_player_name(team, "RB"), + } + if fumbled: + fields["fumble_lost"] = fumbled + fields["fumbled_1_player_id"] = league_gsis_id(team, "RB") + fields["fumbled_1_player_name"] = league_player_name(team, "RB") + return fields + + +def _dead_ball(play_type: str) -> dict: + """One snap that is neither a pass nor a rush.""" + return {"play_type": play_type} + + +@pytest.fixture() +def pbp() -> pl.DataFrame: + """Play-by-play for four league games plus one postseason game, season 2025. + + Shape: one row per play. NYG and DAL take snaps in weeks 1 and 2 of 2025, PHI in + week 1, WAS in week 2, and NYG again in week ``POSTSEASON_WEEK``. Carries the + identifier, indicator, yardage and EPA columns the red zone engine and + ``ffb.data.stats`` read; indicator and yardage columns are Float64, as nflverse + publishes them. + + Property: every red zone number is a small whole count. NYG takes 3 red zone + trips over 8 plays, splitting them 4 passes to 4 rushes, and scores on all 3 — + its receiver draws 3 of the 4 red zone targets and its tight end the fourth. + DAL takes 4 trips over 8 plays, splits them 6 passes to 2 rushes and scores once. + PHI takes 1 trip and scores; WAS takes 1 and does not. Plays that must not reach + a red zone total sit inside the 20 to prove the filters: a field goal and a + no-play on NYG drives, and a receiving touchdown on a season_type POST row. + Away from the red zone the plays make each weekly line whole: NYG's week 1 gives + the quarterback 50 passing yards with one touchdown and one interception, the + receiver 5 targets for 3 catches and 30 yards, the tight end 2 catches for 20 + yards and a score, and the back 5 carries for 30 yards, a score and a lost fumble. + """ + rows: list[dict] = [] + + def play( + week: int, posteam: str, drive: int, yardline: float, fields: dict, epa: float + ) -> None: + rows.append( + { + **dict.fromkeys(_PBP_SCHEMA, None), + **_PBP_ZERO, + "game_id": league_game_id(2025, week, posteam), + "season": 2025, + "week": week, + "season_type": "REG", + "posteam": posteam, + "defteam": league_opponent(2025, week, posteam), + "fixed_drive": drive, + "yardline_100": yardline, + "epa": epa, + **fields, + } + ) + + # NYG, week 1 — red zone + play(1, "NYG", 3, 15.0, _throw("NYG", "WR", yards=5.0), 0.5) + play(1, "NYG", 3, 10.0, _carry("NYG", yards=5.0), 0.5) + play(1, "NYG", 3, 5.0, _throw("NYG", "TE", yards=5.0, td=1.0), 2.0) + play(1, "NYG", 7, 8.0, _carry("NYG", yards=3.0), 0.0) + play(1, "NYG", 7, 5.0, _carry("NYG", yards=5.0, td=1.0), 1.0) + # NYG, week 1 — inside the 20, but neither a pass nor a rush + play(1, "NYG", 3, 12.0, _dead_ball("no_play"), 0.0) + play(1, "NYG", 5, 15.0, _dead_ball("field_goal"), 0.5) + # NYG, week 1 — outside the 20 + play(1, "NYG", 1, 75.0, _throw("NYG", "WR", yards=10.0), 1.0) + play(1, "NYG", 1, 60.0, _throw("NYG", "WR", yards=15.0), 1.0) + play(1, "NYG", 1, 45.0, _throw("NYG", "WR", complete=0.0), -0.5) + play(1, "NYG", 2, 55.0, _throw("NYG", "TE", yards=15.0), 1.0) + play(1, "NYG", 2, 40.0, _throw("NYG", "WR", complete=0.0, intercepted=1.0), -3.0) + play(1, "NYG", 4, 70.0, _carry("NYG", yards=10.0), 0.5) + play(1, "NYG", 4, 60.0, _carry("NYG", yards=7.0, fumbled=1.0), -2.0) + + # NYG, week 2 + play(2, "NYG", 2, 20.0, _carry("NYG"), -1.0) + play(2, "NYG", 2, 20.0, _throw("NYG", "WR", complete=0.0), -1.0) + play(2, "NYG", 2, 20.0, _throw("NYG", "WR", yards=20.0, td=1.0), 2.0) + play(2, "NYG", 4, 50.0, _throw("NYG", "TE", yards=30.0), 1.5) + + # DAL, weeks 1 and 2 + play(1, "DAL", 4, 18.0, _throw("DAL", "WR", yards=6.0), -0.5) + play(1, "DAL", 4, 12.0, _throw("DAL", "TE", complete=0.0), -0.5) + play(1, "DAL", 8, 15.0, _carry("DAL", yards=2.0), -0.5) + play(1, "DAL", 8, 13.0, _throw("DAL", "WR", complete=0.0), -0.5) + play(2, "DAL", 5, 19.0, _throw("DAL", "TE", yards=9.0), 0.5) + play(2, "DAL", 5, 10.0, _throw("DAL", "WR", yards=10.0, td=1.0), 1.5) + play(2, "DAL", 9, 20.0, _carry("DAL", yards=1.0), 0.0) + play(2, "DAL", 9, 19.0, _throw("DAL", "TE", complete=0.0), 0.0) + + # PHI and WAS + play(1, "PHI", 6, 10.0, _throw("PHI", "WR", yards=5.0), 0.0) + play(1, "PHI", 6, 5.0, _carry("PHI", yards=5.0, td=1.0), 2.0) + play(2, "WAS", 3, 20.0, _throw("WAS", "WR", complete=0.0), -1.0) + play(2, "WAS", 3, 20.0, _throw("WAS", "TE", complete=0.0), -1.0) + + # Postseason + rows.append( + { + **dict.fromkeys(_PBP_SCHEMA, None), + **_PBP_ZERO, + "game_id": f"2025_{POSTSEASON_WEEK}_NYG_DAL", "season": 2025, - "week": week, - "game_type": "REG", - "home_team": "DAL", - "away_team": "NYG", - }) - return pl.DataFrame(rows) + "week": POSTSEASON_WEEK, + "season_type": "POST", + "posteam": "NYG", + "defteam": "DAL", + "fixed_drive": 1, + "yardline_100": 5.0, + "epa": 5.0, + **_throw("NYG", "WR", yards=5.0, td=1.0), + } + ) + + return pl.DataFrame(rows, schema=_PBP_SCHEMA) diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..cfa6b86 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,184 @@ +"""Properties of the app shell: the tool registry, the sidebar and the switcher.""" + +from __future__ import annotations + +import polars as pl +import pytest +from textual.pilot import Pilot +from textual.widgets import ContentSwitcher, Label, ListItem, ListView + +from ffb.app import FFBApp +from ffb.ui.base import ToolView +from tests.test_ui_base import LoaderCall, loader_frames, piloted, settle, stub_loaders + +#: Every tool the app registers, in sidebar order. +TOOL_IDS = [tool_id for tool_id, _ in FFBApp.TOOLS] + +#: What the sidebar calls each tool. +TOOL_LABELS = dict(FFBApp.TOOLS) + +#: The tool the shell opens on. +FIRST_TOOL = TOOL_IDS[0] + +#: The tools reached by navigating to them. +LATER_TOOLS = TOOL_IDS[1:] + + +@pytest.fixture() +def loaders( + monkeypatch: pytest.MonkeyPatch, + league_snap_counts: pl.DataFrame, + league_weekly_stats: pl.DataFrame, + player_ids: pl.DataFrame, + full_season_schedules: pl.DataFrame, + injuries: pl.DataFrame, + pbp: pl.DataFrame, + rosters: pl.DataFrame, +) -> list[LoaderCall]: + """Every loader call the tools make, in order, against the league fixtures.""" + calls: list[LoaderCall] = [] + stub_loaders( + monkeypatch, + loader_frames( + league_snap_counts, + league_weekly_stats, + player_ids, + full_season_schedules, + injuries, + pbp, + rosters, + ), + calls, + ) + return calls + + +async def select_tool(pilot: Pilot[None], tool_id: str) -> None: + """Walk the sidebar cursor onto `tool_id` and select it, as a user does.""" + nav = pilot.app.query_one("#nav", ListView) + nav.focus() + nav.index = TOOL_IDS.index(tool_id) + await pilot.press("enter") + await settle(pilot) + + +# ── the registry ───────────────────────────────────────────────────────────── + + +def test_every_tool_is_registered() -> None: + assert TOOL_IDS == [ + "snap-share", + "red-zone", + "injury-impact", + "waiver-wire", + "start-sit", + "trade-value", + ] + + +@piloted +async def test_the_sidebar_lists_one_entry_per_registered_tool( + loaders: list[LoaderCall], +) -> None: + async with FFBApp().run_test() as pilot: + await settle(pilot) + nav = pilot.app.query_one("#nav", ListView) + + assert [item.id for item in nav.query(ListItem)] == [ + f"nav-{tool_id}" for tool_id in TOOL_IDS + ] + + +@pytest.mark.parametrize("tool_id", TOOL_IDS) +@piloted +async def test_each_sidebar_entry_carries_the_registered_label( + tool_id: str, loaders: list[LoaderCall] +) -> None: + async with FFBApp().run_test() as pilot: + await settle(pilot) + item = pilot.app.query_one(f"#nav-{tool_id}", ListItem) + + assert str(item.query_one(Label).content) == TOOL_LABELS[tool_id] + + +@piloted +async def test_the_switcher_carries_one_pane_per_registered_tool( + loaders: list[LoaderCall], +) -> None: + async with FFBApp().run_test() as pilot: + await settle(pilot) + switcher = pilot.app.query_one(ContentSwitcher) + + assert [pane.id for pane in switcher.children] == TOOL_IDS + + +@pytest.mark.parametrize("tool_id", TOOL_IDS) +@piloted +async def test_every_registered_tool_id_resolves_to_a_mounted_view( + tool_id: str, loaders: list[LoaderCall] +) -> None: + async with FFBApp().run_test() as pilot: + await settle(pilot) + view = pilot.app.query_one(f"#{tool_id}", ToolView) + + assert view.parent is pilot.app.query_one(ContentSwitcher) + + +# ── navigation ─────────────────────────────────────────────────────────────── + + +@piloted +async def test_the_shell_opens_on_the_first_registered_tool( + loaders: list[LoaderCall], +) -> None: + async with FFBApp().run_test() as pilot: + await settle(pilot) + + assert pilot.app.query_one(ContentSwitcher).current == FIRST_TOOL + + +@piloted +async def test_the_tool_the_shell_opens_on_is_the_only_one_that_loads( + loaders: list[LoaderCall], +) -> None: + """A tool downloads when it is shown, so opening the shell costs one tool's load.""" + async with FFBApp().run_test() as pilot: + await settle(pilot) + + assert {call.view for call in loaders} == {FIRST_TOOL} + + +@pytest.mark.parametrize("tool_id", TOOL_IDS) +@piloted +async def test_selecting_a_tool_shows_it(tool_id: str, loaders: list[LoaderCall]) -> None: + async with FFBApp().run_test() as pilot: + await settle(pilot) + await select_tool(pilot, tool_id) + + assert pilot.app.query_one(ContentSwitcher).current == tool_id + + +@pytest.mark.parametrize("tool_id", LATER_TOOLS) +@piloted +async def test_selecting_a_tool_loads_its_data(tool_id: str, loaders: list[LoaderCall]) -> None: + async with FFBApp().run_test() as pilot: + await settle(pilot) + loaders.clear() + await select_tool(pilot, tool_id) + + assert loaders, f"{tool_id} loaded nothing" + assert {call.view for call in loaders} == {tool_id} + + +@pytest.mark.parametrize("tool_id", TOOL_IDS) +@piloted +async def test_reselecting_a_tool_does_not_reload_it( + tool_id: str, loaders: list[LoaderCall] +) -> None: + async with FFBApp().run_test() as pilot: + await settle(pilot) + await select_tool(pilot, tool_id) + loaders.clear() + await select_tool(pilot, tool_id) + + assert loaders == [] diff --git a/tests/test_cache.py b/tests/test_cache.py index ca06365..182592d 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1,9 +1,11 @@ """Tests for the data cache layer.""" import json -import polars as pl from pathlib import Path +import polars as pl +import pytest + from ffb.data import cache @@ -64,3 +66,233 @@ def test_corrupt_parquet_self_heals(tmp_path, monkeypatch): # Meta should be cleaned up meta = json.loads((tmp_path / "_meta.json").read_text()) assert "test_key" not in meta + + +# ── Fixtures and helpers ───────────────────────────────────────────────────── + + +class Clock: + """Stand-in for the wall clock the cache stamps entries with.""" + + def __init__(self, start: float = 1_000_000.0) -> None: + self.value = start + + def time(self) -> float: + return self.value + + def advance(self, seconds: float = 1.0) -> float: + self.value += seconds + return self.value + + +@pytest.fixture() +def clock(monkeypatch): + """Freeze the cache's clock so write and access times are exact.""" + fake = Clock() + monkeypatch.setattr(cache, "time", fake) + return fake + + +@pytest.fixture() +def cache_dir(tmp_path, monkeypatch): + """Point the cache at a directory of this test's own.""" + monkeypatch.setattr(cache, "CACHE_DIR", tmp_path) + return tmp_path + + +def read_meta(cache_dir: Path) -> dict: + return json.loads((cache_dir / "_meta.json").read_text()) + + +def entry(marker: int) -> pl.DataFrame: + """A one-row frame; every entry built this way serializes to the same size.""" + return pl.DataFrame({"marker": [marker]}) + + +def fail_midway(self, path, *args, **kwargs) -> None: + """Write part of the parquet, then fail as a full disk does.""" + Path(path).write_bytes(b"PAR1 partial") + raise OSError("no space left on device") + + +# ── Access time ────────────────────────────────────────────────────────────── + + +def test_a_read_records_the_time_it_read_at(cache_dir, clock): + cache.put("k", entry(1)) + reading = clock.advance(60) + cache.get("k") + assert read_meta(cache_dir)["k"]["accessed"] == reading + + +def test_a_read_leaves_the_write_time_alone(cache_dir, clock): + written = clock.value + cache.put("k", entry(1)) + clock.advance(60) + cache.get("k") + assert read_meta(cache_dir)["k"]["timestamp"] == written + + +def test_an_entry_expires_the_ttl_after_it_was_written_not_after_it_was_read(cache_dir, clock): + cache.put("k", entry(1)) + clock.advance(cache.DEFAULT_TTL - 1) + cache.get("k") + clock.advance(2) + assert cache.get("k") is None + + +def test_an_entry_survives_to_the_end_of_its_ttl(cache_dir, clock): + cache.put("k", entry(1)) + clock.advance(cache.DEFAULT_TTL) + assert cache.get("k") is not None + + +def test_an_entry_expires_one_second_past_its_ttl(cache_dir, clock): + cache.put("k", entry(1)) + clock.advance(cache.DEFAULT_TTL + 1) + assert cache.get("k") is None + + +# ── Size ceiling ───────────────────────────────────────────────────────────── + + +def fill_three_and_read_b(cache_dir, clock, monkeypatch) -> None: + """Store a, b and c, then read b, leaving `a` least recently accessed. + + The ceiling is set to what the three occupy, so storing a fourth entry of the + same size puts the cache one entry over. + """ + for key in ("a", "b", "c"): + clock.advance() + cache.put(key, entry(1)) + clock.advance() + cache.get("b") + resident = sum((cache_dir / f"{key}.parquet").stat().st_size for key in "abc") + monkeypatch.setattr(cache, "MAX_CACHE_BYTES", resident) + clock.advance() + + +def test_the_ceiling_evicts_the_least_recently_accessed_entry(cache_dir, clock, monkeypatch): + fill_three_and_read_b(cache_dir, clock, monkeypatch) + cache.put("d", entry(1)) + assert cache.get("a") is None + + +def test_an_evicted_entry_leaves_no_data_file(cache_dir, clock, monkeypatch): + fill_three_and_read_b(cache_dir, clock, monkeypatch) + cache.put("d", entry(1)) + assert not (cache_dir / "a.parquet").exists() + + +def test_an_evicted_entry_leaves_no_record(cache_dir, clock, monkeypatch): + fill_three_and_read_b(cache_dir, clock, monkeypatch) + cache.put("d", entry(1)) + assert "a" not in read_meta(cache_dir) + + +def test_eviction_stops_once_the_cache_fits(cache_dir, clock, monkeypatch): + fill_three_and_read_b(cache_dir, clock, monkeypatch) + cache.put("d", entry(1)) + assert [key for key in ("b", "c", "d") if cache.get(key) is None] == [] + + +def test_a_read_promotes_an_entry_past_one_written_after_it(cache_dir, clock, monkeypatch): + fill_three_and_read_b(cache_dir, clock, monkeypatch) + cache.get("a") + clock.advance() + cache.put("d", entry(1)) + assert [key for key in ("a", "b", "d") if cache.get(key) is None] == [] + + +def test_a_read_promoted_entry_outlives_the_entry_it_overtook(cache_dir, clock, monkeypatch): + fill_three_and_read_b(cache_dir, clock, monkeypatch) + cache.get("a") + clock.advance() + cache.put("d", entry(1)) + assert cache.get("c") is None + + +# ── Atomic writes ──────────────────────────────────────────────────────────── + + +def test_a_write_that_fails_partway_leaves_the_stored_entry_readable(cache_dir, monkeypatch): + cache.put("k", entry(1)) + monkeypatch.setattr(pl.DataFrame, "write_parquet", fail_midway) + with pytest.raises(OSError): + cache.put("k", entry(2)) + assert cache.get("k")["marker"].to_list() == [1] + + +def test_a_write_that_fails_partway_stores_no_entry(cache_dir, monkeypatch): + monkeypatch.setattr(pl.DataFrame, "write_parquet", fail_midway) + with pytest.raises(OSError): + cache.put("k", entry(1)) + assert cache.get("k") is None + + +def test_a_write_that_fails_partway_leaves_no_data_file(cache_dir, monkeypatch): + monkeypatch.setattr(pl.DataFrame, "write_parquet", fail_midway) + with pytest.raises(OSError): + cache.put("k", entry(1)) + assert not (cache_dir / "k.parquet").exists() + + +def test_a_write_that_fails_partway_leaves_no_temporary_file(cache_dir, monkeypatch): + cache.put("k", entry(1)) + monkeypatch.setattr(pl.DataFrame, "write_parquet", fail_midway) + with pytest.raises(OSError): + cache.put("k", entry(2)) + assert sorted(path.name for path in cache_dir.iterdir()) == [ + "_meta.json", + "k.parquet", + ] + + +def test_a_stored_entry_leaves_no_temporary_file(cache_dir): + cache.put("k", entry(1)) + assert list(cache_dir.glob(".tmp-*")) == [] + + +# ── Orphan reaping ─────────────────────────────────────────────────────────── + + +def test_a_record_whose_data_file_is_gone_is_reaped(cache_dir): + cache.put("a", entry(1)) + (cache_dir / "a.parquet").unlink() + cache.put("b", entry(2)) + assert "a" not in read_meta(cache_dir) + + +def test_a_data_file_no_record_claims_is_reaped(cache_dir): + cache.put("a", entry(1)) + stray = cache_dir / "stray.parquet" + stray.write_bytes(b"unclaimed") + cache.put("b", entry(2)) + assert not stray.exists() + + +def test_reaping_leaves_the_recorded_entries_alone(cache_dir): + cache.put("a", entry(1)) + (cache_dir / "stray.parquet").write_bytes(b"unclaimed") + cache.put("b", entry(2)) + assert cache.get("a")["marker"].to_list() == [1] + + +def test_a_reaped_record_stops_being_served(cache_dir): + cache.put("a", entry(1)) + (cache_dir / "a.parquet").unlink() + cache.put("b", entry(2)) + assert cache.get("a") is None + + +def test_invalidating_one_key_leaves_the_others_readable(cache_dir): + cache.put("a", entry(1)) + cache.put("b", entry(2)) + cache.invalidate("a") + assert cache.get("b")["marker"].to_list() == [2] + + +def test_invalidating_one_key_removes_its_data_file(cache_dir): + cache.put("a", entry(1)) + cache.invalidate("a") + assert not (cache_dir / "a.parquet").exists() diff --git a/tests/test_injury_impact.py b/tests/test_injury_impact.py new file mode 100644 index 0000000..13b060f --- /dev/null +++ b/tests/test_injury_impact.py @@ -0,0 +1,592 @@ +"""Property tests for the injury impact engine. + +The league fixtures carry one absence: the ``ABSENCE_TEAM`` starter at +``ABSENCE_POSITION`` sits out ``ABSENCE_WEEKS`` of ``ABSENCE_SEASON``. The weeks +played and the weeks missed face each of the other three defenses the same number of +times, so a teammate's split average is the mean of ``POINTS_ALLOWED`` over those +defenses on the played side and that mean plus ``ABSENCE_BONUS`` on the missed side. +Every expected number below is that arithmetic, not a value copied out of a run. +""" + +import warnings +from collections.abc import Collection +from statistics import fmean + +import polars as pl +import pytest + +from ffb.injury_impact.engine import compute_injury_impact, get_searchable_players +from tests.conftest import ( + ABSENCE_BONUS, + ABSENCE_POSITION, + ABSENCE_SEASON, + ABSENCE_TEAM, + ABSENCE_WEEKS, + LEAGUE_POSITIONS, + LEAGUE_SEASONS, + LEAGUE_TEAMS, + POINTS_ALLOWED, + REGULAR_SEASON_WEEKS, + league_pfr_id, + league_player_name, + ppr_points, +) + +#: The player whose absence the league fixtures carry. +ABSENT_PLAYER = league_player_name(ABSENCE_TEAM, ABSENCE_POSITION) + +#: Teammates of the absent player: every league position but theirs. +TEAMMATE_POSITIONS = tuple(p for p in LEAGUE_POSITIONS if p != ABSENCE_POSITION) + +#: The season carrying no absence at all, so a receiver sat down there splits a +#: teammate's weeks without any box-score bonus muddying the two sides. +CLEAN_SEASON = next(s for s in LEAGUE_SEASONS if s != ABSENCE_SEASON) + +#: Default of ``compute_injury_impact(min_games_missed=...)``. +DEFAULT_MIN_GAMES_MISSED = 2 + +#: A scoreless box score, so ``ppr_points`` can price a bonus on its own. +_SCORELESS = { + "receptions": 0.0, + "receiving_yards": 0.0, + "receiving_tds": 0.0, + "rushing_yards": 0.0, + "rushing_tds": 0.0, + "passing_yards": 0.0, + "passing_tds": 0.0, + "interceptions": 0.0, + "fumbles_lost": 0.0, +} + + +def _baseline(position: str, team: str) -> float: + """Points `team`'s starter at `position` averages against the rest of the league.""" + return fmean(POINTS_ALLOWED[position][d] for d in LEAGUE_TEAMS if d != team) + + +def _bonus_points(position: str) -> float: + """Points ``ABSENCE_BONUS`` adds to a `position` box score.""" + return ppr_points({**_SCORELESS, **ABSENCE_BONUS[position]}) + + +def _sit_out( + snaps: pl.DataFrame, + weeks: Collection[int], + *, + season: int = CLEAN_SEASON, + team: str = ABSENCE_TEAM, + position: str = ABSENCE_POSITION, +) -> pl.DataFrame: + """`snaps` with one starter taking no offensive snap in the named weeks.""" + return snaps.with_columns( + pl.when( + (pl.col("season") == season) + & (pl.col("team") == team) + & (pl.col("position") == position) + & pl.col("week").is_in(list(weeks)) + ) + .then(pl.lit(0.0)) + .otherwise(pl.col("offense_snaps")) + .alias("offense_snaps") + ) + + +def _extra_player( + snaps: pl.DataFrame, *, player: str, position: str, game_type: str +) -> pl.DataFrame: + """`snaps` plus one row for a player carrying the given labels.""" + extra = snaps.head(1).with_columns( + pl.lit("ZZZ99").alias("pfr_player_id"), + pl.lit(player).alias("player"), + pl.lit(position).alias("position"), + pl.lit(game_type).alias("game_type"), + ) + return pl.concat([snaps, extra]) + + +#: Seeds the order-independence tests present the engine with. +SHUFFLE_SEEDS = range(16) + + +def _shuffled(df: pl.DataFrame, seed: int) -> pl.DataFrame: + """`df` with its rows reordered, reproducibly from `seed`.""" + return df.sample(fraction=1.0, shuffle=True, seed=seed) + + +def _teammate_row(teammates: pl.DataFrame, position: str) -> dict: + """The single row `teammates` holds for the absent player's teammate at `position`.""" + name = league_player_name(ABSENCE_TEAM, position) + rows = teammates.filter(pl.col("teammate") == name).to_dicts() + assert len(rows) == 1 + return rows[0] + + +@pytest.fixture() +def clean_snaps(league_snap_counts: pl.DataFrame) -> pl.DataFrame: + """League snap counts narrowed to the season carrying no absence.""" + return league_snap_counts.filter(pl.col("season") == CLEAN_SEASON) + + +@pytest.fixture() +def clean_stats(league_weekly_stats: pl.DataFrame) -> pl.DataFrame: + """League box scores narrowed to the season carrying no absence.""" + return league_weekly_stats.filter(pl.col("season") == CLEAN_SEASON) + + +# ── Empty results ──────────────────────────────────────────────────────────── + + +def test_unknown_player_name_returns_the_empty_result( + league_snap_counts, league_weekly_stats, player_ids +): + info, teammates = compute_injury_impact( + "Nobody Rostered", league_snap_counts, league_weekly_stats, player_ids + ) + + assert info is None + assert teammates.is_empty() + + +def test_player_who_never_sits_returns_the_empty_result(clean_snaps, clean_stats, player_ids): + info, teammates = compute_injury_impact(ABSENT_PLAYER, clean_snaps, clean_stats, player_ids) + + assert info is None + assert teammates.is_empty() + + +@pytest.mark.parametrize( + ("minimum", "missed"), + [(DEFAULT_MIN_GAMES_MISSED, DEFAULT_MIN_GAMES_MISSED - 1), (5, 4)], +) +def test_absence_shorter_than_the_minimum_returns_the_empty_result( + clean_snaps, clean_stats, player_ids, minimum, missed +): + info, teammates = compute_injury_impact( + ABSENT_PLAYER, + _sit_out(clean_snaps, range(1, missed + 1)), + clean_stats, + player_ids, + min_games_missed=minimum, + ) + + assert info is None + assert teammates.is_empty() + + +@pytest.mark.parametrize( + ("minimum", "missed"), + [(DEFAULT_MIN_GAMES_MISSED, DEFAULT_MIN_GAMES_MISSED), (5, 5)], +) +def test_absence_reaching_the_minimum_returns_a_result( + clean_snaps, clean_stats, player_ids, minimum, missed +): + info, teammates = compute_injury_impact( + ABSENT_PLAYER, + _sit_out(clean_snaps, range(1, missed + 1)), + clean_stats, + player_ids, + min_games_missed=minimum, + ) + + assert info is not None + assert info["games_missed"] == missed + assert teammates.height == len(TEAMMATE_POSITIONS) + + +# ── Player description ─────────────────────────────────────────────────────── + + +def test_player_info_names_the_team_season_the_absence_falls_in( + league_snap_counts, league_weekly_stats, player_ids +): + info, _ = compute_injury_impact( + ABSENT_PLAYER, league_snap_counts, league_weekly_stats, player_ids + ) + + assert info == { + "name": ABSENT_PLAYER, + "position": ABSENCE_POSITION, + "team": ABSENCE_TEAM, + "season": ABSENCE_SEASON, + "games_missed": len(ABSENCE_WEEKS), + } + + +def test_player_info_names_the_most_recent_qualifying_team_season( + league_snap_counts, league_weekly_stats, player_ids +): + both_seasons = _sit_out(league_snap_counts, range(1, 4)) + + info, _ = compute_injury_impact(ABSENT_PLAYER, both_seasons, league_weekly_stats, player_ids) + + assert info is not None + assert info["season"] == max(LEAGUE_SEASONS) + + +def test_seeded_shuffles_present_distinct_row_orders(league_snap_counts): + orders = { + tuple(_shuffled(league_snap_counts, seed)["week"].to_list()) for seed in SHUFFLE_SEEDS + } + + assert len(orders) == len(SHUFFLE_SEEDS) + + +def test_player_info_is_identical_across_input_row_orders( + league_snap_counts, league_weekly_stats, player_ids +): + both_seasons = _sit_out(league_snap_counts, range(1, 4)) + + descriptions = { + tuple( + sorted( + compute_injury_impact( + ABSENT_PLAYER, + _shuffled(both_seasons, seed), + _shuffled(league_weekly_stats, seed), + _shuffled(player_ids, seed), + )[0].items() + ) + ) + for seed in SHUFFLE_SEEDS + } + + assert len(descriptions) == 1 + + +def test_teammate_splits_are_identical_across_input_row_orders( + league_snap_counts, league_weekly_stats, player_ids +): + both_seasons = _sit_out(league_snap_counts, range(1, 4)) + + splits = { + tuple( + sorted( + compute_injury_impact( + ABSENT_PLAYER, + _shuffled(both_seasons, seed), + _shuffled(league_weekly_stats, seed), + _shuffled(player_ids, seed), + )[1].rows() + ) + ) + for seed in SHUFFLE_SEEDS + } + + assert len(splits) == 1 + + +# ── The with / without split ───────────────────────────────────────────────── + + +def test_teammate_gains_exactly_the_production_the_absence_frees( + league_snap_counts, league_weekly_stats, player_ids +): + _, teammates = compute_injury_impact( + ABSENT_PLAYER, league_snap_counts, league_weekly_stats, player_ids + ) + back = _teammate_row(teammates, "RB") + with_player = _baseline("RB", ABSENCE_TEAM) + + assert back["fpts_with"] == pytest.approx(with_player) + assert back["fpts_without"] == pytest.approx(with_player + _bonus_points("RB")) + assert back["delta_fpts"] == pytest.approx(_bonus_points("RB")) + + +def test_teammate_losing_production_carries_a_negative_delta( + league_snap_counts, league_weekly_stats, player_ids +): + _, teammates = compute_injury_impact( + ABSENT_PLAYER, league_snap_counts, league_weekly_stats, player_ids + ) + quarterback = _teammate_row(teammates, "QB") + + assert _bonus_points("QB") < 0 + assert quarterback["delta_fpts"] == pytest.approx(_bonus_points("QB")) + + +def test_target_split_reflects_the_targets_the_absence_frees( + league_snap_counts, league_weekly_stats, player_ids +): + _, teammates = compute_injury_impact( + ABSENT_PLAYER, league_snap_counts, league_weekly_stats, player_ids + ) + back = _teammate_row(teammates, "RB") + + assert back["tgt_without"] - back["tgt_with"] == pytest.approx(ABSENCE_BONUS["RB"]["targets"]) + + +def test_touch_split_counts_targets_alongside_carries( + league_snap_counts, league_weekly_stats, player_ids +): + _, teammates = compute_injury_impact( + ABSENT_PLAYER, league_snap_counts, league_weekly_stats, player_ids + ) + back = _teammate_row(teammates, "RB") + freed = ABSENCE_BONUS["RB"]["targets"] + ABSENCE_BONUS["RB"]["carries"] + + assert back["touches_without"] - back["touches_with"] == pytest.approx(freed) + + +def test_games_split_accounts_for_every_week_of_the_season( + league_snap_counts, league_weekly_stats, player_ids +): + _, teammates = compute_injury_impact( + ABSENT_PLAYER, league_snap_counts, league_weekly_stats, player_ids + ) + back = _teammate_row(teammates, "RB") + + assert back["games_without"] == len(ABSENCE_WEEKS) + assert back["games_with"] == REGULAR_SEASON_WEEKS - len(ABSENCE_WEEKS) + + +def test_missed_weeks_are_the_weeks_carrying_no_offensive_snap( + clean_snaps, clean_stats, player_ids +): + missed = (2, 5, 11, 17) + + info, teammates = compute_injury_impact( + ABSENT_PLAYER, _sit_out(clean_snaps, missed), clean_stats, player_ids + ) + back = _teammate_row(teammates, "RB") + + assert info is not None + assert info["games_missed"] == len(missed) + assert back["games_without"] == len(missed) + assert back["games_with"] == REGULAR_SEASON_WEEKS - len(missed) + + +def test_postseason_games_reach_neither_side_of_the_split(clean_snaps, clean_stats, player_ids): + missed = (1, 2) + + _, teammates = compute_injury_impact( + ABSENT_PLAYER, _sit_out(clean_snaps, missed), clean_stats, player_ids + ) + back = _teammate_row(teammates, "RB") + + assert back["games_with"] == REGULAR_SEASON_WEEKS - len(missed) + + +def test_every_teammate_of_the_absent_player_gets_one_row( + league_snap_counts, league_weekly_stats, player_ids +): + _, teammates = compute_injury_impact( + ABSENT_PLAYER, league_snap_counts, league_weekly_stats, player_ids + ) + + assert sorted(teammates["teammate"].to_list()) == sorted( + league_player_name(ABSENCE_TEAM, p) for p in TEAMMATE_POSITIONS + ) + + +def test_rows_are_ordered_by_absolute_fantasy_delta( + league_snap_counts, league_weekly_stats, player_ids +): + _, teammates = compute_injury_impact( + ABSENT_PLAYER, league_snap_counts, league_weekly_stats, player_ids + ) + + magnitudes = [abs(d) for d in teammates["delta_fpts"].to_list()] + assert magnitudes == sorted(magnitudes, reverse=True) + + +# ── Teammate labelling ─────────────────────────────────────────────────────── + + +#: The teammate the reclassification tests list at two positions, named — as league +#: starters are throughout this file — by the position they hold. The snap source +#: carries them at ``RECLASSIFIED_POSITION`` over ``RECLASSIFIED_WEEKS`` and at their +#: own position in every earlier week. +RECLASSIFIED_TEAMMATE = "RB" +RECLASSIFIED_POSITION = "TE" +RECLASSIFIED_WEEKS = tuple(range(10, REGULAR_SEASON_WEEKS + 1)) + +#: A second listing for that teammate inside their last week. It sorts after +#: ``RECLASSIFIED_POSITION``, so a reduction that takes the label sorting first and +#: one that takes whichever row lands last give different answers. +LATE_TIE_POSITION = "WR" + + +def _relist( + snaps: pl.DataFrame, + *, + teammate: str = RECLASSIFIED_TEAMMATE, + position: str = RECLASSIFIED_POSITION, + weeks: Collection[int] = RECLASSIFIED_WEEKS, +) -> pl.DataFrame: + """`snaps` with the absent player's `teammate` listed at `position` in `weeks`.""" + return snaps.with_columns( + pl.when( + (pl.col("pfr_player_id") == league_pfr_id(ABSENCE_TEAM, teammate)) + & pl.col("week").is_in(list(weeks)) + ) + .then(pl.lit(position)) + .otherwise(pl.col("position")) + .alias("position") + ) + + +def _second_listing( + snaps: pl.DataFrame, + *, + teammate: str = RECLASSIFIED_TEAMMATE, + position: str = LATE_TIE_POSITION, +) -> pl.DataFrame: + """`snaps` plus a row listing `teammate` at `position` in their last league week.""" + duplicate = snaps.filter( + (pl.col("pfr_player_id") == league_pfr_id(ABSENCE_TEAM, teammate)) + & (pl.col("season") == ABSENCE_SEASON) + & (pl.col("game_type") == "REG") + & (pl.col("week") == REGULAR_SEASON_WEEKS) + ).with_columns(pl.lit(position).alias("position")) + assert duplicate.height == 1 + return pl.concat([snaps, duplicate]) + + +def _reclassified_position( + snaps: pl.DataFrame, weekly_stats: pl.DataFrame, player_ids: pl.DataFrame +) -> str: + """Position the engine reports for the teammate carrying two listings.""" + _, teammates = compute_injury_impact(ABSENT_PLAYER, snaps, weekly_stats, player_ids) + return _teammate_row(teammates, RECLASSIFIED_TEAMMATE)["position"] + + +def test_teammate_position_comes_from_their_last_listed_week( + league_snap_counts, league_weekly_stats, player_ids +): + position = _reclassified_position(_relist(league_snap_counts), league_weekly_stats, player_ids) + + assert position == RECLASSIFIED_POSITION + + +def test_teammate_position_is_identical_across_input_row_orders( + league_snap_counts, league_weekly_stats, player_ids +): + reclassified = _relist(league_snap_counts) + + positions = { + _reclassified_position( + _shuffled(reclassified, seed), + _shuffled(league_weekly_stats, seed), + _shuffled(player_ids, seed), + ) + for seed in SHUFFLE_SEEDS + } + + assert positions == {RECLASSIFIED_POSITION} + + +def test_splits_are_identical_when_a_teammate_carries_two_positions( + league_snap_counts, league_weekly_stats, player_ids +): + reclassified = _relist(league_snap_counts) + + splits = { + tuple( + sorted( + compute_injury_impact( + ABSENT_PLAYER, + _shuffled(reclassified, seed), + _shuffled(league_weekly_stats, seed), + _shuffled(player_ids, seed), + )[1].rows() + ) + ) + for seed in SHUFFLE_SEEDS + } + + assert len(splits) == 1 + + +def test_two_listings_inside_the_last_week_resolve_on_the_position_label( + league_snap_counts, league_weekly_stats, player_ids +): + tied = _second_listing(_relist(league_snap_counts)) + + positions = { + _reclassified_position( + _shuffled(tied, seed), + _shuffled(league_weekly_stats, seed), + _shuffled(player_ids, seed), + ) + for seed in SHUFFLE_SEEDS + } + + assert positions == {min(RECLASSIFIED_POSITION, LATE_TIE_POSITION)} + + +# ── Confidence ─────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("missed", "confidence"), + [(2, "Low"), (3, "Low"), (4, "Med"), (6, "Med"), (7, "High"), (8, "High")], +) +def test_confidence_tier_follows_the_games_without_the_player( + clean_snaps, clean_stats, player_ids, missed, confidence +): + _, teammates = compute_injury_impact( + ABSENT_PLAYER, _sit_out(clean_snaps, range(1, missed + 1)), clean_stats, player_ids + ) + + assert teammates["games_without"].to_list() == [missed] * teammates.height + assert teammates["confidence"].to_list() == [confidence] * teammates.height + + +# ── Search ─────────────────────────────────────────────────────────────────── + + +def test_searchable_players_are_the_sorted_unique_offensive_names(league_snap_counts): + assert get_searchable_players(league_snap_counts) == sorted( + league_player_name(t, p) for t in LEAGUE_TEAMS for p in LEAGUE_POSITIONS + ) + + +def test_searchable_players_exclude_non_offensive_positions(league_snap_counts): + kicker = "NYG Kicker" + + players = get_searchable_players( + _extra_player(league_snap_counts, player=kicker, position="K", game_type="REG") + ) + + assert kicker not in players + + +def test_searchable_players_exclude_a_player_seen_only_in_the_postseason( + league_snap_counts, +): + reserve = "NYG Reserve" + + players = get_searchable_players( + _extra_player(league_snap_counts, player=reserve, position="WR", game_type="POST") + ) + + assert reserve not in players + + +# ── Polars call sites ──────────────────────────────────────────────────────── + + +def _deprecations(raised: list[warnings.WarningMessage]) -> list[str]: + """Deprecation texts among `raised`. + + A filter set to ``error`` does not stop a polars deprecation: the call returns its + result and the text lands on stderr. Recording the warnings is what puts one in + reach of an assertion. + """ + return [str(w.message) for w in raised if issubclass(w.category, DeprecationWarning)] + + +def test_the_engine_makes_no_deprecated_polars_call( + league_snap_counts, league_weekly_stats, player_ids +): + with warnings.catch_warnings(record=True) as raised: + warnings.simplefilter("always") + players = get_searchable_players(league_snap_counts) + info, teammates = compute_injury_impact( + ABSENT_PLAYER, league_snap_counts, league_weekly_stats, player_ids + ) + + assert ABSENT_PLAYER in players + assert info is not None + assert teammates.height == len(TEAMMATE_POSITIONS) + assert _deprecations(raised) == [] diff --git a/tests/test_loader.py b/tests/test_loader.py new file mode 100644 index 0000000..36a9312 --- /dev/null +++ b/tests/test_loader.py @@ -0,0 +1,476 @@ +"""Tests for the caching, download and fallback behaviour around the loaders. + +No test reaches the network: `FakeNflverse` replaces `loader._nflverse`, the single +seam every loader fetches through, and the download tests pass their own callable to +`loader._download`. +""" + +import email.message +import json +import threading +import urllib.error +from typing import Any + +import polars as pl +import pytest + +from ffb.data import cache, loader +from tests.conftest import league_gsis_id + + +class FakeNflverse: + """Stand-in for `loader._nflverse` that records every call it is given. + + Each keyword names an nfl_data_py importer and gives what that importer yields: + a frame to return, or a callable taking the arguments the loader passes. An + importer with no keyword raises `KeyError`, so a loader reaching for data the + test did not describe fails loudly. + """ + + def __init__(self, **importers: Any) -> None: + self.importers = importers + self.calls: list[tuple[str, tuple[Any, ...]]] = [] + + def __call__(self, importer: str, *args: Any) -> pl.DataFrame: + self.calls.append((importer, args)) + handler = self.importers[importer] + return handler(*args) if callable(handler) else handler + + @property + def names(self) -> list[str]: + """The importers called, in call order.""" + return [name for name, _ in self.calls] + + +def marked(marker: int) -> pl.DataFrame: + """A one-row frame whose `marker` column identifies which fetch produced it.""" + return pl.DataFrame({"marker": [marker]}) + + +_RELEASE_URL = ( + "https://github.com/nflverse/nflverse-data/releases/download" + "/player_stats/player_stats_2099.parquet" +) + + +def unavailable(*args: Any) -> pl.DataFrame: + """An importer whose release asset is not published. + + nfl_data_py reads a season straight from its GitHub release asset URL, so a + season with no asset surfaces as the 404 urllib raises: `HTTPError`, which + derives from `OSError`. The fallback tests are pinned to that exception, so + they hold against what nflverse hands the loader. + """ + raise urllib.error.HTTPError( + url=_RELEASE_URL, + code=404, + msg="Not Found", + hdrs=email.message.Message(), + fp=None, + ) + + +def published_weekly(season: int, points: float) -> pl.DataFrame: + """One row of weekly stats in the shape nflverse publishes them.""" + return pl.DataFrame( + { + "player_id": ["00-0999"], + "player_display_name": ["Published Starter"], + "recent_team": ["NYG"], + "position": ["WR"], + "season": [season], + "week": [1], + "season_type": ["REG"], + "fantasy_points_ppr": [points], + } + ) + + +@pytest.fixture(autouse=True) +def cache_dir(tmp_path, monkeypatch): + """Point the cache at a directory of this test's own.""" + monkeypatch.setattr(cache, "CACHE_DIR", tmp_path) + return tmp_path + + +@pytest.fixture() +def nflverse(monkeypatch): + """Install a `FakeNflverse` over the loader's fetch seam.""" + + def install(**importers: Any) -> FakeNflverse: + fake = FakeNflverse(**importers) + monkeypatch.setattr(loader, "_nflverse", fake) + return fake + + return install + + +@pytest.fixture() +def stall(): + """A fetch that blocks until the test ends, standing in for a stalled transfer.""" + released = threading.Event() + + def fetch(*args: Any) -> str: + released.wait() + return "released" + + yield fetch + released.set() + + +# ── Cache reads and writes ─────────────────────────────────────────────────── + + +def test_a_miss_fetches_from_the_importer_the_loader_names(nflverse): + fake = nflverse(import_snap_counts=marked(1)) + loader.load_snap_counts([2024, 2025]) + assert fake.calls == [("import_snap_counts", ([2024, 2025],))] + + +def test_a_fresh_entry_is_served_without_a_second_fetch(nflverse): + fake = nflverse(import_snap_counts=marked(1)) + loader.load_snap_counts([2025]) + loader.load_snap_counts([2025]) + assert fake.names == ["import_snap_counts"] + + +def test_a_served_entry_carries_the_values_that_were_fetched(nflverse): + nflverse(import_snap_counts=marked(7)) + loader.load_snap_counts([2025]) + assert loader.load_snap_counts([2025])["marker"].to_list() == [7] + + +def test_force_refresh_fetches_past_a_fresh_entry(nflverse): + markers = iter([1, 2]) + fake = nflverse(import_snap_counts=lambda seasons: marked(next(markers))) + loader.load_snap_counts([2025]) + loader.load_snap_counts([2025], force_refresh=True) + assert fake.names == ["import_snap_counts", "import_snap_counts"] + + +def test_force_refresh_returns_the_frame_it_fetched(nflverse): + markers = iter([1, 2]) + nflverse(import_snap_counts=lambda seasons: marked(next(markers))) + loader.load_snap_counts([2025]) + assert loader.load_snap_counts([2025], force_refresh=True)["marker"].to_list() == [2] + + +def test_force_refresh_stores_the_frame_it_fetched(nflverse): + markers = iter([1, 2]) + nflverse(import_snap_counts=lambda seasons: marked(next(markers))) + loader.load_snap_counts([2025]) + loader.load_snap_counts([2025], force_refresh=True) + # A third fetch would exhaust `markers`, so this frame comes from the cache. + assert loader.load_snap_counts([2025])["marker"].to_list() == [2] + + +# ── Cache keys ─────────────────────────────────────────────────────────────── + + +def test_a_season_key_names_the_dataset_and_its_seasons_ascending(nflverse, cache_dir): + nflverse(import_pbp_data=marked(1)) + loader.load_pbp([2025, 2024]) + assert (cache_dir / "pbp_2024_2025.parquet").exists() + + +def test_a_season_key_ignores_the_order_the_seasons_are_asked_for(nflverse): + fake = nflverse(import_snap_counts=marked(1)) + loader.load_snap_counts([2024, 2025]) + loader.load_snap_counts([2025, 2024]) + assert fake.names == ["import_snap_counts"] + + +def test_datasets_sharing_a_season_hold_separate_entries(nflverse): + nflverse(import_snap_counts=marked(1), import_pbp_data=marked(2)) + loader.load_snap_counts([2025]) + assert loader.load_pbp([2025])["marker"].to_list() == [2] + + +def test_distinct_season_lists_hold_separate_entries(nflverse): + nflverse(import_snap_counts=lambda seasons: marked(sum(seasons))) + loader.load_snap_counts([2024]) + loader.load_snap_counts([2025]) + assert loader.load_snap_counts([2024])["marker"].to_list() == [2024] + + +def test_the_player_crosswalk_is_fetched_without_seasons(nflverse): + fake = nflverse(import_ids=marked(1)) + loader.load_player_ids() + assert fake.calls == [("import_ids", ())] + + +def test_the_player_crosswalk_is_stored_under_a_key_naming_no_seasons(nflverse, cache_dir): + nflverse(import_ids=marked(1)) + loader.load_player_ids() + assert [path.name for path in cache_dir.glob("*.parquet")] == ["player_ids.parquet"] + + +# ── Download supervision ───────────────────────────────────────────────────── + + +def test_a_download_returns_what_the_fetch_produced(): + assert loader._download(lambda: "frame") == "frame" + + +def test_a_download_forwards_its_positional_arguments(): + received: list[Any] = [] + loader._download(lambda *args: received.append(args), "import_pbp_data", [2025]) + assert received == [("import_pbp_data", [2025])] + + +def test_a_failing_fetch_raises_on_the_calling_thread(): + def refuse() -> None: + raise OSError("HTTP Error 404: Not Found") + + with pytest.raises(OSError, match="404"): + loader._download(refuse) + + +def test_a_stalled_download_times_out_naming_its_timeout(stall): + with pytest.raises(TimeoutError, match="timed out after 1s"): + loader._download(stall, timeout=1) + + +def test_a_download_after_a_timeout_returns_within_its_own_budget(stall): + with pytest.raises(TimeoutError): + loader._download(stall, timeout=1) + assert loader._download(lambda: "second", timeout=1) == "second" + + +def test_a_download_beside_a_stalled_one_returns_without_waiting_for_it(stall): + stalled = threading.Thread(target=lambda: loader._download(stall, timeout=60), daemon=True) + stalled.start() + assert loader._download(lambda: "beside", timeout=1) == "beside" + + +# ── Weekly stats and the play-by-play fallback ─────────────────────────────── + + +def nyg_receiver_week_one(weekly: pl.DataFrame) -> pl.DataFrame: + """The row for the NYG receiver's opening week of 2025.""" + return weekly.filter( + (pl.col("player_id") == league_gsis_id("NYG", "WR")) + & (pl.col("season") == 2025) + & (pl.col("week") == 1) + ) + + +def test_a_published_season_passes_through_unchanged(nflverse): + nflverse(import_weekly_data=lambda seasons: published_weekly(seasons[0], 12.5)) + weekly = loader.load_weekly_stats([2024]) + assert weekly["fantasy_points_ppr"].to_list() == [12.5] + + +def test_an_unpublished_season_is_derived_from_play_by_play(nflverse, pbp, rosters): + nflverse( + import_weekly_data=unavailable, + import_pbp_data=pbp, + import_seasonal_rosters=rosters, + ) + weekly = loader.load_weekly_stats([2025]) + assert nyg_receiver_week_one(weekly)["fantasy_points_ppr"].to_list() == [6.0] + + +def test_derived_rows_count_the_targets_the_play_by_play_holds(nflverse, pbp, rosters): + nflverse( + import_weekly_data=unavailable, + import_pbp_data=pbp, + import_seasonal_rosters=rosters, + ) + weekly = loader.load_weekly_stats([2025]) + assert nyg_receiver_week_one(weekly)["targets"].to_list() == [5.0] + + +def test_derived_rows_carry_the_position_the_rosters_list(nflverse, pbp, rosters): + nflverse( + import_weekly_data=unavailable, + import_pbp_data=pbp, + import_seasonal_rosters=rosters, + ) + weekly = loader.load_weekly_stats([2025]) + assert nyg_receiver_week_one(weekly)["position"].to_list() == ["WR"] + + +def test_only_the_unpublished_seasons_reach_the_fallback(nflverse, pbp, rosters): + fake = nflverse( + import_weekly_data=lambda seasons: ( + published_weekly(2024, 12.5) if seasons == [2024] else unavailable() + ), + import_pbp_data=pbp, + import_seasonal_rosters=rosters, + ) + loader.load_weekly_stats([2024, 2025]) + assert [call for call in fake.calls if call[0] == "import_pbp_data"] == [ + ("import_pbp_data", ([2025],)) + ] + + +def test_a_published_row_survives_beside_derived_rows(nflverse, pbp, rosters): + nflverse( + import_weekly_data=lambda seasons: ( + published_weekly(2024, 12.5) if seasons == [2024] else unavailable() + ), + import_pbp_data=pbp, + import_seasonal_rosters=rosters, + ) + weekly = loader.load_weekly_stats([2024, 2025]) + published = weekly.filter(pl.col("season") == 2024) + assert published["fantasy_points_ppr"].to_list() == [12.5] + + +def test_derivation_proceeds_when_the_rosters_are_unpublished(nflverse, pbp): + nflverse( + import_weekly_data=unavailable, + import_pbp_data=pbp, + import_seasonal_rosters=unavailable, + ) + weekly = loader.load_weekly_stats([2025]) + assert nyg_receiver_week_one(weekly)["fantasy_points_ppr"].to_list() == [6.0] + + +def test_a_stalled_roster_download_propagates(nflverse, pbp): + def stalled(*args: Any) -> pl.DataFrame: + raise TimeoutError("Download timed out after 120s.") + + nflverse( + import_weekly_data=unavailable, + import_pbp_data=pbp, + import_seasonal_rosters=stalled, + ) + with pytest.raises(TimeoutError): + loader.load_weekly_stats([2025]) + + +@pytest.mark.parametrize("failure", [ValueError, AttributeError]) +def test_a_roster_failure_that_is_not_unavailability_propagates(nflverse, pbp, failure): + def refuse(seasons: list[int]) -> pl.DataFrame: + raise failure("upstream refused the request") + + nflverse( + import_weekly_data=unavailable, + import_pbp_data=pbp, + import_seasonal_rosters=refuse, + ) + with pytest.raises(failure): + loader.load_weekly_stats([2025]) + + +def test_a_roster_failure_that_is_not_unavailability_stores_nothing(nflverse, pbp, cache_dir): + def refuse(seasons: list[int]) -> pl.DataFrame: + raise ValueError("upstream refused the request") + + nflverse( + import_weekly_data=unavailable, + import_pbp_data=pbp, + import_seasonal_rosters=refuse, + ) + with pytest.raises(ValueError): + loader.load_weekly_stats([2025]) + assert list(cache_dir.glob("*.parquet")) == [] + + +@pytest.mark.parametrize("failure", [ValueError, AttributeError, TimeoutError]) +def test_a_weekly_failure_that_is_not_unavailability_propagates(nflverse, failure): + def refuse(seasons: list[int]) -> pl.DataFrame: + raise failure("upstream refused the request") + + nflverse(import_weekly_data=refuse) + with pytest.raises(failure): + loader.load_weekly_stats([2025]) + + +@pytest.mark.parametrize("failure", [ValueError, AttributeError, TimeoutError]) +def test_a_weekly_failure_that_is_not_unavailability_skips_the_fallback(nflverse, failure): + def refuse(seasons: list[int]) -> pl.DataFrame: + raise failure("upstream refused the request") + + fake = nflverse(import_weekly_data=refuse) + with pytest.raises(failure): + loader.load_weekly_stats([2025]) + assert fake.names == ["import_weekly_data"] + + +def test_a_weekly_failure_that_is_not_unavailability_stores_nothing(nflverse, cache_dir): + def refuse(seasons: list[int]) -> pl.DataFrame: + raise ValueError("upstream refused the request") + + nflverse(import_weekly_data=refuse) + with pytest.raises(ValueError): + loader.load_weekly_stats([2025]) + assert list(cache_dir.glob("*.parquet")) == [] + + +@pytest.mark.parametrize("published", [(True, True), (True, False), (False, True), (False, False)]) +def test_a_request_naming_seasons_yields_rows_however_they_are_sourced( + nflverse, pbp, rosters, published +): + """Every named season lands in the published set or the fallback, never neither.""" + sourced = dict(zip([2024, 2025], published, strict=True)) + nflverse( + import_weekly_data=lambda seasons: ( + published_weekly(seasons[0], 12.5) if sourced[seasons[0]] else unavailable() + ), + import_pbp_data=pbp, + import_seasonal_rosters=rosters, + ) + assert loader.load_weekly_stats([2024, 2025]).height > 0 + + +def test_a_request_for_no_seasons_yields_no_weekly_stats(nflverse): + nflverse() + with pytest.raises(RuntimeError, match="No weekly stats available"): + loader.load_weekly_stats([]) + + +def test_a_request_for_no_seasons_is_the_condition_the_message_names(nflverse): + nflverse() + with pytest.raises(RuntimeError, match="no seasons requested"): + loader.load_weekly_stats([]) + + +def test_a_request_for_no_seasons_reaches_no_importer(nflverse): + fake = nflverse() + with pytest.raises(RuntimeError): + loader.load_weekly_stats([]) + assert fake.names == [] + + +def test_derived_weekly_stats_are_cached_under_their_season_key(nflverse, pbp, rosters, cache_dir): + nflverse( + import_weekly_data=unavailable, + import_pbp_data=pbp, + import_seasonal_rosters=rosters, + ) + loader.load_weekly_stats([2025]) + assert (cache_dir / "weekly_stats_2025.parquet").exists() + + +def test_a_derived_season_is_fetched_once_and_then_served_from_the_cache(nflverse, pbp, rosters): + fake = nflverse( + import_weekly_data=unavailable, + import_pbp_data=pbp, + import_seasonal_rosters=rosters, + ) + loader.load_weekly_stats([2025]) + loader.load_weekly_stats([2025]) + assert fake.names == [ + "import_weekly_data", + "import_pbp_data", + "import_seasonal_rosters", + ] + + +def test_a_completed_load_leaves_no_temporary_file(nflverse, cache_dir): + nflverse(import_snap_counts=marked(1)) + loader.load_snap_counts([2025]) + assert sorted(path.name for path in cache_dir.iterdir()) == [ + "_meta.json", + "snap_counts_2025.parquet", + ] + + +def test_a_stored_entry_is_recorded_under_the_key_it_was_stored_with(nflverse, cache_dir): + nflverse(import_injuries=marked(1)) + loader.load_injuries([2024, 2025]) + meta = json.loads((cache_dir / "_meta.json").read_text()) + assert list(meta) == ["injuries_2024_2025"] diff --git a/tests/test_project_gates.py b/tests/test_project_gates.py new file mode 100644 index 0000000..d3f8eda --- /dev/null +++ b/tests/test_project_gates.py @@ -0,0 +1,59 @@ +"""The build gates the repository commits to, pinned against silent relaxation.""" + +import tomllib +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +PYPROJECT = REPO_ROOT / "pyproject.toml" +CI_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "ci.yml" + + +def _mypy_config() -> dict[str, object]: + with PYPROJECT.open("rb") as handle: + config = tomllib.load(handle) + mypy: dict[str, object] = config["tool"]["mypy"] + return mypy + + +def test_mypy_runs_in_strict_mode() -> None: + assert _mypy_config()["strict"] is True + + +def test_mypy_config_carves_out_no_strict_mode_check() -> None: + """Every boolean under `[tool.mypy]` enables a check rather than disabling one. + + A `false` here reads as strict while exempting whichever check it names, so the + only honest way to relax one is to drop `strict` itself. + """ + disabled = [key for key, value in _mypy_config().items() if value is False] + assert disabled == [] + + +def test_mypy_suppressions_cover_only_stubless_dependencies() -> None: + """No module under `src` buys its clean type check by silencing its own types. + + A dependency that ships no stubs is outside this repository's control, so + `import-untyped` is the one code an inline suppression may carry. + """ + suppressed = [ + f"{path.relative_to(REPO_ROOT)}:{number}" + for path in sorted((REPO_ROOT / "src").rglob("*.py")) + for number, line in enumerate(path.read_text().splitlines(), start=1) + if "type: ignore" in line and "type: ignore[import-untyped]" not in line + ] + assert suppressed == [] + + +def test_ci_fails_on_unformatted_source() -> None: + assert "uv run ruff format --check ." in CI_WORKFLOW.read_text() + + +def test_ci_fails_on_lint_type_test_and_build_failure() -> None: + workflow = CI_WORKFLOW.read_text() + for command in ( + "uv run ruff check .", + "uv run mypy src", + "uv run pytest --cov", + "uv build", + ): + assert command in workflow diff --git a/tests/test_red_zone.py b/tests/test_red_zone.py new file mode 100644 index 0000000..12480e4 --- /dev/null +++ b/tests/test_red_zone.py @@ -0,0 +1,448 @@ +"""Property tests for the red zone efficiency engine. + +Every expected number is read off the ``pbp`` fixture's construction rule: NYG takes +3 red zone trips over 8 plays split 4 passes to 4 rushes and scores on all 3, DAL +takes 4 trips over 8 plays split 6 passes to 2 rushes and scores once, PHI takes 1 +trip and scores, and WAS takes 1 and does not. +""" + +import polars as pl +import pytest + +from ffb.red_zone.engine import compute_player_rz, compute_team_rz +from tests.conftest import ( + LEAGUE_POSITIONS, + LEAGUE_TEAMS, + league_gsis_id, + league_player_name, +) + +#: The columns ``compute_team_rz`` declares. Its docstring is the contract, so a +#: column arriving or leaving without the docstring following it is a break. +TEAM_COLUMNS = frozenset( + { + "team", + "season", + "rz_trips", + "rz_tds", + "conv_pct", + "pass_pct", + "rush_pct", + "rz_epa", + } +) + +#: The columns ``compute_player_rz`` declares. +PLAYER_COLUMNS = frozenset( + { + "player", + "position", + "team", + "season", + "rz_targets", + "rz_tgt_share", + "rz_carries", + "rz_touches", + "rz_tds", + "td_pct", + } +) + +#: Distance to the goal line at which a snap is a red zone snap. +RED_ZONE_YARDLINE = 20.0 + +#: A second display name for one GSIS id. nflverse fills ``receiver_player_name`` and +#: ``rusher_player_name`` from separate source fields, so one player reaches the engine +#: under two spellings. +ALTERNATE_SPELLING = "N. Receiver" + + +def _red_zone_plays(pbp: pl.DataFrame) -> pl.DataFrame: + """Regular-season pass and run plays snapped on or inside the twenty.""" + return pbp.filter( + (pl.col("yardline_100") <= RED_ZONE_YARDLINE) + & (pl.col("season_type") == "REG") + & pl.col("play_type").is_in(["pass", "run"]) + ) + + +def _team_row(teams: pl.DataFrame, team: str) -> dict: + """The single row `teams` holds for `team`.""" + rows = teams.filter(pl.col("team") == team).to_dicts() + assert len(rows) == 1 + return rows[0] + + +def _player_row(players: pl.DataFrame, team: str, position: str) -> dict: + """The single row `players` holds for `team`'s starter at `position`.""" + rows = players.filter(pl.col("player") == league_player_name(team, position)).to_dicts() + assert len(rows) == 1 + return rows[0] + + +def _rows_for(players: pl.DataFrame, team: str, position: str) -> list[dict]: + """Every row `players` holds for `team` at `position`, found without the name.""" + return players.filter((pl.col("team") == team) & (pl.col("position") == position)).to_dicts() + + +def _moved_downfield(pbp: pl.DataFrame) -> pl.DataFrame: + """`pbp` with every snap taken from midfield, so no play is a red zone play.""" + return pbp.with_columns(pl.lit(50.0).alias("yardline_100")) + + +def _replayed_a_season_later(pbp: pl.DataFrame) -> pl.DataFrame: + """`pbp` alongside a copy of itself played out one season later.""" + return pl.concat([pbp, pbp.with_columns(pl.col("season") + 1)]) + + +def _one_snap_from(pbp: pl.DataFrame, yardline: float) -> pl.DataFrame: + """One regular-season pass play of `pbp`, snapped `yardline` yards from the goal.""" + one = pbp.filter((pl.col("season_type") == "REG") & (pl.col("play_type") == "pass")).head(1) + assert one.height == 1 + return one.with_columns(pl.lit(yardline).alias("yardline_100")) + + +def _hand_off_to(pbp: pl.DataFrame, team: str, position: str) -> pl.DataFrame: + """`pbp` with `team`'s red zone rushes credited to its starter at `position`.""" + rushed = ( + (pl.col("posteam") == team) + & (pl.col("rush_attempt") == 1.0) + & (pl.col("yardline_100") <= RED_ZONE_YARDLINE) + ) + return pbp.with_columns( + pl.when(rushed) + .then(pl.lit(league_gsis_id(team, position))) + .otherwise(pl.col("rusher_player_id")) + .alias("rusher_player_id"), + pl.when(rushed) + .then(pl.lit(league_player_name(team, position))) + .otherwise(pl.col("rusher_player_name")) + .alias("rusher_player_name"), + ) + + +def _respell_rusher(pbp: pl.DataFrame, player_id: str, spelling: str) -> pl.DataFrame: + """`pbp` with every rush by `player_id` credited under the display name `spelling`.""" + return pbp.with_columns( + pl.when(pl.col("rusher_player_id") == player_id) + .then(pl.lit(spelling)) + .otherwise(pl.col("rusher_player_name")) + .alias("rusher_player_name"), + ) + + +def _reordered(pbp: pl.DataFrame) -> pl.DataFrame: + """The same plays as `pbp`, arriving in the opposite row order.""" + return pbp.reverse() + + +# ── Declared shape ─────────────────────────────────────────────────────────── + + +def test_team_frame_carries_exactly_the_declared_columns(pbp): + assert set(compute_team_rz(pbp).columns) == TEAM_COLUMNS + + +def test_player_frame_carries_exactly_the_declared_columns(pbp, rosters): + assert set(compute_player_rz(pbp, rosters).columns) == PLAYER_COLUMNS + + +def test_team_frame_carries_the_declared_columns_when_no_play_reaches_the_red_zone(pbp): + empty = compute_team_rz(_moved_downfield(pbp)) + + assert empty.height == 0 + assert set(empty.columns) == TEAM_COLUMNS + + +def test_player_frame_carries_the_declared_columns_when_no_play_reaches_the_red_zone(pbp, rosters): + empty = compute_player_rz(_moved_downfield(pbp), rosters) + + assert empty.height == 0 + assert set(empty.columns) == PLAYER_COLUMNS + + +# ── Which plays are red zone plays ─────────────────────────────────────────── + + +def test_snap_on_the_twenty_yard_line_is_a_red_zone_snap(pbp): + on_the_line = _one_snap_from(pbp, RED_ZONE_YARDLINE) + + assert compute_team_rz(on_the_line).height == 1 + + +def test_snap_beyond_the_twenty_yard_line_is_not_a_red_zone_snap(pbp): + beyond_the_line = _one_snap_from(pbp, RED_ZONE_YARDLINE + 0.5) + + assert compute_team_rz(beyond_the_line).height == 0 + + +def test_snaps_taken_outside_the_twenty_reach_no_team_total(pbp): + assert compute_team_rz(_moved_downfield(pbp)).height == 0 + + +def test_postseason_snaps_reach_no_team_total(pbp): + postseason = pbp.filter(pl.col("season_type") != "REG") + + assert postseason.height > 0 + assert compute_team_rz(postseason).height == 0 + + +def test_team_touchdowns_count_only_regular_season_red_zone_scores(pbp): + postseason_scores = pbp.filter( + (pl.col("season_type") != "REG") + & (pl.col("yardline_100") <= RED_ZONE_YARDLINE) + & (pl.col("touchdown") == 1.0) + ) + + assert postseason_scores.height == 1 + assert _team_row(compute_team_rz(pbp), "NYG")["rz_tds"] == 3 + + +def test_snaps_that_are_neither_pass_nor_rush_reach_no_team_total(pbp): + dead_ball = pbp.filter( + (pl.col("posteam") == "NYG") + & (pl.col("yardline_100") <= RED_ZONE_YARDLINE) + & ~pl.col("play_type").is_in(["pass", "run"]) + ) + nyg = _team_row(compute_team_rz(pbp), "NYG") + + assert dead_ball.height == 2 + assert nyg["pass_pct"] + nyg["rush_pct"] == pytest.approx(100.0) + + +# ── Team totals ────────────────────────────────────────────────────────────── + + +def test_trips_count_unique_drives_rather_than_plays(pbp): + nyg_plays = _red_zone_plays(pbp).filter(pl.col("posteam") == "NYG") + + assert nyg_plays.height == 8 + assert _team_row(compute_team_rz(pbp), "NYG")["rz_trips"] == 3 + + +def test_conversion_rate_is_touchdowns_over_trips(pbp): + dal = _team_row(compute_team_rz(pbp), "DAL") + + assert dal["rz_tds"] == 1 + assert dal["rz_trips"] == 4 + assert dal["conv_pct"] == pytest.approx(25.0) + + +def test_conversion_rate_is_zero_for_a_team_that_scores_no_red_zone_touchdown(pbp): + was = _team_row(compute_team_rz(pbp), "WAS") + + assert was["rz_tds"] == 0 + assert was["conv_pct"] == pytest.approx(0.0) + + +def test_pass_and_rush_shares_split_the_red_zone_plays(pbp): + dal = _team_row(compute_team_rz(pbp), "DAL") + + assert dal["pass_pct"] == pytest.approx(75.0) + assert dal["rush_pct"] == pytest.approx(25.0) + + +def test_red_zone_epa_averages_the_red_zone_plays(pbp): + assert _team_row(compute_team_rz(pbp), "NYG")["rz_epa"] == pytest.approx(0.5) + + +def test_every_team_taking_a_red_zone_snap_gets_one_row(pbp): + teams = compute_team_rz(pbp) + + assert sorted(teams["team"].to_list()) == sorted(LEAGUE_TEAMS) + + +def test_team_totals_are_kept_apart_by_season(pbp): + season = pbp["season"][0] + teams = compute_team_rz(_replayed_a_season_later(pbp)) + + assert teams.height == 2 * len(LEAGUE_TEAMS) + assert _team_row(teams.filter(pl.col("season") == season), "NYG")["rz_trips"] == 3 + + +# ── Player totals ──────────────────────────────────────────────────────────── + + +def test_target_share_divides_a_players_red_zone_targets_by_the_teams(pbp, rosters): + players = compute_player_rz(pbp, rosters) + receiver = _player_row(players, "NYG", "WR") + tight_end = _player_row(players, "NYG", "TE") + + assert receiver["rz_targets"] == 3 + assert tight_end["rz_targets"] == 1 + assert receiver["rz_tgt_share"] == pytest.approx(75.0) + + +def test_target_shares_of_a_team_sum_to_one_hundred(pbp, rosters): + shares = ( + compute_player_rz(pbp, rosters) + .group_by("team") + .agg(pl.col("rz_tgt_share").sum().alias("total")) + ) + + assert shares.height == len(LEAGUE_TEAMS) + for row in shares.to_dicts(): + assert row["total"] == pytest.approx(100.0) + + +def test_target_share_divides_by_the_teams_targets_in_that_season(pbp, rosters): + later = pbp["season"][0] + 1 + players = compute_player_rz(_replayed_a_season_later(pbp), rosters) + + row = _player_row(players.filter(pl.col("season") == later), "NYG", "WR") + assert row["rz_targets"] == 3 + assert row["rz_tgt_share"] == pytest.approx(75.0) + + +def test_carries_count_only_red_zone_rushes(pbp, rosters): + assert _player_row(compute_player_rz(pbp, rosters), "NYG", "RB")["rz_carries"] == 4 + + +def test_touches_add_targets_to_carries(pbp, rosters): + players = compute_player_rz(pbp, rosters) + + assert players.height > 0 + for row in players.to_dicts(): + assert row["rz_touches"] == row["rz_targets"] + row["rz_carries"] + + +def test_touchdown_rate_is_touchdowns_over_touches(pbp, rosters): + back = _player_row(compute_player_rz(pbp, rosters), "NYG", "RB") + + assert back["rz_tds"] == 1 + assert back["rz_touches"] == 4 + assert back["td_pct"] == pytest.approx(25.0) + + +def test_touchdown_rate_is_zero_for_a_player_who_scores_no_red_zone_touchdown(pbp, rosters): + tight_end = _player_row(compute_player_rz(pbp, rosters), "DAL", "TE") + + assert tight_end["rz_touches"] == 3 + assert tight_end["rz_tds"] == 0 + assert tight_end["td_pct"] == pytest.approx(0.0) + + +def test_touchdown_rate_is_never_null(pbp, rosters): + assert compute_player_rz(pbp, rosters)["td_pct"].null_count() == 0 + + +def test_player_taking_no_red_zone_target_reports_a_zero_target_share(pbp, rosters): + back = _player_row(compute_player_rz(pbp, rosters), "NYG", "RB") + + assert back["rz_targets"] == 0 + assert back["rz_tgt_share"] == pytest.approx(0.0) + + +def test_player_who_both_catches_and_runs_in_the_red_zone_gets_one_row(pbp, rosters): + carried_by_the_receiver = _hand_off_to(pbp, "NYG", "WR") + players = compute_player_rz(carried_by_the_receiver, rosters) + receiver = _player_row(players, "NYG", "WR") + + assert receiver["rz_targets"] == 3 + assert receiver["rz_carries"] == 4 + assert receiver["rz_touches"] == 7 + + +def test_position_comes_from_the_roster(pbp, rosters): + players = compute_player_rz(pbp, rosters) + + for row in players.to_dicts(): + team, _, _ = row["player"].partition(" ") + assert row["position"] in LEAGUE_POSITIONS + assert row["player"] == league_player_name(team, row["position"]) + + +# ── One player, one row ────────────────────────────────────────────────────── + + +def test_player_spelled_two_ways_across_the_name_columns_gets_one_row(pbp, rosters): + two_spellings = _respell_rusher( + _hand_off_to(pbp, "NYG", "WR"), + league_gsis_id("NYG", "WR"), + ALTERNATE_SPELLING, + ) + receiver = _rows_for(compute_player_rz(two_spellings, rosters), "NYG", "WR") + + assert len(receiver) == 1 + assert receiver[0]["rz_targets"] == 3 + assert receiver[0]["rz_carries"] == 4 + assert receiver[0]["rz_touches"] == 7 + assert receiver[0]["rz_tds"] == 2 + assert receiver[0]["td_pct"] == pytest.approx(200.0 / 7) + + +def test_player_spelled_two_ways_renders_under_the_receiving_spelling(pbp, rosters): + two_spellings = _respell_rusher( + _hand_off_to(pbp, "NYG", "WR"), + league_gsis_id("NYG", "WR"), + ALTERNATE_SPELLING, + ) + receiver = _rows_for(compute_player_rz(two_spellings, rosters), "NYG", "WR") + + assert len(receiver) == 1 + assert receiver[0]["player"] == league_player_name("NYG", "WR") + + +def test_player_who_only_runs_in_the_red_zone_renders_under_the_rushing_spelling(pbp, rosters): + back = _rows_for(compute_player_rz(pbp, rosters), "NYG", "RB") + + assert len(back) == 1 + assert back[0]["rz_targets"] == 0 + assert back[0]["player"] == league_player_name("NYG", "RB") + + +def test_every_player_row_carries_a_display_name(pbp, rosters): + assert compute_player_rz(pbp, rosters)["player"].null_count() == 0 + + +# ── Touchdown rate is defined on every row ─────────────────────────────────── + + +def test_every_player_row_carries_at_least_one_red_zone_touch(pbp, rosters): + touches = compute_player_rz(pbp, rosters)["rz_touches"] + + assert touches.len() > 0 + assert touches.min() >= 1 + + +def test_touchdown_rate_is_the_touchdown_ratio_on_every_row(pbp, rosters): + rows = compute_player_rz(pbp, rosters).to_dicts() + + assert rows + for row in rows: + assert row["td_pct"] == pytest.approx(row["rz_tds"] / row["rz_touches"] * 100) + + +# ── Row order ──────────────────────────────────────────────────────────────── + + +def test_team_rows_land_in_one_order_whatever_order_the_plays_arrive_in(pbp): + assert compute_team_rz(pbp).equals(compute_team_rz(_reordered(pbp))) + + +def test_team_rows_hold_that_order_across_two_seasons_of_the_same_plays(pbp): + both_seasons = _replayed_a_season_later(pbp) + + assert compute_team_rz(both_seasons).equals(compute_team_rz(_reordered(both_seasons))) + + +def test_teams_tying_on_conversion_rate_are_ordered_by_team(pbp): + perfect = compute_team_rz(pbp).filter(pl.col("conv_pct") == 100.0) + + assert perfect["team"].to_list() == ["NYG", "PHI"] + + +def test_player_rows_land_in_one_order_whatever_order_the_plays_arrive_in(pbp, rosters): + assert compute_player_rz(pbp, rosters).equals(compute_player_rz(_reordered(pbp), rosters)) + + +def test_players_tying_on_touchdowns_are_ordered_by_touches_then_name(pbp, rosters): + scorers = compute_player_rz(pbp, rosters).filter(pl.col("rz_tds") == 1) + + assert scorers["player"].to_list() == [ + league_player_name("NYG", "RB"), + league_player_name("DAL", "WR"), + league_player_name("NYG", "WR"), + league_player_name("NYG", "TE"), + league_player_name("PHI", "RB"), + ] diff --git a/tests/test_seasons.py b/tests/test_seasons.py new file mode 100644 index 0000000..f8f60b6 --- /dev/null +++ b/tests/test_seasons.py @@ -0,0 +1,85 @@ +"""Tests for nflverse season arithmetic. + +Every case injects the date it resolves against, so a run on one day agrees with a +run on any other. +""" + +from datetime import date + +import pytest + +from ffb.data.seasons import current_season, recent_seasons + + +@pytest.mark.parametrize("month", [1, 2, 3, 4, 5, 6, 7, 8]) +def test_a_month_before_september_belongs_to_the_prior_season(month): + assert current_season(date(2025, month, 15)) == 2024 + + +@pytest.mark.parametrize("month", [9, 10, 11, 12]) +def test_september_onward_belongs_to_the_season_the_year_labels(month): + assert current_season(date(2025, month, 15)) == 2025 + + +def test_the_last_day_of_august_belongs_to_the_prior_season(): + assert current_season(date(2025, 8, 31)) == 2024 + + +def test_the_first_day_of_september_opens_the_season_the_year_labels(): + assert current_season(date(2025, 9, 1)) == 2025 + + +def test_january_belongs_to_the_season_that_opened_the_prior_autumn(): + assert current_season(date(2026, 1, 1)) == 2025 + + +def test_february_belongs_to_the_season_that_opened_the_prior_autumn(): + assert current_season(date(2026, 2, 28)) == 2025 + + +def test_a_leap_day_belongs_to_the_season_that_opened_the_prior_autumn(): + assert current_season(date(2024, 2, 29)) == 2023 + + +def test_the_last_day_of_december_belongs_to_the_season_the_year_labels(): + assert current_season(date(2025, 12, 31)) == 2025 + + +def test_recent_seasons_lists_consecutive_labels_oldest_first(): + assert recent_seasons(4, date(2026, 1, 5)) == [2022, 2023, 2024, 2025] + + +@pytest.mark.parametrize("count", [1, 2, 3, 10]) +def test_recent_seasons_returns_the_requested_count(count): + assert len(recent_seasons(count, date(2025, 10, 1))) == count + + +@pytest.mark.parametrize( + "today", + [date(2025, 8, 31), date(2025, 9, 1), date(2026, 2, 1), date(2025, 12, 31)], +) +def test_recent_seasons_ends_at_the_current_season(today): + assert recent_seasons(3, today)[-1] == current_season(today) + + +def test_recent_seasons_of_one_holds_the_current_season_alone(): + assert recent_seasons(1, date(2025, 9, 1)) == [2025] + + +def test_recent_seasons_defaults_to_a_pair(): + assert recent_seasons(today=date(2025, 9, 1)) == [2024, 2025] + + +def test_recent_seasons_crosses_the_september_boundary_with_current_season(): + assert recent_seasons(2, date(2025, 8, 31)) == [2023, 2024] + + +@pytest.mark.parametrize("count", [0, -1, -10]) +def test_a_count_below_one_is_rejected(count): + with pytest.raises(ValueError): + recent_seasons(count, date(2025, 9, 1)) + + +def test_the_rejection_names_the_count_it_rejected(): + with pytest.raises(ValueError, match="got 0"): + recent_seasons(0, date(2025, 9, 1)) diff --git a/tests/test_snap_share.py b/tests/test_snap_share.py index 12f5417..a10f0f7 100644 --- a/tests/test_snap_share.py +++ b/tests/test_snap_share.py @@ -1,14 +1,26 @@ """Tests for snap share trend engine.""" +import polars as pl +import pytest + from ffb.snap_share.engine import compute_trends +from tests.conftest import COLLIDING_FALLING_PFR_ID, COLLIDING_RISING_PFR_ID def test_compute_trends_basic(snap_counts): trends = compute_trends(snap_counts) assert trends.shape[0] > 0 assert set(trends.columns) >= { - "player", "position", "team", "season", "week", - "snap_pct", "rolling_avg", "delta", "velocity", "trend", + "player", + "position", + "team", + "season", + "week", + "snap_pct", + "rolling_avg", + "delta", + "velocity", + "trend", } @@ -42,3 +54,76 @@ def test_compute_trends_window_guard(): with pytest.raises(ValueError, match="window must be >= 2"): compute_trends(pl.DataFrame(), window=1) + + +def _by_week(trends: pl.DataFrame, pfr_id: str, column: str) -> dict: + """Value of `column` in every trend row `pfr_id` holds, keyed by week.""" + rows = trends.filter(pl.col("pfr_player_id") == pfr_id).sort("week") + return dict(zip(rows["week"].to_list(), rows[column].to_list(), strict=True)) + + +def test_colliding_names_hold_separate_rolling_averages(colliding_name_snaps): + """A rolling average covers one player's weeks, not every player wearing their name. + + The rising share reads 30, 40, 50, 60, 70, 80 and the falling one mirrors it. A + three-week window over the preceding weeks therefore averages 30 and 40 in week 3, + then 30, 40 and 50 in week 4, and the falling player averages 80 and 70, then 80, + 70 and 60. + """ + trends = compute_trends(colliding_name_snaps, window=3) + assert _by_week(trends, COLLIDING_RISING_PFR_ID, "rolling_avg") == pytest.approx( + {3: 35.0, 4: 40.0, 5: 50.0, 6: 60.0} + ) + assert _by_week(trends, COLLIDING_FALLING_PFR_ID, "rolling_avg") == pytest.approx( + {3: 75.0, 4: 70.0, 5: 60.0, 6: 50.0} + ) + + +def test_colliding_names_hold_separate_deltas(colliding_name_snaps): + """A delta measures a player's share against their own rolling average. + + The rising player is 15 points over their week-3 average of 35 and 20 points over + every later one; the falling player is under theirs by the same margins. + """ + trends = compute_trends(colliding_name_snaps, window=3) + assert _by_week(trends, COLLIDING_RISING_PFR_ID, "delta") == pytest.approx( + {3: 15.0, 4: 20.0, 5: 20.0, 6: 20.0} + ) + assert _by_week(trends, COLLIDING_FALLING_PFR_ID, "delta") == pytest.approx( + {3: -15.0, 4: -20.0, 5: -20.0, 6: -20.0} + ) + + +def test_colliding_names_hold_separate_velocities(colliding_name_snaps): + """A velocity is the slope of one player's own shares across the window. + + Each series moves ten points a week, so the two-week slope is +10 for the rising + player and -10 for the falling one in every week the window covers. + """ + trends = compute_trends(colliding_name_snaps, window=3) + assert _by_week(trends, COLLIDING_RISING_PFR_ID, "velocity") == pytest.approx( + dict.fromkeys(range(3, 7), 10.0) + ) + assert _by_week(trends, COLLIDING_FALLING_PFR_ID, "velocity") == pytest.approx( + dict.fromkeys(range(3, 7), -10.0) + ) + + +def test_colliding_names_classify_in_opposite_directions(colliding_name_snaps): + """Each player wearing one name lands on the trend their own shares describe.""" + trends = compute_trends(colliding_name_snaps, window=3) + assert _by_week(trends, COLLIDING_RISING_PFR_ID, "trend") == dict.fromkeys( + range(3, 7), "rising" + ) + assert _by_week(trends, COLLIDING_FALLING_PFR_ID, "trend") == dict.fromkeys( + range(3, 7), "falling" + ) + + +def test_a_shared_display_name_yields_one_series_per_identity(colliding_name_snaps): + """Two players wearing one name produce two windows, not one spliced series.""" + trends = compute_trends(colliding_name_snaps, window=3) + assert trends["player"].unique().to_list() == ["Casey Rivers"] + assert sorted(trends["pfr_player_id"].unique().to_list()) == sorted( + [COLLIDING_RISING_PFR_ID, COLLIDING_FALLING_PFR_ID] + ) diff --git a/tests/test_start_sit.py b/tests/test_start_sit.py new file mode 100644 index 0000000..ed334ac --- /dev/null +++ b/tests/test_start_sit.py @@ -0,0 +1,597 @@ +"""Tests for the start/sit matchup projection engine. + +Expected values are arithmetic on the fixture constants rather than literals. In the +league fixtures a starter scores exactly what the defence across from them concedes, +so a defence's points-allowed average is its ``POINTS_ALLOWED`` entry, the league +average at a position is the mean of that position's four entries, and a player's +baseline is the mean over the defences their schedule put them opposite. + +The confidence thresholds need multipliers the league fixtures cannot reach, so +``boundary_weekly_stats`` supplies ten defences averaging ``BOUNDARY_LEAGUE_AVG`` +whose points allowed land on each threshold and one point under it. +""" + +import polars as pl +import pytest + +from ffb.start_sit.engine import ( + MIN_BASELINE_GAMES, + MIN_DEFENSIVE_GAMES, + OUTPUT_SCHEMA, + _defensive_rankings, + _league_avg_by_position, + _matchups_for_week, + _player_baselines, + compute_start_sit, +) +from tests.conftest import ( + LEAGUE_POSITIONS, + LEAGUE_TEAMS, + POINTS_ALLOWED, + POSTSEASON_WEEK, + REGULAR_SEASON_WEEKS, + league_opponent, + league_player_name, +) + +#: Season of the league fixtures that carries no injury absence, so every average +#: reads straight off ``POINTS_ALLOWED``. +SEASON = 2025 + +#: First week the engine projects: three weeks of history precede it, and both league +#: pairings play it. +FIRST_PROJECTED_WEEK = MIN_DEFENSIVE_GAMES + 1 + +#: The 2025 bye week, when only NYG and DAL take the field. +BYE_WEEK = 7 + + +# ── Boundary league ────────────────────────────────────────────────────────── + +#: Points a defence concedes per game to the one opposing receiver, over ten teams +#: averaging exactly ``BOUNDARY_LEAGUE_AVG``. A multiplier is therefore an entry +#: divided by 100, and the entries pair each confidence threshold with the value one +#: point below it. +BOUNDARY_ALLOWED = { + "BUF": 120.0, + "MIA": 119.0, + "NYJ": 108.0, + "NE": 107.0, + "CIN": 92.0, + "BAL": 91.0, + "CLE": 80.0, + "PIT": 79.0, + "HOU": 102.0, + "IND": 102.0, +} + +BOUNDARY_LEAGUE_AVG = 100.0 +BOUNDARY_SEASON = 2025 +BOUNDARY_WEEK = MIN_DEFENSIVE_GAMES + 1 +BOUNDARY_POSITION = "WR" + +#: Who plays whom in ``BOUNDARY_WEEK``, home side first. Each pair puts a team on a +#: threshold defence opposite a team one point under it. +BOUNDARY_PAIRS = ( + ("BUF", "MIA"), + ("NYJ", "NE"), + ("CIN", "BAL"), + ("CLE", "PIT"), + ("HOU", "IND"), +) + + +def _boundary_opponent(team: str, week: int) -> str: + """Defence `team` faces in a baseline week of ``boundary_weekly_stats``.""" + teams = list(BOUNDARY_ALLOWED) + return teams[(teams.index(team) + week) % len(teams)] + + +def _boundary_baseline(team: str) -> float: + """Per-game average of `team`'s receiver across the baseline weeks.""" + faced = [ + BOUNDARY_ALLOWED[_boundary_opponent(team, week)] + for week in range(1, MIN_DEFENSIVE_GAMES + 1) + ] + return sum(faced) / len(faced) + + +@pytest.fixture() +def boundary_weekly_stats() -> pl.DataFrame: + """Receiver box scores for the ten ``BOUNDARY_ALLOWED`` teams. + + Shape: one row per team per baseline week, season ``BOUNDARY_SEASON``, + season_type REG, position ``BOUNDARY_POSITION``. Carries the columns the engine + reads: player_id, player_display_name, position, recent_team, opponent_team, + season, week, season_type, fantasy_points_ppr. + + Property: each defence is faced once a week by exactly one receiver scoring its + ``BOUNDARY_ALLOWED`` entry, so every defence clears ``MIN_DEFENSIVE_GAMES``, every + points-allowed average equals its entry, and the league average is exactly + ``BOUNDARY_LEAGUE_AVG``. + """ + rows = [] + for team in BOUNDARY_ALLOWED: + for week in range(1, MIN_DEFENSIVE_GAMES + 1): + opponent = _boundary_opponent(team, week) + rows.append( + { + "player_id": f"00-{team}", + "player_display_name": f"{team} Receiver", + "position": BOUNDARY_POSITION, + "recent_team": team, + "opponent_team": opponent, + "season": BOUNDARY_SEASON, + "week": week, + "season_type": "REG", + "fantasy_points_ppr": BOUNDARY_ALLOWED[opponent], + } + ) + return pl.DataFrame(rows) + + +@pytest.fixture() +def boundary_schedules() -> pl.DataFrame: + """The ``BOUNDARY_PAIRS`` games, all in ``BOUNDARY_WEEK``. + + Shape: one row per pair, season ``BOUNDARY_SEASON``, game_type REG. + + Property: every team in ``BOUNDARY_ALLOWED`` plays once, so each receiver in + ``boundary_weekly_stats`` draws exactly one projection. + """ + return pl.DataFrame( + [ + { + "game_id": f"{BOUNDARY_SEASON}_{BOUNDARY_WEEK:02d}_{away}_{home}", + "season": BOUNDARY_SEASON, + "week": BOUNDARY_WEEK, + "game_type": "REG", + "home_team": home, + "away_team": away, + } + for home, away in BOUNDARY_PAIRS + ] + ) + + +# ── Three-team league ──────────────────────────────────────────────────────── + +#: Points each receiver of the three-team fixture scores every week. LAR's defence +#: faces both of the others, so it concedes the sum of the two; SEA's faces only LAR; +#: ARI's faces nobody. +TRIO_RECEIVER_POINTS = {"LAR": 10.0, "SEA": 12.0, "ARI": 8.0} + +TRIO_LAR_ALLOWED = TRIO_RECEIVER_POINTS["SEA"] + TRIO_RECEIVER_POINTS["ARI"] +TRIO_SEA_ALLOWED = TRIO_RECEIVER_POINTS["LAR"] +TRIO_SEASON = 2025 +TRIO_WEEK = MIN_DEFENSIVE_GAMES + 1 + + +@pytest.fixture() +def trio_weekly_stats() -> pl.DataFrame: + """Receiver box scores for three teams whose defences carry unequal samples. + + Shape: one row per team per baseline week, season ``TRIO_SEASON``, season_type + REG, position WR. + + Property: SEA and ARI both face LAR every week and LAR faces SEA, so LAR's defence + is charged ``TRIO_LAR_ALLOWED`` a week and SEA's ``TRIO_SEA_ALLOWED``, while ARI's + defence faces nobody and earns no ranking at all. + """ + schedule = {"LAR": "SEA", "SEA": "LAR", "ARI": "LAR"} + rows = [] + for team, opponent in schedule.items(): + for week in range(1, MIN_DEFENSIVE_GAMES + 1): + rows.append( + { + "player_id": f"00-{team}", + "player_display_name": f"{team} Receiver", + "position": "WR", + "recent_team": team, + "opponent_team": opponent, + "season": TRIO_SEASON, + "week": week, + "season_type": "REG", + "fantasy_points_ppr": TRIO_RECEIVER_POINTS[team], + } + ) + return pl.DataFrame(rows) + + +@pytest.fixture() +def trio_schedules() -> pl.DataFrame: + """One game in ``TRIO_WEEK``: SEA at ARI. + + Shape: 1 row, season ``TRIO_SEASON``, game_type REG. + + Property: SEA's receiver faces the unranked ARI defence and ARI's receiver faces + the ranked SEA defence, so one side of the game is projectable and the other is + not. + """ + return pl.DataFrame( + [ + { + "game_id": f"{TRIO_SEASON}_{TRIO_WEEK:02d}_SEA_ARI", + "season": TRIO_SEASON, + "week": TRIO_WEEK, + "game_type": "REG", + "home_team": "ARI", + "away_team": "SEA", + } + ] + ) + + +# ── Expected values from the league fixtures ───────────────────────────────── + + +def league_avg(position: str) -> float: + """League-wide points allowed per game at `position`.""" + allowed = POINTS_ALLOWED[position] + return sum(allowed.values()) / len(allowed) + + +def baseline(team: str, position: str, through_week: int) -> float: + """Per-game average of `team`'s starter at `position` through `through_week`.""" + faced = [league_opponent(SEASON, week, team) for week in range(1, through_week + 1)] + scored = [POINTS_ALLOWED[position][opponent] for opponent in faced if opponent] + return sum(scored) / len(scored) + + +def row_for(result: pl.DataFrame, team: str, position: str) -> dict[str, object]: + """The one projection `result` carries for `team`'s starter at `position`.""" + match = result.filter((pl.col("team") == team) & (pl.col("position") == position)) + assert match.height == 1 + return match.row(0, named=True) + + +@pytest.fixture() +def projections( + league_weekly_stats: pl.DataFrame, full_season_schedules: pl.DataFrame +) -> pl.DataFrame: + """League projections for ``FIRST_PROJECTED_WEEK`` of ``SEASON``.""" + return compute_start_sit( + league_weekly_stats, full_season_schedules, SEASON, FIRST_PROJECTED_WEEK + ) + + +# ── Empty results ──────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "week", + [MIN_DEFENSIVE_GAMES, POSTSEASON_WEEK], + ids=["short-history", "no-scheduled-game"], +) +def test_empty_result_carries_the_full_output_schema( + league_weekly_stats: pl.DataFrame, full_season_schedules: pl.DataFrame, week: int +) -> None: + """A week the engine declines to project yields a zero-row OUTPUT_SCHEMA frame.""" + result = compute_start_sit(league_weekly_stats, full_season_schedules, SEASON, week) + assert result.height == 0 + assert result.schema == OUTPUT_SCHEMA + + +@pytest.mark.parametrize( + "week", + [MIN_DEFENSIVE_GAMES, POSTSEASON_WEEK], + ids=["short-history", "no-scheduled-game"], +) +def test_empty_result_narrows_by_position( + league_weekly_stats: pl.DataFrame, full_season_schedules: pl.DataFrame, week: int +) -> None: + """An empty result answers a position filter instead of raising.""" + result = compute_start_sit(league_weekly_stats, full_season_schedules, SEASON, week) + assert result.filter(pl.col("position") == "WR").height == 0 + + +def test_a_week_short_of_the_baseline_floor_projects_nobody( + league_weekly_stats: pl.DataFrame, full_season_schedules: pl.DataFrame +) -> None: + """No week entered with fewer than MIN_BASELINE_GAMES weeks behind it projects.""" + for week in range(1, MIN_BASELINE_GAMES + 1): + result = compute_start_sit(league_weekly_stats, full_season_schedules, SEASON, week) + assert result.height == 0 + assert result.schema == OUTPUT_SCHEMA + + +def test_first_week_with_enough_history_projects(projections: pl.DataFrame) -> None: + """Three preceding weeks are enough history to project a week.""" + assert projections.height == len(LEAGUE_TEAMS) * len(LEAGUE_POSITIONS) + + +# ── Multiplier and projection ──────────────────────────────────────────────── + + +def test_defence_allowing_the_league_average_yields_a_unit_multiplier( + projections: pl.DataFrame, +) -> None: + """A defence conceding exactly the positional league average scales by 1.0.""" + assert POINTS_ALLOWED["QB"]["DAL"] == league_avg("QB") + assert row_for(projections, "NYG", "QB")["matchup_mult"] == 1.0 + + +def test_unit_multiplier_projects_the_baseline(projections: pl.DataFrame) -> None: + """A 1.0 multiplier leaves the projection equal to the player's baseline.""" + row = row_for(projections, "NYG", "QB") + expected = baseline("NYG", "QB", FIRST_PROJECTED_WEEK - 1) + assert row["baseline_fpts"] == pytest.approx(expected) + assert row["projected_fpts"] == pytest.approx(expected) + + +def test_weak_defence_projects_above_the_baseline(projections: pl.DataFrame) -> None: + """A defence conceding more than the league average lifts the projection.""" + row = row_for(projections, "PHI", "WR") + multiplier = POINTS_ALLOWED["WR"]["WAS"] / league_avg("WR") + expected = baseline("PHI", "WR", FIRST_PROJECTED_WEEK - 1) * multiplier + assert multiplier > 1.0 + assert row["projected_fpts"] == pytest.approx(expected) + assert row["projected_fpts"] > row["baseline_fpts"] + + +def test_strong_defence_projects_below_the_baseline(projections: pl.DataFrame) -> None: + """A defence conceding less than the league average cuts the projection.""" + row = row_for(projections, "DAL", "WR") + multiplier = POINTS_ALLOWED["WR"]["NYG"] / league_avg("WR") + expected = baseline("DAL", "WR", FIRST_PROJECTED_WEEK - 1) * multiplier + assert multiplier < 1.0 + assert row["projected_fpts"] == pytest.approx(expected) + assert row["projected_fpts"] < row["baseline_fpts"] + + +def test_points_allowed_is_the_opponent_defensive_average( + projections: pl.DataFrame, +) -> None: + """Every row carries the points its opponent concedes at that position.""" + expected = [ + POINTS_ALLOWED[row["position"]][row["opponent"]] + for row in projections.iter_rows(named=True) + ] + assert projections["fpts_allowed"].to_list() == expected + + +def test_league_average_is_the_mean_over_every_defence( + projections: pl.DataFrame, +) -> None: + """Every row carries the mean points allowed across the whole league.""" + expected = [league_avg(row["position"]) for row in projections.iter_rows(named=True)] + assert projections["league_avg"].to_list() == pytest.approx(expected) + + +def test_multiplier_is_points_allowed_over_the_league_average( + projections: pl.DataFrame, +) -> None: + """The multiplier divides opponent points allowed by the positional average.""" + expected = [ + POINTS_ALLOWED[row["position"]][row["opponent"]] / league_avg(row["position"]) + for row in projections.iter_rows(named=True) + ] + assert projections["matchup_mult"].to_list() == pytest.approx(expected) + + +def test_projection_is_the_baseline_scaled_by_the_multiplier( + projections: pl.DataFrame, +) -> None: + """Every projection is the player's baseline times their matchup multiplier.""" + expected = [ + baseline(row["team"], row["position"], FIRST_PROJECTED_WEEK - 1) + * POINTS_ALLOWED[row["position"]][row["opponent"]] + / league_avg(row["position"]) + for row in projections.iter_rows(named=True) + ] + assert projections["projected_fpts"].to_list() == pytest.approx(expected) + + +def test_projections_are_ordered_by_projected_points( + projections: pl.DataFrame, +) -> None: + """Rows descend by projected points.""" + values = projections["projected_fpts"].to_list() + assert values == sorted(values, reverse=True) + + +# ── Confidence tiers ───────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("team", "threshold", "tier"), + [ + ("MIA", 1.20, "Strong Start"), + ("NE", 1.08, "Lean Start"), + ("BAL", 0.92, "Neutral"), + ("PIT", 0.80, "Lean Sit"), + ], +) +def test_confidence_tier_at_its_threshold( + boundary_weekly_stats: pl.DataFrame, + boundary_schedules: pl.DataFrame, + team: str, + threshold: float, + tier: str, +) -> None: + """A multiplier landing exactly on a threshold takes the tier above it.""" + result = compute_start_sit( + boundary_weekly_stats, boundary_schedules, BOUNDARY_SEASON, BOUNDARY_WEEK + ) + row = row_for(result, team, BOUNDARY_POSITION) + assert row["matchup_mult"] == threshold + assert row["confidence"] == tier + + +@pytest.mark.parametrize( + ("team", "threshold", "tier"), + [ + ("BUF", 1.20, "Lean Start"), + ("NYJ", 1.08, "Neutral"), + ("CIN", 0.92, "Lean Sit"), + ("CLE", 0.80, "Strong Sit"), + ], +) +def test_confidence_tier_below_its_threshold( + boundary_weekly_stats: pl.DataFrame, + boundary_schedules: pl.DataFrame, + team: str, + threshold: float, + tier: str, +) -> None: + """A multiplier one point under a threshold takes the tier below it.""" + result = compute_start_sit( + boundary_weekly_stats, boundary_schedules, BOUNDARY_SEASON, BOUNDARY_WEEK + ) + row = row_for(result, team, BOUNDARY_POSITION) + assert row["matchup_mult"] < threshold + assert row["confidence"] == tier + + +# ── Matchups ───────────────────────────────────────────────────────────────── + + +def test_matchups_for_week_faces_each_team_with_its_opponent( + full_season_schedules: pl.DataFrame, +) -> None: + """Each game yields two rows, one per side.""" + matchups = _matchups_for_week(full_season_schedules, SEASON, FIRST_PROJECTED_WEEK) + expected = { + (team, league_opponent(SEASON, FIRST_PROJECTED_WEEK, team)) for team in LEAGUE_TEAMS + } + assert matchups.height == len(LEAGUE_TEAMS) + assert set(zip(matchups["team"], matchups["opponent"], strict=True)) == expected + + +def test_matchups_for_week_omits_a_team_on_its_bye( + full_season_schedules: pl.DataFrame, +) -> None: + """A team without a game in the week has no matchup row.""" + matchups = _matchups_for_week(full_season_schedules, SEASON, BYE_WEEK) + playing = {team for team in LEAGUE_TEAMS if league_opponent(SEASON, BYE_WEEK, team)} + assert set(matchups["team"]) == playing + assert matchups.height == len(playing) + + +def test_matchups_for_week_omits_postseason_games( + full_season_schedules: pl.DataFrame, +) -> None: + """A week holding only a postseason game yields no matchups.""" + assert _matchups_for_week(full_season_schedules, SEASON, POSTSEASON_WEEK).is_empty() + + +def test_teams_on_a_bye_receive_no_projection( + league_weekly_stats: pl.DataFrame, full_season_schedules: pl.DataFrame +) -> None: + """Only the starters of teams playing that week are projected.""" + result = compute_start_sit(league_weekly_stats, full_season_schedules, SEASON, BYE_WEEK) + playing = {team for team in LEAGUE_TEAMS if league_opponent(SEASON, BYE_WEEK, team)} + assert set(result["team"]) == playing + assert result.height == len(playing) * len(LEAGUE_POSITIONS) + + +def test_each_starter_is_projected_once(projections: pl.DataFrame) -> None: + """One row per starter, so no join multiplies a player across matchups.""" + expected = { + league_player_name(team, position) for team in LEAGUE_TEAMS for position in LEAGUE_POSITIONS + } + assert sorted(projections["player"]) == sorted(expected) + + +# ── Baselines ──────────────────────────────────────────────────────────────── + + +def test_player_baselines_require_three_games( + league_weekly_stats: pl.DataFrame, +) -> None: + """Two games of history yield no baseline; three yield one per starter.""" + short = _player_baselines(league_weekly_stats, SEASON, MIN_DEFENSIVE_GAMES - 1) + full = _player_baselines(league_weekly_stats, SEASON, MIN_DEFENSIVE_GAMES) + assert short.height == 0 + assert full.height == len(LEAGUE_TEAMS) * len(LEAGUE_POSITIONS) + + +@pytest.mark.parametrize("through_week", [3, 4, REGULAR_SEASON_WEEKS]) +def test_player_baseline_averages_the_games_inside_the_window( + league_weekly_stats: pl.DataFrame, through_week: int +) -> None: + """A baseline is the mean over the games played up to and including the week.""" + baselines = _player_baselines(league_weekly_stats, SEASON, through_week) + expected = [ + baseline(row["team"], row["position"], through_week) + for row in baselines.iter_rows(named=True) + ] + assert baselines["baseline_fpts"].to_list() == pytest.approx(expected) + + +def test_player_baselines_exclude_postseason_scoring( + league_weekly_stats: pl.DataFrame, +) -> None: + """A postseason line carries no weight in a regular-season baseline.""" + baselines = _player_baselines(league_weekly_stats, SEASON, POSTSEASON_WEEK) + row = baselines.filter((pl.col("team") == "NYG") & (pl.col("position") == "QB")).row( + 0, named=True + ) + assert row["baseline_fpts"] == pytest.approx(baseline("NYG", "QB", REGULAR_SEASON_WEEKS)) + + +# ── Defensive rankings ─────────────────────────────────────────────────────── + + +def test_defensive_rankings_require_three_games( + league_weekly_stats: pl.DataFrame, +) -> None: + """Two games of history rank no defence; three rank every position of each.""" + short = _defensive_rankings(league_weekly_stats, SEASON, MIN_DEFENSIVE_GAMES - 1) + full = _defensive_rankings(league_weekly_stats, SEASON, MIN_DEFENSIVE_GAMES) + assert short.height == 0 + assert full.height == len(LEAGUE_TEAMS) * len(LEAGUE_POSITIONS) + + +def test_defensive_ranking_is_the_points_conceded_per_game( + league_weekly_stats: pl.DataFrame, +) -> None: + """Each ranking is the average the defence concedes at that position.""" + ranks = _defensive_rankings(league_weekly_stats, SEASON, MIN_DEFENSIVE_GAMES) + expected = [ + POINTS_ALLOWED[row["position"]][row["opponent_team"]] for row in ranks.iter_rows(named=True) + ] + assert ranks["fpts_allowed"].to_list() == pytest.approx(expected) + + +def test_defensive_rankings_exclude_postseason_scoring( + league_weekly_stats: pl.DataFrame, +) -> None: + """A postseason line carries no weight in a defensive ranking.""" + ranks = _defensive_rankings(league_weekly_stats, SEASON, POSTSEASON_WEEK) + row = ranks.filter((pl.col("opponent_team") == "DAL") & (pl.col("position") == "QB")).row( + 0, named=True + ) + assert row["fpts_allowed"] == pytest.approx(POINTS_ALLOWED["QB"]["DAL"]) + + +def test_defence_is_charged_every_player_it_faces( + trio_weekly_stats: pl.DataFrame, +) -> None: + """A defence facing two receivers in a week concedes the sum of the two.""" + ranks = _defensive_rankings(trio_weekly_stats, TRIO_SEASON, MIN_DEFENSIVE_GAMES) + row = ranks.filter(pl.col("opponent_team") == "LAR").row(0, named=True) + assert row["fpts_allowed"] == pytest.approx(TRIO_LAR_ALLOWED) + + +def test_league_average_is_the_mean_of_the_ranked_defences( + league_weekly_stats: pl.DataFrame, +) -> None: + """The positional average is the mean over every ranked defence.""" + ranks = _defensive_rankings(league_weekly_stats, SEASON, MIN_DEFENSIVE_GAMES) + averages = _league_avg_by_position(ranks) + expected = [league_avg(row["position"]) for row in averages.iter_rows(named=True)] + assert averages.height == len(LEAGUE_POSITIONS) + assert averages["league_avg"].to_list() == pytest.approx(expected) + + +def test_unranked_defence_leaves_its_opponent_unprojected( + trio_weekly_stats: pl.DataFrame, trio_schedules: pl.DataFrame +) -> None: + """A player facing a defence with no ranking is dropped, not carried as a null.""" + result = compute_start_sit(trio_weekly_stats, trio_schedules, TRIO_SEASON, TRIO_WEEK) + assert result["player"].to_list() == ["ARI Receiver"] + expected = TRIO_RECEIVER_POINTS["ARI"] * ( + TRIO_SEA_ALLOWED / ((TRIO_LAR_ALLOWED + TRIO_SEA_ALLOWED) / 2) + ) + assert result["projected_fpts"][0] == pytest.approx(expected) diff --git a/tests/test_stats.py b/tests/test_stats.py new file mode 100644 index 0000000..5a0f055 --- /dev/null +++ b/tests/test_stats.py @@ -0,0 +1,631 @@ +"""Tests for weekly stats derived from play-by-play. + +The play builders below carry every column ``compute_weekly_stats_from_pbp`` reads +and nothing else, so a frame built here is the function's whole input contract. +Every expected point total is the PPR arithmetic spelled out against the plays that +produced it, so a failure names the scoring term that moved. +""" + +from typing import NamedTuple + +import polars as pl +import pytest +from polars.testing import assert_frame_equal + +from ffb.data.stats import compute_weekly_stats_from_pbp +from ffb.start_sit.engine import OUTPUT_SCHEMA as START_SIT_SCHEMA +from ffb.start_sit.engine import compute_start_sit +from ffb.trade_value.engine import OUTPUT_SCHEMA as TRADE_VALUE_SCHEMA +from ffb.trade_value.engine import compute_trade_values +from tests.conftest import ( + LEAGUE_POSITIONS, + LEAGUE_TEAMS, + POSTSEASON_WEEK, + league_gsis_id, + league_opponent, +) + +# ── Input contract ─────────────────────────────────────────────────────────── + +#: Every play-by-play column the function reads, typed as nflverse publishes it: +#: indicators and yardage as floats, identifiers and names as strings. +PBP_SCHEMA = { + "play_type": pl.String, + "posteam": pl.String, + "defteam": pl.String, + "season": pl.Int64, + "week": pl.Int64, + "season_type": pl.String, + "pass_attempt": pl.Float64, + "rush_attempt": pl.Float64, + "complete_pass": pl.Float64, + "pass_touchdown": pl.Float64, + "rush_touchdown": pl.Float64, + "interception": pl.Float64, + "fumble_lost": pl.Float64, + "passing_yards": pl.Float64, + "receiving_yards": pl.Float64, + "rushing_yards": pl.Float64, + "passer_player_id": pl.String, + "passer_player_name": pl.String, + "receiver_player_id": pl.String, + "receiver_player_name": pl.String, + "rusher_player_id": pl.String, + "rusher_player_name": pl.String, + "fumbled_1_player_id": pl.String, + "fumbled_1_player_name": pl.String, +} + +#: Columns that count events or yardage, and so read zero rather than null on a +#: player who took no play of that kind. +STAT_COLUMNS = ( + "targets", + "receptions", + "receiving_yards", + "receiving_tds", + "carries", + "rushing_yards", + "rushing_tds", + "passing_yards", + "passing_tds", + "interceptions", + "fumbles_lost", +) + +_INDICATOR_COLUMNS = ( + "pass_attempt", + "rush_attempt", + "complete_pass", + "pass_touchdown", + "rush_touchdown", + "interception", + "fumble_lost", + "passing_yards", + "receiving_yards", + "rushing_yards", +) + +HOME = "HOM" +AWAY = "AWY" +SEASON = 2025 +WEEK = 1 + +#: The week the start/sit and trade value engines act on. Three weeks of results +#: precede it, the least history either engine takes. +PROJECTED_WEEK = 4 + + +class Player(NamedTuple): + """A play-by-play identity: the id a stat line keys on and the name it carries.""" + + player_id: str + name: str + + +PASSER = Player("00-0011", "Ada Everett") +CATCHER = Player("00-0012", "Bo Lindqvist") +CARRIER = Player("00-0013", "Cy Marchetti") +ALL_PURPOSE = Player("00-0014", "Dee Halvorsen") + + +def play(**fields: object) -> dict[str, object]: + """One snap by `HOME` against `AWAY` in week `WEEK`, with every indicator at zero.""" + row: dict[str, object] = dict.fromkeys(PBP_SCHEMA) + row.update(dict.fromkeys(_INDICATOR_COLUMNS, 0.0)) + row.update( + { + "posteam": HOME, + "defteam": AWAY, + "season": SEASON, + "week": WEEK, + "season_type": "REG", + } + ) + row.update(fields) + return row + + +def throw( + passer: Player, + receiver: Player, + *, + complete: float = 1.0, + yards: float = 0.0, + td: float = 0.0, + intercepted: float = 0.0, + offense: str = HOME, + defense: str = AWAY, + week: int = WEEK, + season_type: str = "REG", +) -> dict[str, object]: + """One pass attempt from `passer` to `receiver`.""" + return play( + play_type="pass", + pass_attempt=1.0, + complete_pass=complete, + passing_yards=yards, + receiving_yards=yards, + pass_touchdown=td, + interception=intercepted, + passer_player_id=passer.player_id, + passer_player_name=passer.name, + receiver_player_id=receiver.player_id, + receiver_player_name=receiver.name, + posteam=offense, + defteam=defense, + week=week, + season_type=season_type, + ) + + +def carry( + rusher: Player, + *, + yards: float = 0.0, + td: float = 0.0, + fumbler: Player | None = None, + offense: str = HOME, + defense: str = AWAY, + week: int = WEEK, + season_type: str = "REG", +) -> dict[str, object]: + """One rush by `rusher`, lost on a fumble by `fumbler` when one is given.""" + fields: dict[str, object] = { + "play_type": "run", + "rush_attempt": 1.0, + "rushing_yards": yards, + "rush_touchdown": td, + "rusher_player_id": rusher.player_id, + "rusher_player_name": rusher.name, + "posteam": offense, + "defteam": defense, + "week": week, + "season_type": season_type, + } + if fumbler is not None: + fields["fumble_lost"] = 1.0 + fields["fumbled_1_player_id"] = fumbler.player_id + fields["fumbled_1_player_name"] = fumbler.name + return play(**fields) + + +def frame(plays: list[dict[str, object]]) -> pl.DataFrame: + """Play-by-play frame over `plays`, typed as nflverse publishes it.""" + return pl.DataFrame(plays, schema=PBP_SCHEMA) + + +def rows_for(stats: pl.DataFrame, player: Player) -> pl.DataFrame: + """Every weekly line `stats` carries for `player`.""" + return stats.filter(pl.col("player_id") == player.player_id) + + +def points_for(stats: pl.DataFrame, player: Player) -> float: + """`player`'s PPR points in `stats`, requiring exactly one line for them.""" + return float(rows_for(stats, player)["fantasy_points_ppr"].item()) + + +#: A week in which one player throws, catches, runs and fumbles. Their line is +#: 250 passing yards and 2 passing touchdowns against 1 interception, 5 catches for +#: 100 yards and a touchdown, and 3 carries for 50 yards, a touchdown and a fumble. +EVERY_ROLE_WEEK = [ + throw(ALL_PURPOSE, CATCHER, yards=100.0, td=1.0), + throw(ALL_PURPOSE, CATCHER, yards=100.0, td=1.0), + throw(ALL_PURPOSE, CATCHER, yards=50.0), + throw(ALL_PURPOSE, CATCHER, complete=0.0, intercepted=1.0), + throw(PASSER, ALL_PURPOSE, yards=40.0), + throw(PASSER, ALL_PURPOSE, yards=30.0), + throw(PASSER, ALL_PURPOSE, yards=20.0), + throw(PASSER, ALL_PURPOSE, yards=10.0), + throw(PASSER, ALL_PURPOSE, td=1.0), + carry(ALL_PURPOSE, yards=30.0), + carry(ALL_PURPOSE, yards=20.0, td=1.0), + carry(ALL_PURPOSE, fumbler=ALL_PURPOSE), +] + + +# ── PPR scoring ────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("plays", "scorer", "expected"), + [ + pytest.param( + [throw(PASSER, CATCHER)], + CATCHER, + 1.0, + id="a_reception_scores_one", + ), + pytest.param( + [throw(PASSER, CATCHER, complete=0.0)], + CATCHER, + 0.0, + id="an_incomplete_target_scores_nothing", + ), + pytest.param( + [throw(PASSER, CATCHER, yards=10.0)], + CATCHER, + 2.0, + id="ten_receiving_yards_score_one_over_the_reception", + ), + pytest.param( + [throw(PASSER, CATCHER, td=1.0)], + CATCHER, + 7.0, + id="a_receiving_touchdown_scores_six_over_the_reception", + ), + pytest.param( + [carry(CARRIER, yards=10.0)], + CARRIER, + 1.0, + id="ten_rushing_yards_score_one", + ), + pytest.param( + [carry(CARRIER, td=1.0)], + CARRIER, + 6.0, + id="a_rushing_touchdown_scores_six", + ), + pytest.param( + [throw(PASSER, CATCHER, yards=25.0)], + PASSER, + 1.0, + id="twenty_five_passing_yards_score_one", + ), + pytest.param( + [throw(PASSER, CATCHER, td=1.0)], + PASSER, + 4.0, + id="a_passing_touchdown_scores_four", + ), + pytest.param( + [throw(PASSER, CATCHER, complete=0.0, intercepted=1.0)], + PASSER, + -2.0, + id="an_interception_scores_minus_two", + ), + pytest.param( + [carry(CARRIER, fumbler=CARRIER)], + CARRIER, + -2.0, + id="a_lost_fumble_scores_minus_two", + ), + ], +) +def test_ppr_scoring_term(plays: list[dict[str, object]], scorer: Player, expected: float) -> None: + stats = compute_weekly_stats_from_pbp(frame(plays)) + assert points_for(stats, scorer) == pytest.approx(expected) + + +def test_ppr_points_sum_every_scoring_term() -> None: + stats = compute_weekly_stats_from_pbp(frame(EVERY_ROLE_WEEK)) + passing = 250 / 25 + 2 * 4 - 1 * 2 + receiving = 100 / 10 + 5 * 1 + 1 * 6 + rushing = 50 / 10 + 1 * 6 - 1 * 2 + assert points_for(stats, ALL_PURPOSE) == pytest.approx(passing + receiving + rushing) + + +# ── Merging the four stat lines ────────────────────────────────────────────── + + +def test_a_multi_role_week_yields_one_line_per_player() -> None: + stats = compute_weekly_stats_from_pbp(frame(EVERY_ROLE_WEEK)) + assert rows_for(stats, ALL_PURPOSE).height == 1 + + +def test_a_merged_line_carries_the_totals_of_every_role() -> None: + stats = compute_weekly_stats_from_pbp(frame(EVERY_ROLE_WEEK)) + line = rows_for(stats, ALL_PURPOSE).select(STAT_COLUMNS).row(0, named=True) + assert line == { + "targets": 5.0, + "receptions": 5.0, + "receiving_yards": 100.0, + "receiving_tds": 1.0, + "carries": 3.0, + "rushing_yards": 50.0, + "rushing_tds": 1.0, + "passing_yards": 250.0, + "passing_tds": 2.0, + "interceptions": 1.0, + "fumbles_lost": 1.0, + } + + +def test_one_line_per_player_season_week_and_season_type(pbp: pl.DataFrame) -> None: + stats = compute_weekly_stats_from_pbp(pbp) + keys = stats.select("player_id", "season", "week", "season_type") + assert keys.n_unique() == stats.height + + +def test_a_pass_with_no_receiver_credits_the_passer_alone() -> None: + stats = compute_weekly_stats_from_pbp( + frame( + [ + play( + play_type="pass", + pass_attempt=1.0, + passer_player_id=PASSER.player_id, + passer_player_name=PASSER.name, + ) + ] + ) + ) + assert stats["player_id"].to_list() == [PASSER.player_id] + + +def test_snaps_that_are_neither_pass_nor_run_produce_no_lines() -> None: + stats = compute_weekly_stats_from_pbp( + frame( + [ + play(play_type="field_goal"), + play(play_type="punt"), + play(play_type="no_play"), + ] + ) + ) + assert stats.height == 0 + + +# ── Absent roles read zero, not null ───────────────────────────────────────── + + +def test_a_player_with_no_rushing_plays_carries_zero_rushing_stats() -> None: + stats = compute_weekly_stats_from_pbp(frame([throw(PASSER, CATCHER, yards=10.0)])) + line = rows_for(stats, CATCHER).select("carries", "rushing_yards", "rushing_tds") + assert line.row(0, named=True) == {"carries": 0.0, "rushing_yards": 0.0, "rushing_tds": 0.0} + + +def test_a_player_with_no_passing_plays_carries_zero_passing_stats() -> None: + stats = compute_weekly_stats_from_pbp(frame([carry(CARRIER, yards=10.0)])) + line = rows_for(stats, CARRIER).select("passing_yards", "passing_tds", "interceptions") + assert line.row(0, named=True) == { + "passing_yards": 0.0, + "passing_tds": 0.0, + "interceptions": 0.0, + } + + +def test_no_stat_column_carries_a_null(pbp: pl.DataFrame) -> None: + stats = compute_weekly_stats_from_pbp(pbp) + counted = (*STAT_COLUMNS, "fantasy_points_ppr") + assert stats.select(counted).null_count().row(0) == (0,) * len(counted) + + +# ── Opponent ───────────────────────────────────────────────────────────────── + + +def test_opponent_team_follows_the_possessing_team() -> None: + stats = compute_weekly_stats_from_pbp( + frame( + [ + throw(PASSER, CATCHER, yards=10.0), + carry(CARRIER, yards=10.0, offense=AWAY, defense=HOME), + ] + ) + ) + assert dict( + zip(stats["player_id"].to_list(), stats["opponent_team"].to_list(), strict=True) + ) == { + PASSER.player_id: AWAY, + CATCHER.player_id: AWAY, + CARRIER.player_id: HOME, + } + + +def test_opponent_team_tracks_the_week(pbp: pl.DataFrame) -> None: + stats = compute_weekly_stats_from_pbp(pbp).filter(pl.col("season_type") == "REG") + assert stats["opponent_team"].to_list() == [ + league_opponent(row["season"], row["week"], row["recent_team"]) + for row in stats.iter_rows(named=True) + ] + + +# ── Season type ────────────────────────────────────────────────────────────── + + +def test_postseason_plays_score_under_their_own_season_type(pbp: pl.DataFrame) -> None: + post = compute_weekly_stats_from_pbp(pbp).filter(pl.col("season_type") == "POST") + assert dict( + zip(post["player_id"].to_list(), post["fantasy_points_ppr"].to_list(), strict=True) + ) == pytest.approx( + { + league_gsis_id("NYG", "QB"): 5 / 25 + 4, + league_gsis_id("NYG", "WR"): 5 / 10 + 1 + 6, + } + ) + + +def test_a_player_scores_separately_in_each_week_they_play(pbp: pl.DataFrame) -> None: + receiver = compute_weekly_stats_from_pbp(pbp).filter( + pl.col("player_id") == league_gsis_id("NYG", "WR") + ) + assert dict( + zip(receiver["week"].to_list(), receiver["fantasy_points_ppr"].to_list(), strict=True) + ) == pytest.approx( + { + 1: 30 / 10 + 3, + 2: 20 / 10 + 1 + 6, + POSTSEASON_WEEK: 5 / 10 + 1 + 6, + } + ) + + +def test_a_week_of_play_by_play_scores_every_role(pbp: pl.DataFrame) -> None: + week_one = compute_weekly_stats_from_pbp(pbp).filter( + (pl.col("recent_team") == "NYG") & (pl.col("week") == 1) + ) + assert dict( + zip( + week_one["player_id"].to_list(), + week_one["fantasy_points_ppr"].to_list(), + strict=True, + ) + ) == pytest.approx( + { + league_gsis_id("NYG", "QB"): 50 / 25 + 4 - 2, + league_gsis_id("NYG", "RB"): 30 / 10 + 6 - 2, + league_gsis_id("NYG", "WR"): 30 / 10 + 3, + league_gsis_id("NYG", "TE"): 20 / 10 + 2 + 6, + } + ) + + +# ── Position ───────────────────────────────────────────────────────────────── + + +def test_position_comes_from_the_roster(pbp: pl.DataFrame, rosters: pl.DataFrame) -> None: + stats = compute_weekly_stats_from_pbp(pbp, rosters) + listed = { + league_gsis_id(team, position): position + for team in LEAGUE_TEAMS + for position in LEAGUE_POSITIONS + } + assert stats["position"].to_list() == [listed[player_id] for player_id in stats["player_id"]] + + +def test_omitting_rosters_nulls_the_position_column_and_leaves_the_rest( + pbp: pl.DataFrame, rosters: pl.DataFrame +) -> None: + with_rosters = compute_weekly_stats_from_pbp(pbp, rosters) + without_rosters = compute_weekly_stats_from_pbp(pbp, None) + assert without_rosters.columns == with_rosters.columns + assert without_rosters.height > 0 + assert without_rosters["position"].null_count() == without_rosters.height + assert_frame_equal( + without_rosters.drop("position"), + with_rosters.drop("position"), + check_row_order=False, + ) + + +# ── Fumbles away from scrimmage ────────────────────────────────────────────── + + +def return_fumble(fumbler: Player, *, play_type: str) -> dict[str, object]: + """One `play_type` snap that `fumbler` loses on a fumble.""" + return play( + play_type=play_type, + fumble_lost=1.0, + fumbled_1_player_id=fumbler.player_id, + fumbled_1_player_name=fumbler.name, + ) + + +@pytest.mark.parametrize("play_type", ["kickoff", "punt"]) +def test_a_fumble_lost_away_from_scrimmage_scores_minus_two(play_type: str) -> None: + stats = compute_weekly_stats_from_pbp(frame([return_fumble(CARRIER, play_type=play_type)])) + assert rows_for(stats, CARRIER)["fumbles_lost"].item() == 1.0 + assert points_for(stats, CARRIER) == pytest.approx(-2.0) + + +def test_a_week_charges_fumbles_from_scrimmage_and_from_returns_alike() -> None: + stats = compute_weekly_stats_from_pbp( + frame( + [ + carry(CARRIER, yards=10.0, fumbler=CARRIER), + return_fumble(CARRIER, play_type="punt"), + ] + ) + ) + line = rows_for(stats, CARRIER) + assert line.height == 1 + assert line["fumbles_lost"].item() == 2.0 + assert points_for(stats, CARRIER) == pytest.approx(10 / 10 - 2 * 2) + + +# ── Identity across the role name columns ──────────────────────────────────── + +#: One player id under a different spelling in each role's name column, as nflverse +#: fills the four columns from separate source fields. +ROLE_SPELLINGS = { + "receiving": "Dee Halvorsen", + "rushing": "D.Halvorsen", + "passing": "Dee Halvorsen Jr.", + "fumbling": "DEE HALVORSEN", +} + + +def spelled(role: str) -> Player: + """`ALL_PURPOSE`, under the spelling their `role` name column carries.""" + return Player(ALL_PURPOSE.player_id, ROLE_SPELLINGS[role]) + + +#: A week `ALL_PURPOSE` plays in all four roles, each name column spelling them +#: differently: 25 passing yards, a catch for 10, and a carry for 10 lost on a fumble. +DISAGREEING_NAMES_WEEK = [ + throw(spelled("passing"), CATCHER, yards=25.0), + throw(PASSER, spelled("receiving"), yards=10.0), + carry(spelled("rushing"), yards=10.0, fumbler=spelled("fumbling")), +] + + +def test_a_player_spelled_differently_across_roles_yields_one_line() -> None: + stats = compute_weekly_stats_from_pbp(frame(DISAGREEING_NAMES_WEEK)) + assert rows_for(stats, ALL_PURPOSE).height == 1 + + +def test_a_line_merged_across_spellings_carries_the_totals_of_every_role() -> None: + stats = compute_weekly_stats_from_pbp(frame(DISAGREEING_NAMES_WEEK)) + line = rows_for(stats, ALL_PURPOSE).select(STAT_COLUMNS).row(0, named=True) + assert line == { + "targets": 1.0, + "receptions": 1.0, + "receiving_yards": 10.0, + "receiving_tds": 0.0, + "carries": 1.0, + "rushing_yards": 10.0, + "rushing_tds": 0.0, + "passing_yards": 25.0, + "passing_tds": 0.0, + "interceptions": 0.0, + "fumbles_lost": 1.0, + } + + +def test_a_line_merged_across_spellings_renders_one_of_them() -> None: + stats = compute_weekly_stats_from_pbp(frame(DISAGREEING_NAMES_WEEK)) + rendered = rows_for(stats, ALL_PURPOSE)["player_display_name"].item() + assert rendered in set(ROLE_SPELLINGS.values()) + + +# ── Position, and the engines that read it ─────────────────────────────────── + + +def test_the_position_column_carries_the_roster_dtype_without_rosters( + pbp: pl.DataFrame, rosters: pl.DataFrame +) -> None: + with_rosters = compute_weekly_stats_from_pbp(pbp, rosters) + without_rosters = compute_weekly_stats_from_pbp(pbp, None) + assert without_rosters.schema["position"] == with_rosters.schema["position"] + assert without_rosters["position"].null_count() == without_rosters.height + + +def test_start_sit_reads_a_frame_derived_without_rosters( + pbp: pl.DataFrame, full_season_schedules: pl.DataFrame +) -> None: + """A null position matches no offensive position, so the projection is empty.""" + stats = compute_weekly_stats_from_pbp(pbp, None) + result = compute_start_sit(stats, full_season_schedules, SEASON, PROJECTED_WEEK) + assert result.schema == START_SIT_SCHEMA + assert result.height == 0 + + +def test_trade_value_reads_a_frame_derived_without_rosters( + pbp: pl.DataFrame, + league_snap_counts: pl.DataFrame, + full_season_schedules: pl.DataFrame, + injuries: pl.DataFrame, + player_ids: pl.DataFrame, +) -> None: + """A null position matches no offensive position, so the valuation is empty.""" + stats = compute_weekly_stats_from_pbp(pbp, None) + result = compute_trade_values( + stats, + league_snap_counts, + full_season_schedules, + injuries, + player_ids, + SEASON, + PROJECTED_WEEK, + ) + assert result.schema == TRADE_VALUE_SCHEMA + assert result.height == 0 diff --git a/tests/test_trade_value.py b/tests/test_trade_value.py new file mode 100644 index 0000000..18d4bd9 --- /dev/null +++ b/tests/test_trade_value.py @@ -0,0 +1,752 @@ +"""Tests for the trade value engine. + +Expected numbers are arithmetic on the league constants in ``tests.conftest``. The +league plays a three-week pairing cycle, so a window covering whole cycles gives each +team one meeting with each of the other three, and a mean over the defenses a player +faces is a mean over ``POINTS_ALLOWED``. +""" + +import polars as pl +import pytest + +from ffb.trade_value.engine import ( + OUTPUT_SCHEMA, + REGULAR_SEASON_WEEKS, + _defensive_strength, + _health_discount, + _remaining_schedule_factor, + _team_bye_weeks, + compute_trade_values, +) +from tests.conftest import ( + ABSENCE_BONUS, + ABSENCE_SEASON, + ABSENCE_TEAM, + ABSENCE_WEEKS, + LEAGUE_POSITIONS, + LEAGUE_SEASONS, + LEAGUE_TEAMS, + PAIRINGS, + POINTS_ALLOWED, + POSTSEASON_POINTS, + POSTSEASON_WEEK, + SEASON_BYES, + SNAP_SHARE, + league_gsis_id, + league_opponent, + league_player_name, + ppr_points, +) + +#: The league season carrying a bye, and the two teams that take it. +SEASON = 2025 +BYE_WEEK, BYE_TEAMS = SEASON_BYES[SEASON] + +#: Weeks in one pass through the pairing cycle. +CYCLE_WEEKS = len(PAIRINGS) + +#: A week two full cycles into the season, ahead of the bye. Every team has played +#: each opponent twice. +BEFORE_BYE = 2 * CYCLE_WEEKS + +#: A week five full cycles into the season, past the bye. One cycle is left to play, +#: so every team has one meeting with each opponent still ahead. +LAST_FULL_CYCLE = 5 * CYCLE_WEEKS + +#: The season's earliest and latest weeks that yield values, and the weeks either side. +FIRST_SCORED_WEEK = 3 +LAST_SCORED_WEEK = REGULAR_SEASON_WEEKS - 1 + +#: Every column ``ppr_points`` scores, at zero. +BLANK_LINE = dict.fromkeys( + ( + "receiving_yards", + "receiving_tds", + "receptions", + "rushing_yards", + "rushing_tds", + "passing_yards", + "passing_tds", + "interceptions", + "fumbles_lost", + ), + 0.0, +) + +#: Points the league's one absence adds to the back's weekly line. +ABSENCE_BONUS_POINTS = ppr_points(BLANK_LINE | ABSENCE_BONUS["RB"]) + + +def _opponents(team: str) -> tuple[str, ...]: + """Every other team in the league.""" + return tuple(other for other in LEAGUE_TEAMS if other != team) + + +def _mean(values: list[float]) -> float: + """Arithmetic mean, as polars takes it.""" + return sum(values) / len(values) + + +def _allowed(position: str, defenses: tuple[str, ...]) -> list[float]: + """Points each of `defenses` concedes to a starter at `position`.""" + return [POINTS_ALLOWED[position][defense] for defense in defenses] + + +def _league_average(position: str) -> float: + """Points the average league defense concedes at `position`.""" + return _mean(list(POINTS_ALLOWED[position].values())) + + +def _row(frame: pl.DataFrame, player: str) -> dict: + """The one row `player` holds in a trade value result.""" + matches = frame.filter(pl.col("player") == player) + assert matches.shape[0] == 1 + return matches.row(0, named=True) + + +def _injury_reports(rows: list[dict]) -> pl.DataFrame: + """Injury report frame carrying the columns the health discount reads.""" + return pl.DataFrame( + rows, + schema={ + "gsis_id": pl.String, + "season": pl.Int64, + "season_type": pl.String, + "report_status": pl.String, + }, + ) + + +def _report(gsis_id: str, status: str, season_type: str = "REG", season: int = SEASON) -> dict: + """One injury report, in the regular season of `SEASON` unless told otherwise.""" + return { + "gsis_id": gsis_id, + "season": season, + "season_type": season_type, + "report_status": status, + } + + +@pytest.fixture() +def league_values( + league_weekly_stats: pl.DataFrame, + league_snap_counts: pl.DataFrame, + full_season_schedules: pl.DataFrame, + injuries: pl.DataFrame, + player_ids: pl.DataFrame, +): + """Callable computing league trade values through a given week of `SEASON`. + + Keyword arguments substitute a modified weekly stats, snap counts or schedule + frame; the rest of the league inputs stay as the shared fixtures build them. + """ + + def compute( + current_week: int, + *, + weekly_stats: pl.DataFrame | None = None, + snap_counts: pl.DataFrame | None = None, + schedules: pl.DataFrame | None = None, + ) -> pl.DataFrame: + return compute_trade_values( + league_weekly_stats if weekly_stats is None else weekly_stats, + league_snap_counts if snap_counts is None else snap_counts, + full_season_schedules if schedules is None else schedules, + injuries, + player_ids, + SEASON, + current_week, + ) + + return compute + + +# ── Bye weeks ──────────────────────────────────────────────────────────────── + + +def test_bye_week_is_the_week_a_team_sits_out(full_season_schedules: pl.DataFrame) -> None: + """A team missing one week of the regular season carries that week as its bye.""" + byes = _team_bye_weeks(full_season_schedules, SEASON) + assert dict(zip(byes["team"], byes["bye_week"], strict=True)) == { + team: BYE_WEEK for team in BYE_TEAMS + } + + +def test_a_team_playing_every_week_carries_no_bye(full_season_schedules: pl.DataFrame) -> None: + """Teams with a game in all eighteen weeks are absent from the bye table.""" + byes = _team_bye_weeks(full_season_schedules, SEASON) + playing_every_week = set(LEAGUE_TEAMS) - set(BYE_TEAMS) + assert playing_every_week.isdisjoint(byes["team"].to_list()) + + +def test_a_season_without_byes_yields_a_typed_empty_bye_table( + full_season_schedules: pl.DataFrame, +) -> None: + """A season where every team plays every week yields zero rows, still typed.""" + byes = _team_bye_weeks(full_season_schedules, ABSENCE_SEASON) + assert byes.shape[0] == 0 + assert byes.schema == pl.Schema({"team": pl.String(), "bye_week": pl.Int64()}) + + +def test_the_earliest_missing_week_is_the_bye_week() -> None: + """A team missing several weeks carries the first of them as its bye.""" + idle = (5, 9) + schedules = pl.DataFrame( + [ + { + "game_id": f"{SEASON}_{week:02d}_BBB_AAA", + "season": SEASON, + "week": week, + "game_type": "REG", + "home_team": "AAA", + "away_team": "BBB", + } + for week in range(1, REGULAR_SEASON_WEEKS + 1) + if week not in idle + ] + ) + byes = _team_bye_weeks(schedules, SEASON) + assert dict(zip(byes["team"], byes["bye_week"], strict=True)) == { + "AAA": min(idle), + "BBB": min(idle), + } + + +def test_a_postseason_game_does_not_fill_a_bye_week() -> None: + """Only regular-season games count as a week played.""" + idle = 5 + rows = [ + { + "game_id": f"{SEASON}_{week:02d}_BBB_AAA", + "season": SEASON, + "week": week, + "game_type": "REG", + "home_team": "AAA", + "away_team": "BBB", + } + for week in range(1, REGULAR_SEASON_WEEKS + 1) + if week != idle + ] + rows.append( + { + "game_id": f"{SEASON}_{idle:02d}_BBB_AAA", + "season": SEASON, + "week": idle, + "game_type": "WC", + "home_team": "AAA", + "away_team": "BBB", + } + ) + byes = _team_bye_weeks(pl.DataFrame(rows), SEASON) + assert byes["bye_week"].to_list() == [idle, idle] + + +# ── Defensive strength ─────────────────────────────────────────────────────── + + +def _row_value(strength: pl.DataFrame, defense: str, position: str) -> float: + """Points `defense` concedes at `position` in a defensive strength frame.""" + matches = strength.filter( + (pl.col("opponent_team") == defense) & (pl.col("position") == position) + ) + assert matches.shape[0] == 1 + return float(matches["fpts_allowed"][0]) + + +def test_defensive_strength_is_the_points_a_defense_concedes_per_game( + league_weekly_stats: pl.DataFrame, +) -> None: + """Each defense's average allowed at a position equals what it concedes per game.""" + strength = _defensive_strength(league_weekly_stats, SEASON, BEFORE_BYE) + allowed = { + (row["opponent_team"], row["position"]): row["fpts_allowed"] + for row in strength.iter_rows(named=True) + } + assert allowed == { + (defense, position): POINTS_ALLOWED[position][defense] + for position in LEAGUE_POSITIONS + for defense in LEAGUE_TEAMS + } + + +def test_defensive_strength_counts_the_cutoff_week(league_weekly_stats: pl.DataFrame) -> None: + """The window ends on the cutoff week and includes it. + + The first of ``ABSENCE_WEEKS`` is the first week the back on the absent team + carries the absence bonus, so the defense across from them concedes more at RB in + that week and in no earlier one. + """ + bonus_week = min(ABSENCE_WEEKS) + defense = league_opponent(ABSENCE_SEASON, bonus_week, ABSENCE_TEAM) + baseline = POINTS_ALLOWED["RB"][defense] + + through_the_bonus = _row_value( + _defensive_strength(league_weekly_stats, ABSENCE_SEASON, bonus_week), defense, "RB" + ) + before_the_bonus = _row_value( + _defensive_strength(league_weekly_stats, ABSENCE_SEASON, bonus_week - 1), defense, "RB" + ) + + assert before_the_bonus == baseline + assert through_the_bonus == _mean( + [baseline] * (bonus_week - 1) + [baseline + ABSENCE_BONUS_POINTS] + ) + + +def test_defensive_strength_excludes_postseason_games( + league_weekly_stats: pl.DataFrame, +) -> None: + """Postseason scoring leaves the regular-season average untouched.""" + strength = _defensive_strength(league_weekly_stats, SEASON, POSTSEASON_WEEK) + assert _row_value(strength, "DAL", "WR") == POINTS_ALLOWED["WR"]["DAL"] + + +# ── Remaining schedule ─────────────────────────────────────────────────────── + + +def _schedule_factor( + schedules: pl.DataFrame, + weekly_stats: pl.DataFrame, + current_week: int, + team: str, + position: str, +) -> float: + """Remaining schedule factor `team` carries at `position` after `current_week`.""" + strength = _defensive_strength(weekly_stats, SEASON, current_week) + factors = _remaining_schedule_factor(schedules, strength, SEASON, current_week) + matches = factors.filter((pl.col("team") == team) & (pl.col("position") == position)) + assert matches.shape[0] == 1 + return float(matches["sched_factor"][0]) + + +def test_remaining_schedule_factor_averages_the_defenses_still_to_face( + full_season_schedules: pl.DataFrame, + league_weekly_stats: pl.DataFrame, +) -> None: + """One cycle of games left gives the mean over the other three defenses.""" + factors = { + team: _schedule_factor( + full_season_schedules, league_weekly_stats, LAST_FULL_CYCLE, team, "WR" + ) + for team in LEAGUE_TEAMS + } + assert factors == {team: _mean(_allowed("WR", _opponents(team))) for team in LEAGUE_TEAMS} + + +def test_remaining_schedule_factor_starts_after_the_current_week( + full_season_schedules: pl.DataFrame, + league_weekly_stats: pl.DataFrame, +) -> None: + """A week counts as remaining while it sits ahead of the current week. + + A full cycle follows week ``LAST_FULL_CYCLE``, so the week before it leaves that + cycle plus one more meeting — the game week ``LAST_FULL_CYCLE`` itself holds. + """ + extra = league_opponent(SEASON, LAST_FULL_CYCLE, "NYG") + factor = _schedule_factor( + full_season_schedules, league_weekly_stats, LAST_FULL_CYCLE - 1, "NYG", "WR" + ) + assert factor == _mean(_allowed("WR", (*_opponents("NYG"), extra))) + + +def test_remaining_schedule_factor_omits_a_bye_from_the_remaining_games( + full_season_schedules: pl.DataFrame, + league_weekly_stats: pl.DataFrame, +) -> None: + """A team meets its bye partner one fewer time over the rest of the season. + + Four cycles follow week ``BEFORE_BYE``, so each pair meets four times, except the + two teams whose meeting is the bye. + """ + cycles_left = (REGULAR_SEASON_WEEKS - BEFORE_BYE) // CYCLE_WEEKS + team, partner = BYE_TEAMS + meetings = dict.fromkeys(_opponents(team), cycles_left) + meetings[partner] -= 1 + faced = [POINTS_ALLOWED["WR"][opponent] for opponent, n in meetings.items() for _ in range(n)] + + factor = _schedule_factor(full_season_schedules, league_weekly_stats, BEFORE_BYE, team, "WR") + assert factor == pytest.approx(_mean(faced)) + + +# ── Health ─────────────────────────────────────────────────────────────────── + + +def _health(frame: pl.DataFrame, gsis_id: str) -> float: + """Health a player carries in a health discount frame.""" + matches = frame.filter(pl.col("gsis_id") == gsis_id) + assert matches.shape[0] == 1 + return float(matches["health"][0]) + + +def _weeks_out(injuries: pl.DataFrame, gsis_id: str) -> int: + """Regular-season weeks a player is reported Out.""" + return injuries.filter( + (pl.col("gsis_id") == gsis_id) + & (pl.col("report_status") == "Out") + & (pl.col("season_type") == "REG") + ).shape[0] + + +def test_health_is_the_share_of_regular_season_weeks_available( + injuries: pl.DataFrame, +) -> None: + """Weeks reported Out come off a full slate for every season the frame carries.""" + receiver = league_gsis_id(ABSENCE_TEAM, "WR") + span = len(LEAGUE_SEASONS) * REGULAR_SEASON_WEEKS + assert _weeks_out(injuries, receiver) == len(ABSENCE_WEEKS) + + health = _health_discount(injuries) + assert _health(health, receiver) == pytest.approx(1.0 - len(ABSENCE_WEEKS) / span) + + +def test_health_measures_every_player_against_the_seasons_the_frame_covers( + injuries: pl.DataFrame, +) -> None: + """The slate spans every regular season reported, not those one player reports in. + + The WAS quarterback reports inside one season of the two the frame carries, and + the weeks out still come off both seasons' weeks. + """ + quarterback = league_gsis_id("WAS", "QB") + reported = injuries.filter( + (pl.col("gsis_id") == quarterback) & (pl.col("season_type") == "REG") + )["season"].n_unique() + assert reported < len(LEAGUE_SEASONS) + + span = len(LEAGUE_SEASONS) * REGULAR_SEASON_WEEKS + health = _health_discount(injuries) + assert _health(health, quarterback) == pytest.approx( + 1.0 - _weeks_out(injuries, quarterback) / span + ) + + +def test_absences_score_alike_wherever_the_reports_fall() -> None: + """Two players missing as many weeks as each other carry the same health. + + One player's Out weeks spread across both seasons the frame covers and the + other's sit inside one of them. + """ + weeks_out = len(LEAGUE_SEASONS) + spread = [_report("00-0001", "Out", season=season) for season in LEAGUE_SEASONS] + concentrated = [_report("00-0002", "Out") for _ in range(weeks_out)] + assert len(spread) == len(concentrated) == weeks_out + + health = _health_discount(_injury_reports(spread + concentrated)) + assert _health(health, "00-0001") == _health(health, "00-0002") + + +def test_health_floors_at_half_when_most_of_the_slate_is_missed() -> None: + """Missing more than half the weeks discounts no further than half.""" + weeks_out = REGULAR_SEASON_WEEKS // 2 + 1 + rows = [_report("00-0001", "Out") for _ in range(weeks_out)] + + health = _health_discount(_injury_reports(rows)) + assert _health(health, "00-0001") == 0.5 + + +def test_health_reaches_the_floor_at_exactly_half_the_weeks_missed( + injuries: pl.DataFrame, +) -> None: + """Missing exactly half the weeks lands on the floor rather than past it.""" + tight_end = league_gsis_id("PHI", "TE") + span = len(LEAGUE_SEASONS) * REGULAR_SEASON_WEEKS + assert _weeks_out(injuries, tight_end) * 2 == span + + health = _health_discount(injuries) + assert _health(health, tight_end) == 0.5 + + +def test_a_player_reported_but_never_out_is_absent_from_the_health_table( + injuries: pl.DataFrame, +) -> None: + """Reports short of Out carry no discount at all.""" + health = _health_discount(injuries) + assert league_gsis_id("DAL", "RB") not in health["gsis_id"].to_list() + + +def test_health_ignores_reports_outside_the_regular_season() -> None: + """Postseason Out reports leave the discount where the regular season put it.""" + rows = [_report("00-0001", "Out")] + rows += [_report("00-0001", "Out", season_type="POST") for _ in range(5)] + health = _health_discount(_injury_reports(rows)) + assert _health(health, "00-0001") == pytest.approx(1.0 - 1 / REGULAR_SEASON_WEEKS) + + +def test_health_counts_only_weeks_reported_out() -> None: + """A week reported anything short of Out is a week available.""" + weeks_out = 2 + rows = [_report("00-0001", "Out") for _ in range(weeks_out)] + rows += [_report("00-0001", "Questionable") for _ in range(6)] + health = _health_discount(_injury_reports(rows)) + assert _health(health, "00-0001") == pytest.approx(1.0 - weeks_out / REGULAR_SEASON_WEEKS) + + +def test_a_season_reported_only_in_the_postseason_widens_no_slate() -> None: + """A season carrying no regular-season report adds no weeks to the slate.""" + weeks_out = 2 + rows = [_report("00-0001", "Out") for _ in range(weeks_out)] + rows.append(_report("00-0002", "Out", season_type="POST", season=SEASON - 1)) + + health = _health_discount(_injury_reports(rows)) + assert _health(health, "00-0001") == pytest.approx(1.0 - weeks_out / REGULAR_SEASON_WEEKS) + + +def test_a_frame_without_regular_season_reports_discounts_nobody() -> None: + """Postseason reports alone put no player in the discount table.""" + rows = [_report("00-0001", "Out", season_type="POST")] + + health = _health_discount(_injury_reports(rows)) + assert health.is_empty() + assert health.columns == ["gsis_id", "health"] + + +# ── Guard paths ────────────────────────────────────────────────────────────── + + +def test_a_season_with_no_weeks_left_returns_the_declared_schema(league_values) -> None: + """The last week of the regular season leaves nothing to trade for.""" + result = league_values(REGULAR_SEASON_WEEKS) + assert result.shape[0] == 0 + assert result.schema == OUTPUT_SCHEMA + + +def test_the_last_week_holding_a_game_still_scores_players(league_values) -> None: + """One week left to play is enough to carry a value.""" + result = league_values(LAST_SCORED_WEEK) + assert result.shape[0] == len(LEAGUE_TEAMS) * len(LEAGUE_POSITIONS) + + +def test_a_week_below_the_minimum_returns_the_declared_schema(league_values) -> None: + """Two weeks of results are too few to score from.""" + result = league_values(FIRST_SCORED_WEEK - 1) + assert result.shape[0] == 0 + assert result.schema == OUTPUT_SCHEMA + + +def test_the_minimum_week_scores_players(league_values) -> None: + """Three weeks of results are enough to score from.""" + result = league_values(FIRST_SCORED_WEEK) + assert result.shape[0] == len(LEAGUE_TEAMS) * len(LEAGUE_POSITIONS) + + +def test_an_empty_result_filters_by_position(league_values) -> None: + """A result with no rows answers a position filter with no rows.""" + filtered = league_values(REGULAR_SEASON_WEEKS).filter(pl.col("position") == "WR") + assert filtered.shape[0] == 0 + assert filtered.schema == OUTPUT_SCHEMA + + +def test_a_season_without_results_returns_the_declared_schema( + league_values, league_weekly_stats: pl.DataFrame +) -> None: + """A season carrying no weekly results scores nobody.""" + elsewhere = league_weekly_stats.filter(pl.col("season") != SEASON) + result = league_values(LAST_FULL_CYCLE, weekly_stats=elsewhere) + assert result.shape[0] == 0 + assert result.schema == OUTPUT_SCHEMA + + +def test_a_scored_result_carries_the_declared_schema(league_values) -> None: + """Column order and dtypes match the declaration a caller indexes by.""" + assert league_values(LAST_FULL_CYCLE).schema == OUTPUT_SCHEMA + + +# ── Production and byes ────────────────────────────────────────────────────── + + +def test_a_bye_still_ahead_shortens_production_for_an_equal_scorer(league_values) -> None: + """Two players scoring the same per game part on the games they have left. + + Through week ``BEFORE_BYE`` the DAL receiver and the WAS quarterback average the + same points, and of the two teams only WAS has a bye ahead of it. + """ + assert BYE_WEEK > BEFORE_BYE + assert "WAS" in BYE_TEAMS + assert "DAL" not in BYE_TEAMS + + ppg = _mean(_allowed("WR", _opponents("DAL"))) + assert ppg == _mean(_allowed("QB", _opponents("WAS"))) + + result = league_values(BEFORE_BYE) + weeks_left = REGULAR_SEASON_WEEKS - BEFORE_BYE + playing_through = _row(result, league_player_name("DAL", "WR")) + taking_a_bye = _row(result, league_player_name("WAS", "QB")) + + assert playing_through["ppg"] == ppg + assert taking_a_bye["ppg"] == ppg + assert playing_through["production_raw"] == ppg * weeks_left + assert taking_a_bye["production_raw"] == ppg * (weeks_left - 1) + + +def test_a_bye_in_the_current_week_leaves_the_games_left_whole(league_values) -> None: + """A bye is subtracted only while it is still ahead of the current week.""" + result = league_values(BYE_WEEK) + weeks_left = REGULAR_SEASON_WEEKS - BYE_WEEK + assert result["production_raw"].to_list() == pytest.approx( + (result["ppg"] * weeks_left).to_list() + ) + + +def test_the_bye_week_of_a_players_team_is_published(league_values) -> None: + """Teams holding a bye carry its week; teams without one carry none.""" + result = league_values(BEFORE_BYE) + published = {(row["team"], row["bye_week"]) for row in result.iter_rows(named=True)} + assert published == {(team, BYE_WEEK if team in BYE_TEAMS else None) for team in LEAGUE_TEAMS} + + +# ── Composite ──────────────────────────────────────────────────────────────── + + +def test_trade_value_reconstructs_from_the_published_components(league_values) -> None: + """Production weighs 0.50, schedule 0.20, usage 0.20 and health 0.10. + + The four published components determine the value on their own: production + normalized by the field maximum, the schedule multiplier spread across its own + range, snap share as a fraction of a hundred snaps, and health as it stands. The + composite is rescaled so the leader reads 100, and every value is published to one + decimal, so the reconstruction holds to half of that digit. + """ + result = league_values(LAST_FULL_CYCLE) + + production = result["production_raw"] / result["production_raw"].max() + floor = result["sched_mult"].min() + schedule = (result["sched_mult"] - floor) / (result["sched_mult"].max() - floor + 1e-9) + usage = result["avg_snap_pct"] / 100 + composite = production * 0.50 + schedule * 0.20 + usage * 0.20 + result["health"] * 0.10 + expected = composite / composite.max() * 100 + + assert result["trade_value"].to_list() == pytest.approx(expected.to_list(), abs=0.05) + + +def test_the_most_valuable_player_scores_one_hundred(league_values) -> None: + """The scale tops out at 100.""" + assert league_values(LAST_FULL_CYCLE)["trade_value"].max() == 100.0 + + +def test_values_descend_down_the_result(league_values) -> None: + """Rows arrive ordered by trade value, most valuable first.""" + values = league_values(LAST_FULL_CYCLE)["trade_value"].to_list() + assert values == sorted(values, reverse=True) + + +def test_the_schedule_multiplier_measures_the_remaining_schedule_against_the_league( + league_values, +) -> None: + """A multiplier above one marks a remaining schedule easier than the league's.""" + result = league_values(LAST_FULL_CYCLE).filter(pl.col("position") == "WR").sort("team") + assert result["team"].to_list() == sorted(LEAGUE_TEAMS) + assert result["sched_mult"].to_list() == pytest.approx( + [ + _mean(_allowed("WR", _opponents(team))) / _league_average("WR") + for team in sorted(LEAGUE_TEAMS) + ] + ) + + +def test_a_schedule_without_games_leaves_the_multiplier_neutral( + league_values, full_season_schedules: pl.DataFrame +) -> None: + """A team with no opponent to measure carries a multiplier of one.""" + result = league_values(LAST_FULL_CYCLE, schedules=full_season_schedules.head(0)) + assert result["sched_mult"].to_list() == [1.0] * result.shape[0] + + +def test_usage_is_the_average_snap_share_in_points(league_values) -> None: + """Snap share published as a fraction reaches the result on a hundred-point scale.""" + result = league_values(LAST_FULL_CYCLE).sort("position", "team") + assert result["avg_snap_pct"].to_list() == pytest.approx( + [SNAP_SHARE[position] * 100 for position in sorted(LEAGUE_POSITIONS) for _ in LEAGUE_TEAMS] + ) + + +def test_snap_share_published_in_points_scores_the_same( + league_values, league_snap_counts: pl.DataFrame +) -> None: + """A snap share of 0.8 and one of 80 describe the same usage.""" + in_points = league_snap_counts.with_columns(pl.col("offense_pct") * 100) + assert league_values(LAST_FULL_CYCLE, snap_counts=in_points).equals( + league_values(LAST_FULL_CYCLE) + ) + + +def test_a_player_without_snap_rows_takes_the_default_share( + league_values, league_snap_counts: pl.DataFrame +) -> None: + """A player absent from the snap counts is scored at half the offense's snaps.""" + without_receiver = league_snap_counts.filter( + ~((pl.col("team") == "NYG") & (pl.col("position") == "WR")) + ) + result = league_values(LAST_FULL_CYCLE, snap_counts=without_receiver) + assert _row(result, league_player_name("NYG", "WR"))["avg_snap_pct"] == 50.0 + + +def test_a_player_without_injury_reports_is_fully_healthy(league_values) -> None: + """A player carrying no injury report is scored at full health.""" + result = league_values(LAST_FULL_CYCLE) + assert _row(result, league_player_name("NYG", "QB"))["health"] == 1.0 + + +def test_three_games_played_is_the_fewest_that_scores( + league_values, league_weekly_stats: pl.DataFrame +) -> None: + """Three games earn a value and two do not.""" + played_twice = (pl.col("recent_team") == "NYG") & (pl.col("week") >= FIRST_SCORED_WEEK) + played_three_times = (pl.col("recent_team") == "DAL") & (pl.col("week") > FIRST_SCORED_WEEK) + thinned = league_weekly_stats.filter( + ~( + (pl.col("season") == SEASON) + & (pl.col("week") <= BEFORE_BYE) + & (pl.col("position") == "WR") + & (played_twice | played_three_times) + ) + ) + scored = thinned.filter( + (pl.col("season") == SEASON) & (pl.col("week") <= BEFORE_BYE) & (pl.col("position") == "WR") + ) + counted = scored.group_by("recent_team").len() + games = dict(zip(counted["recent_team"], counted["len"], strict=True)) + assert games["NYG"] == FIRST_SCORED_WEEK - 1 + assert games["DAL"] == FIRST_SCORED_WEEK + + result = league_values(BEFORE_BYE, weekly_stats=thinned) + receivers = result.filter(pl.col("position") == "WR")["player"].to_list() + assert league_player_name("NYG", "WR") not in receivers + assert _row(result, league_player_name("DAL", "WR"))["games_played"] == FIRST_SCORED_WEEK + + +def test_postseason_scoring_stays_out_of_the_player_baseline( + league_values, league_weekly_stats: pl.DataFrame +) -> None: + """A postseason line neither raises a player's average nor counts as a game.""" + postseason = league_weekly_stats.filter( + (pl.col("season") == SEASON) + & (pl.col("week") == BEFORE_BYE - 1) + & (pl.col("recent_team") == "NYG") + & (pl.col("position") == "WR") + ).with_columns( + pl.lit("POST").alias("season_type"), + pl.lit(POSTSEASON_POINTS).alias("fantasy_points_ppr"), + ) + + result = league_values(BEFORE_BYE, weekly_stats=pl.concat([league_weekly_stats, postseason])) + receiver = _row(result, league_player_name("NYG", "WR")) + assert receiver["ppg"] == _mean(_allowed("WR", _opponents("NYG"))) + assert receiver["games_played"] == BEFORE_BYE + + +def test_only_offensive_positions_are_scored( + league_values, league_weekly_stats: pl.DataFrame +) -> None: + """A player off the offensive skill positions earns no value.""" + kicker = league_weekly_stats.filter( + (pl.col("season") == SEASON) + & (pl.col("recent_team") == "NYG") + & (pl.col("position") == "WR") + ).with_columns( + pl.lit("K").alias("position"), + pl.lit("NYG Kicker").alias("player_display_name"), + pl.lit("00-0105").alias("player_id"), + ) + + result = league_values(BEFORE_BYE, weekly_stats=pl.concat([league_weekly_stats, kicker])) + assert set(result["position"].to_list()) == set(LEAGUE_POSITIONS) diff --git a/tests/test_ui_base.py b/tests/test_ui_base.py new file mode 100644 index 0000000..33af8ea --- /dev/null +++ b/tests/test_ui_base.py @@ -0,0 +1,852 @@ +"""Properties of the shared tool view: one load, one refresh, one filter pass. + +Every test here drives a real Textual app through the test pilot. The loaders are +stubbed with the league fixtures, so a view under test reaches its engines with data +and reaches the network with nothing. +""" + +from __future__ import annotations + +import asyncio +import functools +import threading +from collections.abc import Awaitable, Callable +from types import ModuleType +from typing import Any, NamedTuple + +import polars as pl +import pytest +from textual.app import App, ComposeResult +from textual.pilot import Pilot +from textual.widgets import Button, DataTable, Select, Static + +from ffb.injury_impact import screen as injury_impact_screen +from ffb.red_zone import screen as red_zone_screen +from ffb.snap_share import screen as snap_share_screen +from ffb.start_sit import screen as start_sit_screen +from ffb.trade_value import screen as trade_value_screen +from ffb.ui import base +from ffb.ui.base import ALL, POSITIONS, ToolView +from ffb.waiver_wire import screen as waiver_wire_screen +from tests.conftest import LEAGUE_POSITIONS, LEAGUE_SEASONS, LEAGUE_TEAMS, REGULAR_SEASON_WEEKS + +#: How long a probe waits for the test to open its gate before giving up, in seconds. +#: A test that forgets to open one fails on its assertions rather than hanging. +GATE_TIMEOUT = 5.0 + +#: How many times a settle dispatches the message queue and drains the workers. A load +#: answers on a worker, renders on the UI thread and posts filter messages from there, +#: so the queue has to be drained more than once for the view to come to rest. +SETTLE_PASSES = 3 + +#: The season the league fixtures end at, which every view's season filter defaults to. +LATEST_SEASON = max(LEAGUE_SEASONS) + +#: Starters the league fields in a week no team sits out. +LEAGUE_STARTERS = len(LEAGUE_TEAMS) * len(LEAGUE_POSITIONS) + +#: Stands in for the probe's table when a result carries no rows. +EMPTY_MESSAGE = "No rows for these filters." + + +# ── the six tools ──────────────────────────────────────────────────────────── + + +class Tool(NamedTuple): + """One tool: the view class, and the module its loader names are bound in.""" + + name: str + module: ModuleType + view: type[ToolView] + + +TOOLS = ( + Tool("snap-share", snap_share_screen, snap_share_screen.SnapShareView), + Tool("red-zone", red_zone_screen, red_zone_screen.RedZoneView), + Tool("injury-impact", injury_impact_screen, injury_impact_screen.InjuryImpactView), + Tool("waiver-wire", waiver_wire_screen, waiver_wire_screen.WaiverWireView), + Tool("start-sit", start_sit_screen, start_sit_screen.StartSitView), + Tool("trade-value", trade_value_screen, trade_value_screen.TradeValueView), +) + +TOOL_IDS = [tool.name for tool in TOOLS] + + +class LoaderCall(NamedTuple): + """One loader call a view made, and whether it asked for a fresh download.""" + + view: str + loader: str + force_refresh: bool + + +def loader_frames( + snaps: pl.DataFrame, + weekly: pl.DataFrame, + ids: pl.DataFrame, + schedules: pl.DataFrame, + injuries: pl.DataFrame, + pbp: pl.DataFrame, + rosters: pl.DataFrame, +) -> dict[str, pl.DataFrame]: + """Map each loader name to the frame a stub of it answers with.""" + return { + "load_snap_counts": snaps, + "load_weekly_stats": weekly, + "load_player_ids": ids, + "load_schedules": schedules, + "load_injuries": injuries, + "load_pbp": pbp, + "load_rosters": rosters, + } + + +def _recorder( + view: str, + loader: str, + frame: pl.DataFrame, + calls: list[LoaderCall], +) -> Callable[..., pl.DataFrame]: + """Build a stand-in for one loader that records its call and answers with `frame`.""" + + def load(*_seasons: Any, force_refresh: bool = False) -> pl.DataFrame: + calls.append(LoaderCall(view, loader, force_refresh)) + return frame + + return load + + +def stub_loaders( + monkeypatch: pytest.MonkeyPatch, + frames: dict[str, pl.DataFrame], + calls: list[LoaderCall], +) -> None: + """Point every screen module's loaders at `frames`, recording each call in `calls`. + + A loader is stubbed in the module that binds the name, so a view reaches the + recorder through the same reference its `fetch` calls. + """ + for tool in TOOLS: + for loader, frame in frames.items(): + if not hasattr(tool.module, loader): + continue + monkeypatch.setattr(tool.module, loader, _recorder(tool.name, loader, frame, calls)) + + +@pytest.fixture() +def loaders( + monkeypatch: pytest.MonkeyPatch, + league_snap_counts: pl.DataFrame, + league_weekly_stats: pl.DataFrame, + player_ids: pl.DataFrame, + full_season_schedules: pl.DataFrame, + injuries: pl.DataFrame, + pbp: pl.DataFrame, + rosters: pl.DataFrame, +) -> list[LoaderCall]: + """Every loader call the views make, in order, against the league fixtures.""" + calls: list[LoaderCall] = [] + stub_loaders( + monkeypatch, + loader_frames( + league_snap_counts, + league_weekly_stats, + player_ids, + full_season_schedules, + injuries, + pbp, + rosters, + ), + calls, + ) + return calls + + +# ── harness ────────────────────────────────────────────────────────────────── + + +def piloted(test: Callable[..., Awaitable[None]]) -> Callable[..., None]: + """Adapt an async test body to pytest, which collects synchronous functions. + + Textual drives a view from an asyncio loop, so every test that mounts one is a + coroutine and owns the loop it runs on. + """ + + @functools.wraps(test) + def run(*args: Any, **kwargs: Any) -> None: + asyncio.run(test(*args, **kwargs)) + + return run + + +class Host(App[None]): + """An app whose only widget is the view under test.""" + + def __init__(self, tool: ToolView) -> None: + super().__init__() + self.tool = tool + + def compose(self) -> ComposeResult: + yield self.tool + + +async def settle(pilot: Pilot[None]) -> None: + """Run the view to rest: every worker finished, every message it posted dispatched.""" + for _ in range(SETTLE_PASSES): + await pilot.pause() + await pilot.app.workers.wait_for_complete() + await pilot.pause() + + +async def reached(event: threading.Event) -> None: + """Wait, off the UI thread, until the probe's fetch reaches `event`.""" + assert await asyncio.to_thread(event.wait, GATE_TIMEOUT), "the probe's fetch stalled" + + +def spy(monkeypatch: pytest.MonkeyPatch, module: ModuleType, name: str) -> list[tuple]: + """Record the arguments of every call to `module.name`, leaving its result alone.""" + calls: list[tuple] = [] + original = getattr(module, name) + + def record(*args: Any, **kwargs: Any) -> Any: + calls.append((args, kwargs)) + return original(*args, **kwargs) + + monkeypatch.setattr(module, name, record) + return calls + + +class Clock: + """A monotonic clock the test advances by hand.""" + + def __init__(self) -> None: + self.seconds = 0.0 + + def __call__(self) -> float: + return self.seconds + + +class ProbeView(ToolView): + """A tool view whose fetch, payload and failure the test owns. + + `gate` holds the fetch on its worker thread while it is clear, so a test can read + the view mid-load; `entered` and `returned` mark the fetch reaching and leaving + that gate. + """ + + ID_PREFIX = "pb" + LOAD_LABEL = "Downloading probe data" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.fetched: list[bool] = [] + self.applied: list[pl.DataFrame] = [] + self.filter_passes: list[str | None] = [] + self.payload = pl.DataFrame({"player": ["Alpha"]}) + self.failure: Exception | None = None + self.gate = threading.Event() + self.gate.set() + self.entered = threading.Event() + self.returned = threading.Event() + + def compose_content(self) -> ComposeResult: + yield from self.compose_filter_bar( + self.position_select(), + Select[int]([("Zero", 0), ("One", 1)], id="pb-filter-count"), + ) + yield DataTable(id="pb-table") + + def on_mount(self) -> None: + self.query_one("#pb-table", DataTable).add_columns("Player") + + def fetch(self, force_refresh: bool) -> pl.DataFrame: + self.fetched.append(force_refresh) + self.entered.set() + self.gate.wait(GATE_TIMEOUT) + try: + if self.failure is not None: + raise self.failure + return self.payload + finally: + self.returned.set() + + def apply(self, payload: pl.DataFrame) -> None: + self.applied.append(payload) + self.fill_table("pb-table", payload, lambda row: (row["player"],), EMPTY_MESSAGE) + + def filter_changed(self, filter_id: str | None) -> None: + self.filter_passes.append(filter_id) + + +# ── the refresh contract ───────────────────────────────────────────────────── + + +@pytest.mark.parametrize("tool", TOOLS, ids=TOOL_IDS) +@piloted +async def test_refresh_reaches_every_loader_with_force_refresh( + tool: Tool, loaders: list[LoaderCall] +) -> None: + """Refresh promises a fresh download, so every loader the view calls is forced.""" + view = tool.view() + async with Host(view).run_test() as pilot: + view.activate() + await settle(pilot) + first = [call.loader for call in loaders] + assert first, f"{tool.name} reached no loader on its first load" + + loaders.clear() + view.query_one(f"#{view.ID_PREFIX}-btn-refresh", Button).press() + await settle(pilot) + + assert loaders == [LoaderCall(tool.name, loader, True) for loader in first] + + +@pytest.mark.parametrize("tool", TOOLS, ids=TOOL_IDS) +@piloted +async def test_first_activation_reads_the_cache(tool: Tool, loaders: list[LoaderCall]) -> None: + """Showing a tool asks for what is cached; only Refresh asks for a download.""" + view = tool.view() + async with Host(view).run_test() as pilot: + view.activate() + await settle(pilot) + + assert loaders, f"{tool.name} reached no loader on its first load" + assert [call.force_refresh for call in loaders] == [False] * len(loaders) + + +@piloted +async def test_a_second_activation_does_not_reload() -> None: + """A tool shown again renders what it holds rather than downloading it again.""" + view = ProbeView() + async with Host(view).run_test() as pilot: + view.activate() + await settle(pilot) + view.activate() + await settle(pilot) + + assert view.fetched == [False] + + +@piloted +async def test_refresh_reloads_a_view_that_already_holds_data() -> None: + """The refresh button loads again whatever the view already rendered.""" + view = ProbeView() + async with Host(view).run_test() as pilot: + view.activate() + await settle(pilot) + view.query_one("#pb-btn-refresh", Button).press() + await settle(pilot) + + assert view.fetched == [False, True] + + +# ── load lifecycle ─────────────────────────────────────────────────────────── + + +@piloted +async def test_the_loading_pane_stands_in_for_the_content_during_a_load() -> None: + view = ProbeView() + async with Host(view).run_test() as pilot: + view.gate.clear() + try: + view.activate() + await pilot.pause() + await reached(view.entered) + panes = ( + view.query_one("#pb-loading").display, + view.query_one("#pb-content").display, + ) + finally: + view.gate.set() + await settle(pilot) + + assert panes == (True, False) + + +@piloted +async def test_a_finished_load_shows_the_content() -> None: + view = ProbeView() + async with Host(view).run_test() as pilot: + view.activate() + await settle(pilot) + + assert ( + view.query_one("#pb-loading").display, + view.query_one("#pb-content").display, + ) == (False, True) + + +@piloted +async def test_a_failing_fetch_reports_through_the_shared_error_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + reported: list[str] = [] + monkeypatch.setattr(ToolView, "report_error", lambda _self, message: reported.append(message)) + view = ProbeView() + view.failure = RuntimeError("nflverse is unreachable") + async with Host(view).run_test() as pilot: + view.activate() + await settle(pilot) + + assert reported == ["Failed to load data: nflverse is unreachable"] + + +@piloted +async def test_a_failing_fetch_renders_nothing() -> None: + view = ProbeView() + view.failure = RuntimeError("nflverse is unreachable") + async with Host(view).run_test() as pilot: + view.activate() + await settle(pilot) + + assert view.applied == [] + + +@piloted +async def test_a_failing_fetch_hides_the_loading_pane() -> None: + """A failure returns the tool to its content, so a retry starts from the view.""" + view = ProbeView() + view.failure = RuntimeError("nflverse is unreachable") + async with Host(view).run_test() as pilot: + view.activate() + await settle(pilot) + + assert ( + view.query_one("#pb-loading").display, + view.query_one("#pb-content").display, + ) == (False, True) + + +@piloted +async def test_a_cancelled_load_never_renders_its_result() -> None: + view = ProbeView() + async with Host(view).run_test() as pilot: + view.gate.clear() + try: + view.activate() + await pilot.pause() + await reached(view.entered) + view.query_one("#pb-btn-cancel", Button).press() + await pilot.pause() + finally: + view.gate.set() + await reached(view.returned) + await settle(pilot) + + assert view.applied == [] + + +@piloted +async def test_a_cancelled_load_shows_the_content_again() -> None: + view = ProbeView() + async with Host(view).run_test() as pilot: + view.gate.clear() + try: + view.activate() + await pilot.pause() + await reached(view.entered) + view.query_one("#pb-btn-cancel", Button).press() + await pilot.pause() + finally: + view.gate.set() + await reached(view.returned) + await settle(pilot) + + assert ( + view.query_one("#pb-loading").display, + view.query_one("#pb-content").display, + ) == (False, True) + + +@piloted +async def test_the_cancel_binding_is_offered_while_a_load_runs() -> None: + view = ProbeView() + async with Host(view).run_test() as pilot: + view.gate.clear() + try: + view.activate() + await pilot.pause() + await reached(view.entered) + offered = view.check_action("cancel_load", ()) + finally: + view.gate.set() + await settle(pilot) + + assert offered is True + + +@piloted +async def test_the_cancel_binding_is_withheld_once_the_load_finishes() -> None: + view = ProbeView() + async with Host(view).run_test() as pilot: + view.activate() + await settle(pilot) + + assert view.check_action("cancel_load", ()) is False + + +# ── the elapsed readout ────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("elapsed", "readout"), + [ + (0.0, "00:00"), + (0.9, "00:00"), + (1.0, "00:01"), + (59.0, "00:59"), + (60.0, "01:00"), + (65.0, "01:05"), + (3600.0, "60:00"), + ], +) +@piloted +async def test_the_elapsed_readout_counts_the_time_since_the_load_started( + monkeypatch: pytest.MonkeyPatch, elapsed: float, readout: str +) -> None: + clock = Clock() + monkeypatch.setattr(base, "monotonic", clock) + view = ProbeView() + async with Host(view).run_test() as pilot: + view.gate.clear() + try: + view.activate() + await pilot.pause() + await reached(view.entered) + clock.seconds = elapsed + view._render_elapsed() + status = str(view.query_one("#pb-load-status", Static).content) + finally: + view.gate.set() + await settle(pilot) + + assert status == f"{ProbeView.LOAD_LABEL} — {readout}" + + +@piloted +async def test_the_elapsed_readout_carries_no_percentage( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The download reports no progress, so the readout claims none.""" + clock = Clock() + monkeypatch.setattr(base, "monotonic", clock) + view = ProbeView() + async with Host(view).run_test() as pilot: + view.gate.clear() + try: + view.activate() + await pilot.pause() + await reached(view.entered) + clock.seconds = 42.0 + view._render_elapsed() + status = str(view.query_one("#pb-load-status", Static).content) + finally: + view.gate.set() + await settle(pilot) + + assert "%" not in status + + +@piloted +async def test_the_elapsed_timer_runs_only_while_a_load_is_in_flight() -> None: + view = ProbeView() + async with Host(view).run_test() as pilot: + view.gate.clear() + try: + view.activate() + await pilot.pause() + await reached(view.entered) + during = view._elapsed_timer + finally: + view.gate.set() + await settle(pilot) + + assert during is not None + assert view._elapsed_timer is None + + +# ── filter reads ───────────────────────────────────────────────────────────── + + +@piloted +async def test_an_unselected_filter_reads_as_none() -> None: + """`Select.NULL` is the unselected sentinel, and the reader collapses it to None. + + `Select.BLANK` is the inherited `Widget.BLANK`, the bool False, so it equals no + filter value and matches no sentinel. + """ + view = ProbeView() + async with Host(view).run_test() as pilot: + await pilot.pause() + + assert view.query_one("#pb-filter-count", Select).value is Select.NULL + assert view.selected("pb-filter-count") is None + + +@piloted +async def test_a_filter_reads_back_the_value_it_holds() -> None: + view = ProbeView() + async with Host(view).run_test() as pilot: + await pilot.pause() + view.query_one("#pb-filter-count", Select).value = 1 + + assert view.selected("pb-filter-count") == 1 + + +@piloted +async def test_a_filter_holding_zero_reads_as_zero() -> None: + """Zero is a value a filter can hold, and only the sentinel reads as no selection.""" + view = ProbeView() + async with Host(view).run_test() as pilot: + await pilot.pause() + view.query_one("#pb-filter-count", Select).value = 0 + + assert view.selected("pb-filter-count") == 0 + + +@piloted +async def test_narrowing_reads_the_all_option_as_narrowing_nothing() -> None: + view = ProbeView() + async with Host(view).run_test() as pilot: + await pilot.pause() + + assert view.selected("pb-filter-position") == ALL + assert view.narrowing("pb-filter-position") is None + + +@piloted +async def test_narrowing_reads_an_unselected_filter_as_narrowing_nothing() -> None: + view = ProbeView() + async with Host(view).run_test() as pilot: + await pilot.pause() + + assert view.narrowing("pb-filter-count") is None + + +@piloted +async def test_narrowing_reads_back_the_value_a_filter_narrows_to() -> None: + view = ProbeView() + async with Host(view).run_test() as pilot: + await pilot.pause() + view.query_one("#pb-filter-position", Select).value = "QB" + + assert view.narrowing("pb-filter-position") == "QB" + + +@piloted +async def test_all_options_offers_all_ahead_of_the_values() -> None: + view = ProbeView() + async with Host(view).run_test() as pilot: + await pilot.pause() + + assert view.all_options(("QB", "RB")) == [("All", "All"), ("QB", "QB"), ("RB", "RB")] + + +@pytest.mark.parametrize("position", POSITIONS) +@piloted +async def test_the_position_filter_offers_every_fantasy_position(position: str) -> None: + """A `Select` refuses a value its options do not carry, so holding one proves it.""" + view = ProbeView() + async with Host(view).run_test() as pilot: + await pilot.pause() + view.query_one("#pb-filter-position", Select).value = position + + assert view.selected("pb-filter-position") == position + + +# ── filter passes ──────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("tool", TOOLS, ids=TOOL_IDS) +@piloted +async def test_a_load_dispatches_no_filter_pass( + tool: Tool, loaders: list[LoaderCall], monkeypatch: pytest.MonkeyPatch +) -> None: + """A load renders once, from `apply`; the filter messages it posts repeat nothing.""" + passes: list[str | None] = [] + original = tool.view.filter_changed + + def counted(self: ToolView, filter_id: str | None) -> None: + passes.append(filter_id) + original(self, filter_id) + + monkeypatch.setattr(tool.view, "filter_changed", counted) + + view = tool.view() + async with Host(view).run_test() as pilot: + view.activate() + await settle(pilot) + + assert passes == [] + + +@piloted +async def test_a_user_filter_change_dispatches_one_filter_pass() -> None: + view = ProbeView() + async with Host(view).run_test() as pilot: + view.activate() + await settle(pilot) + view.filter_passes.clear() + view.query_one("#pb-filter-position", Select).value = "QB" + await settle(pilot) + + assert view.filter_passes == ["pb-filter-position"] + + +@piloted +async def test_a_filter_set_back_to_the_rendered_value_dispatches_no_pass() -> None: + """The render behind the view already answers those values.""" + view = ProbeView() + async with Host(view).run_test() as pilot: + view.activate() + await settle(pilot) + view.filter_passes.clear() + view.query_one("#pb-filter-position", Select).value = ALL + await settle(pilot) + + assert view.filter_passes == [] + + +@piloted +async def test_a_start_sit_load_runs_the_projection_once( + loaders: list[LoaderCall], monkeypatch: pytest.MonkeyPatch +) -> None: + """One engine run per load, for the latest week of the latest season loaded.""" + runs = spy(monkeypatch, start_sit_screen, "compute_start_sit") + view = start_sit_screen.StartSitView() + async with Host(view).run_test() as pilot: + view.activate() + await settle(pilot) + + assert [args[2:] for args, _ in runs] == [(LATEST_SEASON, REGULAR_SEASON_WEEKS)] + + +@piloted +async def test_a_trade_value_load_runs_the_ranking_once( + loaders: list[LoaderCall], monkeypatch: pytest.MonkeyPatch +) -> None: + """One engine run per load, for the last week the season still has a game after.""" + runs = spy(monkeypatch, trade_value_screen, "compute_trade_values") + view = trade_value_screen.TradeValueView() + async with Host(view).run_test() as pilot: + view.activate() + await settle(pilot) + + assert [(kwargs["season"], kwargs["current_week"]) for _, kwargs in runs] == [ + (LATEST_SEASON, REGULAR_SEASON_WEEKS - 1) + ] + + +@piloted +async def test_a_position_filter_narrows_the_rendered_rows( + loaders: list[LoaderCall], +) -> None: + """Each team fields one starter at a position, so a narrowed table holds one per team.""" + view = snap_share_screen.SnapShareView() + async with Host(view).run_test() as pilot: + view.activate() + await settle(pilot) + assert view.query_one("#sn-all-table", DataTable).row_count == LEAGUE_STARTERS + + view.query_one("#sn-filter-position", Select).value = "QB" + await settle(pilot) + + assert view.query_one("#sn-all-table", DataTable).row_count == len(LEAGUE_TEAMS) + + +# ── tables ─────────────────────────────────────────────────────────────────── + + +@piloted +async def test_an_empty_result_renders_the_empty_message() -> None: + view = ProbeView() + view.payload = pl.DataFrame({"player": []}) + async with Host(view).run_test() as pilot: + view.activate() + await settle(pilot) + label = view.query_one("#pb-table-empty", Static) + + assert (str(label.content), label.display) == (EMPTY_MESSAGE, True) + + +@piloted +async def test_an_empty_result_hides_the_table() -> None: + """A header with no rows under it reads as a result; the message says there is none.""" + view = ProbeView() + view.payload = pl.DataFrame({"player": []}) + async with Host(view).run_test() as pilot: + view.activate() + await settle(pilot) + + assert view.query_one("#pb-table", DataTable).display is False + + +@piloted +async def test_a_result_carrying_no_columns_renders_the_empty_message() -> None: + """Emptiness is read from the frame's shape, which a column-less frame still has.""" + view = ProbeView() + view.payload = pl.DataFrame() + async with Host(view).run_test() as pilot: + view.activate() + await settle(pilot) + label = view.query_one("#pb-table-empty", Static) + + assert (str(label.content), label.display) == (EMPTY_MESSAGE, True) + + +@piloted +async def test_a_single_row_result_hides_the_empty_message() -> None: + view = ProbeView() + view.payload = pl.DataFrame({"player": ["Alpha"]}) + async with Host(view).run_test() as pilot: + view.activate() + await settle(pilot) + + assert view.query_one("#pb-table-empty", Static).display is False + assert view.query_one("#pb-table", DataTable).display is True + + +@piloted +async def test_every_row_of_a_result_reaches_the_table_in_order() -> None: + view = ProbeView() + view.payload = pl.DataFrame({"player": ["Alpha", "Bravo", "Charlie"]}) + async with Host(view).run_test() as pilot: + view.activate() + await settle(pilot) + table = view.query_one("#pb-table", DataTable) + + assert [table.get_row_at(index) for index in range(table.row_count)] == [ + ["Alpha"], + ["Bravo"], + ["Charlie"], + ] + + +@piloted +async def test_a_refilled_table_carries_only_the_latest_result() -> None: + view = ProbeView() + view.payload = pl.DataFrame({"player": ["Alpha", "Bravo", "Charlie"]}) + async with Host(view).run_test() as pilot: + view.activate() + await settle(pilot) + view.payload = pl.DataFrame({"player": ["Delta"]}) + view.query_one("#pb-btn-refresh", Button).press() + await settle(pilot) + table = view.query_one("#pb-table", DataTable) + + assert [table.get_row_at(index) for index in range(table.row_count)] == [["Delta"]] + + +@pytest.mark.parametrize("tool", TOOLS, ids=TOOL_IDS) +@piloted +async def test_every_table_is_paired_with_an_empty_state_label( + tool: Tool, loaders: list[LoaderCall] +) -> None: + """`fill_table` reaches a label at `{table_id}-empty` for every table a tool renders.""" + view = tool.view() + async with Host(view).run_test() as pilot: + await pilot.pause() + tables = [table.id for table in view.query(DataTable)] + + assert tables, f"{tool.name} renders no table" + assert [tid for tid in tables if not view.query(f"#{tid}-empty")] == [] diff --git a/tests/test_waiver_wire.py b/tests/test_waiver_wire.py index 2289cfd..20b7ffd 100644 --- a/tests/test_waiver_wire.py +++ b/tests/test_waiver_wire.py @@ -1,6 +1,14 @@ """Tests for waiver wire usage trend engine.""" +import polars as pl +import pytest + from ffb.waiver_wire.engine import compute_usage_trends +from tests.conftest import ( + COLLIDING_FALLING_GSIS_ID, + COLLIDING_NAME, + COLLIDING_RISING_GSIS_ID, +) def test_compute_usage_trends_basic(snap_counts, weekly_stats, player_ids): @@ -21,3 +29,118 @@ def test_usage_score_components(snap_counts, weekly_stats, player_ids): assert trends["snap_pct"].min() >= 0 assert trends["tgt_share"].min() >= 0 assert trends["touch_share"].min() >= 0 + + +def _by_week(trends: pl.DataFrame, gsis_id: str, column: str) -> dict: + """Value of `column` in every trend row `gsis_id` holds, keyed by week.""" + rows = trends.filter(pl.col("gsis_id") == gsis_id).sort("week") + return dict(zip(rows["week"].to_list(), rows[column].to_list(), strict=True)) + + +def _colliding_trends(snaps, stats, ids): + return compute_usage_trends(snaps, stats, ids, window=3) + + +def test_colliding_names_hold_separate_usage_scores( + colliding_name_snaps, colliding_name_weekly_stats, player_ids +): + """A usage score weighs one player's own snap share, targets and touches. + + Each is the only player on their team, so the rising receiver holds a 100 target + share and a 100 touch share and the falling back holds 0 and 100. Their scores are + therefore ``snap_pct * 0.40 + 60`` and ``snap_pct * 0.40 + 25`` against snap shares + that climb and fall by ten points a week. + """ + trends = _colliding_trends(colliding_name_snaps, colliding_name_weekly_stats, player_ids) + assert _by_week(trends, COLLIDING_RISING_GSIS_ID, "usage_score") == pytest.approx( + {3: 80.0, 4: 84.0, 5: 88.0, 6: 92.0} + ) + assert _by_week(trends, COLLIDING_FALLING_GSIS_ID, "usage_score") == pytest.approx( + {3: 49.0, 4: 45.0, 5: 41.0, 6: 37.0} + ) + + +def test_colliding_names_hold_separate_rolling_averages( + colliding_name_snaps, colliding_name_weekly_stats, player_ids +): + """A rolling average covers one player's weeks, not every player wearing their name. + + The rising scores read 72, 76, 80, 84, 88, 92, so the average of the preceding + weeks is 74 in week 3 and 76 in week 4. The falling scores read 57, 53, 49, 45, + 41, 37, giving 55 and 53. + """ + trends = _colliding_trends(colliding_name_snaps, colliding_name_weekly_stats, player_ids) + assert _by_week(trends, COLLIDING_RISING_GSIS_ID, "rolling_avg") == pytest.approx( + {3: 74.0, 4: 76.0, 5: 80.0, 6: 84.0} + ) + assert _by_week(trends, COLLIDING_FALLING_GSIS_ID, "rolling_avg") == pytest.approx( + {3: 55.0, 4: 53.0, 5: 49.0, 6: 45.0} + ) + + +def test_colliding_names_hold_separate_deltas( + colliding_name_snaps, colliding_name_weekly_stats, player_ids +): + """A delta measures a player's score against their own rolling average. + + The rising player is 6 points over their week-3 average of 74 and 8 points over + every later one; the falling player is under theirs by the same margins. + """ + trends = _colliding_trends(colliding_name_snaps, colliding_name_weekly_stats, player_ids) + assert _by_week(trends, COLLIDING_RISING_GSIS_ID, "delta") == pytest.approx( + {3: 6.0, 4: 8.0, 5: 8.0, 6: 8.0} + ) + assert _by_week(trends, COLLIDING_FALLING_GSIS_ID, "delta") == pytest.approx( + {3: -6.0, 4: -8.0, 5: -8.0, 6: -8.0} + ) + + +def test_colliding_names_hold_separate_velocities( + colliding_name_snaps, colliding_name_weekly_stats, player_ids +): + """A velocity is the slope of one player's own scores across the window. + + Each score moves four points a week, so the two-week slope is +4 for the rising + player and -4 for the falling one in every week the window covers. + """ + trends = _colliding_trends(colliding_name_snaps, colliding_name_weekly_stats, player_ids) + assert _by_week(trends, COLLIDING_RISING_GSIS_ID, "velocity") == pytest.approx( + dict.fromkeys(range(3, 7), 4.0) + ) + assert _by_week(trends, COLLIDING_FALLING_GSIS_ID, "velocity") == pytest.approx( + dict.fromkeys(range(3, 7), -4.0) + ) + + +def test_colliding_names_classify_in_opposite_directions( + colliding_name_snaps, colliding_name_weekly_stats, player_ids +): + """Each player wearing one name lands on the trend their own usage describes.""" + trends = _colliding_trends(colliding_name_snaps, colliding_name_weekly_stats, player_ids) + assert _by_week(trends, COLLIDING_RISING_GSIS_ID, "trend") == dict.fromkeys( + range(3, 7), "rising" + ) + assert _by_week(trends, COLLIDING_FALLING_GSIS_ID, "trend") == dict.fromkeys( + range(3, 7), "falling" + ) + + +def test_a_shared_display_name_yields_one_series_per_identity( + colliding_name_snaps, colliding_name_weekly_stats, player_ids +): + """Two players wearing one name produce two windows, not one spliced series.""" + trends = _colliding_trends(colliding_name_snaps, colliding_name_weekly_stats, player_ids) + assert trends["player"].unique().to_list() == [COLLIDING_NAME] + assert sorted(trends["gsis_id"].unique().to_list()) == sorted( + [COLLIDING_RISING_GSIS_ID, COLLIDING_FALLING_GSIS_ID] + ) + + +def test_a_zero_target_team_gives_its_players_no_target_share( + colliding_name_snaps, colliding_name_weekly_stats, player_ids +): + """A share over a zero team total reads zero rather than dividing by nothing.""" + trends = _colliding_trends(colliding_name_snaps, colliding_name_weekly_stats, player_ids) + assert _by_week(trends, COLLIDING_FALLING_GSIS_ID, "tgt_share") == pytest.approx( + dict.fromkeys(range(3, 7), 0.0) + ) diff --git a/uv.lock b/uv.lock index ad8a64c..f2acec4 100644 --- a/uv.lock +++ b/uv.lock @@ -31,6 +31,105 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, + { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, + { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" }, + { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" }, + { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" }, + { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" }, + { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" }, + { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" }, + { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, +] + [[package]] name = "cramjam" version = "2.11.0" @@ -165,6 +264,7 @@ dependencies = [ dev = [ { name = "mypy" }, { name = "pytest" }, + { name = "pytest-cov" }, { name = "ruff" }, ] @@ -180,6 +280,7 @@ requires-dist = [ dev = [ { name = "mypy", specifier = ">=1.13" }, { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "ruff", specifier = ">=0.8" }, ] @@ -635,6 +736,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"