{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "0",
   "metadata": {},
   "source": [
    "# NBA\n",
    "\n",
    "This page is regenerated every week by sdvplot's docs workflow. It builds the standings with offensive, defensive and net ratings, charts net rating with logos and ranks\n",
    "the scoring leaders with their headshots, for the latest NBA season with games: the season to date from October to\n",
    "April, the final regular season once it is over. Data: hoopR's ESPN box scores, read from release files through\n",
    "[sportsdataverse-py](https://py.sportsdataverse.org/) (no stats.nba.com calls)."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1",
   "metadata": {},
   "source": [
    "NBA seasons are named by the year they end (2025-26 is `2026`) and tip off in October, so until then the calendar points at the season that ended in June. For a season that is not published yet `load_nba_team_boxscore` warns and returns an empty frame rather\n",
    "than raising, so the helper below turns \"no regular-season games\" into a `NoDataError` and the page steps back one\n",
    "season."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2",
   "metadata": {},
   "outputs": [],
   "source": [
    "import datetime as dt\n",
    "import warnings\n",
    "\n",
    "import matplotlib.pyplot as plt\n",
    "import polars as pl\n",
    "import sportsdataverse.nba as nba\n",
    "from IPython.display import Markdown, display\n",
    "from sportsdataverse.errors import NoDataError\n",
    "\n",
    "import sdvplot\n",
    "\n",
    "today = dt.date.today()\n",
    "current = today.year + 1 if today.month >= 10 else today.year\n",
    "SOURCE = \"Data: hoopR (ESPN) via sportsdataverse-py\"\n",
    "\n",
    "\n",
    "def label(season):\n",
    "    return f\"{season - 1}-{season % 100:02d}\"\n",
    "\n",
    "\n",
    "def team_games(season):\n",
    "    with warnings.catch_warnings():\n",
    "        warnings.simplefilter(\"ignore\")  # \"no data for season(s)\": handled just below\n",
    "        box = nba.load_nba_team_boxscore(seasons=[season])\n",
    "    if box.is_empty() or box.filter(pl.col(\"season_type\") == 2).is_empty():\n",
    "        raise NoDataError(f\"no {label(season)} regular-season games yet\")\n",
    "    return box\n",
    "\n",
    "\n",
    "try:\n",
    "    season, box = current, team_games(current)\n",
    "except NoDataError as err:\n",
    "    print(f\"{err}; showing {label(current - 1)} instead\")\n",
    "    season, box = current - 1, team_games(current - 1)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3",
   "metadata": {},
   "source": [
    "The status line is written when the page runs: the season to date, a finished regular season with the playoffs under\n",
    "way, or the offseason."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4",
   "metadata": {},
   "outputs": [],
   "source": [
    "last_game = box[\"game_date\"].max()\n",
    "if season < current:\n",
    "    status = f\"**Offseason:** the final {label(season)} regular season; the {label(current)} season has no games yet.\"\n",
    "    through = \"final regular season\"\n",
    "elif box.filter(pl.col(\"season_type\") == 3).is_empty():\n",
    "    status = f\"**Season to date:** the {label(season)} season through {last_game:%B} {last_game.day}.\"\n",
    "    through = f\"through {last_game:%b} {last_game.day}\"\n",
    "elif (today - last_game).days <= 10:\n",
    "    status = f\"**Playoffs:** the final {label(season)} regular season; the playoffs are under way.\"\n",
    "    through = \"final regular season\"\n",
    "else:\n",
    "    status = f\"**Offseason:** the final {label(season)} regular season.\"\n",
    "    through = \"final regular season\"\n",
    "subtitle = through[:1].upper() + through[1:]\n",
    "display(Markdown(status))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5",
   "metadata": {},
   "source": [
    "ESPN files the All-Star Game and the in-season cup final as regular-season games, though neither counts in the\n",
    "standings. The schedule marks them in `type_abbreviation` (`ALLSTAR`, `CC`), so keep the standard games (`STD`) only.\n",
    "`resolve` then maps the ESPN team ids to sdvplot's, for the team table's names and conferences."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6",
   "metadata": {},
   "outputs": [],
   "source": [
    "standard = nba.load_nba_schedule(seasons=[season]).filter(\n",
    "    (pl.col(\"season_type\") == 2) & (pl.col(\"type_abbreviation\") == \"STD\")\n",
    ")\n",
    "assert box.schema[\"game_id\"] == standard.schema[\"game_id\"]\n",
    "regular = box.filter(pl.col(\"season_type\") == 2).join(standard.select(\"game_id\"), on=\"game_id\", how=\"semi\")\n",
    "regular = regular.with_columns(\n",
    "    team=pl.Series(sdvplot.resolve(regular[\"team_id\"].cast(pl.String).to_list(), \"nba\"), dtype=pl.String)\n",
    ")\n",
    "possessions = (  # the box-score estimate, averaged with the opponent's\n",
    "    pl.col(\"field_goals_attempted\")\n",
    "    - pl.col(\"offensive_rebounds\")\n",
    "    + pl.col(\"total_turnovers\")\n",
    "    + 0.44 * pl.col(\"free_throws_attempted\")\n",
    ")\n",
    "games = regular.with_columns(poss=possessions).with_columns(poss=pl.col(\"poss\").mean().over(\"game_id\"))\n",
    "teams = sdvplot.teams(\"nba\").select(team=\"team_id\", abbr=\"abbr\", name=\"short_name\", conference=\"conference\")\n",
    "ratings = (\n",
    "    games.sort(\"game_date\")\n",
    "    .group_by(\"team\", maintain_order=True)\n",
    "    .agg(\n",
    "        w=pl.col(\"team_winner\").sum(),\n",
    "        l=(~pl.col(\"team_winner\")).sum(),\n",
    "        l10=pl.col(\"team_winner\").tail(10).sum(),\n",
    "        ortg=100 * pl.col(\"team_score\").sum() / pl.col(\"poss\").sum(),\n",
    "        drtg=100 * pl.col(\"opponent_team_score\").sum() / pl.col(\"poss\").sum(),\n",
    "    )\n",
    "    .with_columns(pct=pl.col(\"w\") / (pl.col(\"w\") + pl.col(\"l\")), net=pl.col(\"ortg\") - pl.col(\"drtg\"))\n",
    "    .join(teams, on=\"team\")\n",
    "    .sort([\"pct\", \"net\", \"team\"], descending=[True, True, False])  # a tiebreaker keeps re-renders stable\n",
    ")\n",
    "ratings.head()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7",
   "metadata": {},
   "source": [
    "## 1. Standings with net rating\n",
    "\n",
    "Each conference ordered by winning percentage, with every team's points scored and allowed per 100 possessions.\n",
    "`gt_sdv_logos` draws the logos from the abbreviations in sdvplot's team table."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8",
   "metadata": {},
   "outputs": [],
   "source": [
    "from great_tables import GT\n",
    "\n",
    "from sdvplot.great_tables import gt_save_crop, gt_sdv_logos, gt_theme_sofa\n",
    "\n",
    "table = ratings.with_columns(\n",
    "    record=pl.format(\"{}-{}\", \"w\", \"l\"),\n",
    "    last10=pl.format(\"{}-{}\", \"l10\", pl.min_horizontal(10, pl.col(\"w\") + pl.col(\"l\")) - pl.col(\"l10\")),\n",
    "    seed=pl.col(\"pct\").rank(\"ordinal\", descending=True).over(\"conference\"),\n",
    ").sort(\"conference\", \"seed\")\n",
    "table = table.select(\"conference\", \"seed\", \"abbr\", \"name\", \"record\", \"pct\", \"last10\", \"ortg\", \"drtg\", \"net\")\n",
    "gt = (\n",
    "    GT(table, groupname_col=\"conference\", id=\"nba-standings\")  # fixed id: no random one each run\n",
    "    .tab_header(f\"NBA standings and ratings, {label(season)}\", subtitle)\n",
    "    .fmt_number(\"pct\", decimals=3)\n",
    "    .cols_align(\"left\", \"name\")\n",
    "    .fmt_number([\"ortg\", \"drtg\"], decimals=1)\n",
    "    .fmt_number(\"net\", decimals=1, force_sign=True)\n",
    "    .data_color(\"net\", palette=[\"#c84630\", \"#f7f7f7\", \"#2e8b57\"], domain=[-15, 15])\n",
    "    .tab_spanner(\"Per 100 possessions\", [\"ortg\", \"drtg\", \"net\"])\n",
    "    .cols_label(\n",
    "        seed=\"\", abbr=\"\", name=\"Team\", record=\"W-L\", pct=\"Pct\", last10=\"Last 10\", ortg=\"Off\", drtg=\"Def\", net=\"Net\"\n",
    "    )\n",
    "    .tab_source_note(SOURCE + \". Possessions estimated from the box score; ties ordered by net rating.\")\n",
    ")\n",
    "gt = gt_theme_sofa(gt_sdv_logos(gt, \"abbr\", league=\"nba\", height=26))\n",
    "gt"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9",
   "metadata": {},
   "source": [
    "`gt_save_crop` renders the same table to a trimmed PNG, ready to post."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "10",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "NBA standings table by conference with team logos, records and offensive, defensive and net ratings.",
     "title": "NBA standings with net rating"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "gt_save_crop(gt, width=900)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "11",
   "metadata": {},
   "source": [
    "## 2. Net rating, best to worst\n",
    "\n",
    "The same net ratings as bars in team colors; `axis_logos` swaps the abbreviations on the x axis for logos."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "12",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Bars of every NBA team's net rating this season in team colors, team logos on the x axis.",
     "title": "NBA net rating bars"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "by_net = ratings.sort([\"net\", \"team\"], descending=[True, False])\n",
    "fig, ax = plt.subplots(figsize=(10, 5.5))\n",
    "ax.bar(by_net[\"abbr\"], by_net[\"net\"], color=sdvplot.team_colors(by_net[\"team\"].to_list(), \"nba\"))\n",
    "ax.axhline(0, color=\"#222222\", lw=0.8)\n",
    "ax.margins(x=0.01)\n",
    "ax.set_ylabel(\"Net rating (points per 100 possessions)\")\n",
    "ax.spines[[\"top\", \"right\"]].set_visible(False)\n",
    "ax.set_title(f\"NBA net rating, {label(season)} {through}\", loc=\"left\", fontweight=\"bold\")\n",
    "fig.text(0.99, 0.01, SOURCE, ha=\"right\", fontsize=8, color=\"grey\")\n",
    "sdvplot.axis_logos(ax, \"x\", league=\"nba\", season=season, height=0.06)\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "13",
   "metadata": {},
   "source": [
    "## 3. Scoring leaders with headshots\n",
    "\n",
    "Points per game from the player box scores (standard games only, as above), for players who appeared in at least half of the most games any team has\n",
    "played. ESPN athlete ids feed `add_headshots`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "14",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Horizontal bars of the NBA's top scorers in points per game, with player headshots and team logos.",
     "title": "NBA scoring leaders with headshots"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "players = (\n",
    "    nba.load_nba_player_boxscore(seasons=[season])\n",
    "    .filter((pl.col(\"season_type\") == 2) & ~pl.col(\"did_not_play\") & pl.col(\"minutes\").is_not_null())\n",
    "    .join(standard.select(\"game_id\"), on=\"game_id\", how=\"semi\")\n",
    ")  # no All-Star or cup final\n",
    "games_played = players.group_by(\"team_id\", maintain_order=True).agg(pl.col(\"game_id\").n_unique())[\"game_id\"].max()\n",
    "leaders = (\n",
    "    players.sort(\"game_date\")\n",
    "    .group_by(\"athlete_id\", maintain_order=True)\n",
    "    .agg(\n",
    "        name=pl.col(\"athlete_display_name\").last(),\n",
    "        team=pl.col(\"team_abbreviation\").last(),\n",
    "        gp=pl.len(),\n",
    "        ppg=pl.col(\"points\").mean(),\n",
    "    )\n",
    "    .filter(pl.col(\"gp\") >= games_played / 2)\n",
    "    .sort([\"ppg\", \"athlete_id\"], descending=[True, False])\n",
    "    .head(12)\n",
    "    .reverse()\n",
    ")\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(9, 7))\n",
    "y = list(range(leaders.height))\n",
    "ax.barh(y, leaders[\"ppg\"], color=sdvplot.team_colors(leaders[\"team\"].to_list(), \"nba\"), height=0.7)\n",
    "ax.set_yticks(y, [f\"{name}  \" for name in leaders[\"name\"]])\n",
    "top = leaders[\"ppg\"].max()\n",
    "for i, (ppg, gp) in enumerate(zip(leaders[\"ppg\"], leaders[\"gp\"], strict=True)):\n",
    "    ax.text(ppg + 0.1 * top, i, f\"{ppg:.1f}  ({gp} games)\", va=\"center\", fontsize=9)\n",
    "ax.set_xlim(-0.11 * top, 1.42 * top)\n",
    "sdvplot.add_headshots(ax, [-0.055 * top] * leaders.height, y, leaders[\"athlete_id\"], league=\"nba\", height=0.085)\n",
    "sdvplot.add_logos(\n",
    "    ax, (leaders[\"ppg\"] + 0.05 * top).to_list(), y, leaders[\"team\"], league=\"nba\", season=season, height=0.06\n",
    ")\n",
    "ax.spines[[\"top\", \"right\", \"left\"]].set_visible(False)\n",
    "ax.tick_params(axis=\"y\", length=0)\n",
    "ax.set_xlabel(\"Points per game\")\n",
    "ax.set_title(f\"NBA scoring leaders, {label(season)} {through}\", loc=\"left\", fontweight=\"bold\")\n",
    "fig.text(0.99, 0.01, f\"Minimum {games_played / 2:.0f} games. {SOURCE}\", ha=\"right\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "sdvplot": {
   "description": "The latest NBA season, rebuilt every week: standings with net rating, a net rating chart with logos and the scoring leaders with headshots.",
   "label": "NBA",
   "position": 3
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
