import os
import logging
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
from matplotlib.patches import FancyBboxPatch, Rectangle
from gridiron import Play, Player, offense, routes as r, style
from gridiron.data import nfl, pbp
logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
style.apply()
TE_RING = style.LINE_TO_GAIN # gold rings mark tight ends and fullbacks, as in Personnel Groupings
WATCH = style.THIRD # green ring: the player to watch
GREY = style.MUTED
# ------------------------------------------------------------------ drawing helpers
def O(key, pos, d, w, label=None):
return Player(key, pos, "off", d, w, label=label)
def D(key, pos, d, w, label=None):
return Player(key, pos, "def", d, w, label=label)
def line5():
return [p for p in offense("singleback") if p.key in ("LT", "LG", "C", "RG", "RT")]
def no_numbers(ax):
"""Remove the painted yard-line numbers (they sit where wide receivers and corners stand)."""
for t in list(ax.texts):
if t.get_alpha() == 0.55:
t.remove()
def say(fld, play, text, at, fs=7.0, color=None, ha="center", weight="normal", ec="none"):
"""On-field text at (d, w) on a light patch so lines underneath don't cut it."""
x, y = fld.xy(*play.to_abs(*at))
fld.ax.text(x, y, text, ha=ha, va="center", fontsize=fs, color=color or style.INK, zorder=12,
fontweight=weight, bbox=dict(boxstyle="round,pad=0.25", fc=style.SURFACE, ec=ec, lw=0.7, alpha=0.95))
def pointer(fld, play, text, text_at, to, ha="center", fs=6.8, color=None):
"""A labelled arrow from a text box to a spot on the field (both in (d, w) yards)."""
tx, ty = fld.xy(*play.to_abs(*text_at))
px, py = fld.xy(*play.to_abs(*to))
fld.ax.annotate(text, xy=(px, py), xytext=(tx, ty), ha=ha, va="center", fontsize=fs, color=color or style.INK,
zorder=12, arrowprops=dict(arrowstyle="-|>", color=style.INK_2, lw=0.8, shrinkB=7),
bbox=dict(boxstyle="round,pad=0.25", fc=style.SURFACE, ec=style.BASELINE, lw=0.6))
# ------------------------------------------------------------------ data (nflverse, regular seasons)
NFL = nfl()
PBP_COLS = ["season", "season_type", "week", "game_id", "play_id", "posteam", "play_type", "down",
"qb_dropback", "qb_kneel", "pass", "xpass", "pass_oe", "wp", "qtr", "half_seconds_remaining",
"score_differential", "yardline_100", "receiver_player_id", "rusher_player_id", "air_yards",
"receiving_yards", "complete_pass", "touchdown", "pass_touchdown", "rush_touchdown"]
PB = pbp([2024, 2025, 2026], columns=PBP_COLS)
# completed 2024 and 2025 regular seasons, plus 2026 Weeks 1-4 (fixed, so the numbers don't drift as the season goes on)
PB = PB[(PB.season_type == "REG") & ((PB.season < 2026) | (PB.week <= 4))]
PLAYS = PB[PB.play_type.isin(["pass", "run"]) & PB.down.notna()].copy() # scrimmage plays; no two-point tries
PEOPLE = NFL.load_players().to_pandas()[["gsis_id", "display_name", "position", "pfr_id"]]
POS = PEOPLE.set_index("gsis_id").position
NAME = PEOPLE.set_index("gsis_id").display_name
FTN = (NFL.load_ftn_charting([2024, 2025, 2026]).to_pandas()
.rename(columns={"nflverse_game_id": "game_id", "nflverse_play_id": "play_id"}))
PLAYS = PLAYS.merge(FTN[["game_id", "play_id", "is_motion", "is_play_action", "qb_location"]],
on=["game_id", "play_id"], how="left")
def neutral(df):
"""The neutral-situation filter for PROE: downs 1-3, win probability 20-80%, not the last 2 minutes of a half."""
return df[(df.down <= 3) & df.wp.between(0.2, 0.8) & (df.half_seconds_remaining > 120) & df.xpass.notna()]
def team_table(df):
g = df.groupby(["season", "posteam"])
games = g.game_id.nunique()
tg = df[(df.play_type == "pass") & df.receiver_player_id.notna()].copy()
tg["pos"] = tg.receiver_player_id.map(POS)
tgg = tg.groupby(["season", "posteam"])
return pd.DataFrame({
"games": games,
"plays_pg": g.size() / games,
"proe": neutral(df).groupby(["season", "posteam"]).pass_oe.mean(),
"proe_se": neutral(df).groupby(["season", "posteam"]).pass_oe.std()
/ np.sqrt(neutral(df).groupby(["season", "posteam"]).size()),
"n_plays": g.size(),
"targets_pg": tgg.size() / games,
"motion": g.is_motion.mean() * 100,
"play_action": df[df.play_type == "pass"].groupby(["season", "posteam"]).is_play_action.mean() * 100,
"under_center": g.qb_location.apply(lambda s: (s.dropna() == "U").mean()) * 100,
"rb_tgt": tgg.pos.apply(lambda s: (s == "RB").mean()) * 100,
"te_tgt": tgg.pos.apply(lambda s: (s == "TE").mean()) * 100,
"n_targets": tgg.size(),
"n_pass": df[df.play_type == "pass"].groupby(["season", "posteam"]).size(),
})
TEAMS = team_table(PLAYS)
def receiver_usage(season):
"""Per player and team: dropbacks on the field (participation data), targets, air yards, yards, and the shares.
Route participation is ESTIMATED as the share of the team's dropbacks the player was on the field for:
close to true routes for wide receivers, an overstatement for tight ends and backs who sometimes block."""
part = NFL.load_participation([season]).to_pandas()
part = part.rename(columns={"nflverse_game_id": "game_id"})[["game_id", "play_id", "offense_players"]]
s = PLAYS[PLAYS.season == season]
db = s[s.qb_dropback == 1].merge(part, on=["game_id", "play_id"], how="inner")
db = db[db.offense_players.notna()]
on = db.assign(pid=db.offense_players.str.split(";")).explode("pid")[["pid", "posteam", "game_id", "play_id"]]
games = on[["pid", "posteam", "game_id"]].drop_duplicates()
team_db = db.groupby(["posteam", "game_id"]).size().rename("team_db").reset_index()
tg = s[(s.play_type == "pass") & s.receiver_player_id.notna()]
team_tg = (tg.groupby(["posteam", "game_id"]).agg(team_tgt=("play_id", "size"), team_ay=("air_yards", "sum"))
.reset_index())
per_game = games.merge(team_db, on=["posteam", "game_id"]).merge(team_tg, on=["posteam", "game_id"], how="left")
u = per_game.groupby(["pid", "posteam"]).agg(games=("game_id", "nunique"), team_db=("team_db", "sum"),
team_tgt=("team_tgt", "sum"), team_ay=("team_ay", "sum"))
u["db_on"] = on.groupby(["pid", "posteam"]).size()
me = tg.groupby(["receiver_player_id", "posteam"]).agg(tgt=("play_id", "size"), ay=("air_yards", "sum"),
yds=("receiving_yards", "sum"), rec=("complete_pass", "sum"),
td=("pass_touchdown", "sum"))
me.index.names = ["pid", "posteam"]
u = u.join(me, how="left").fillna({"tgt": 0, "ay": 0, "yds": 0, "rec": 0, "td": 0}).reset_index()
u["pos"], u["name"] = u.pid.map(POS), u.pid.map(NAME)
u["route_part"] = u.db_on / u.team_db
u["tgt_share"] = u.tgt / u.team_tgt
u["ay_share"] = u.ay / u.team_ay
u["wopr"] = 1.5 * u.tgt_share + 0.7 * u.ay_share
u["yprr"] = u.yds / u.db_on
return u
U24, U25 = receiver_usage(2024), receiver_usage(2025)