{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "0",
   "metadata": {},
   "source": [
    "# NBA net rating quadrant\n",
    "\n",
    "**The brief:** a season-review blog post needs one chart of where all 30 teams finished on offense and defense,\n",
    "split by conference, at 1600 px wide, plus a square version for Instagram. This recipe builds it in plotnine, where\n",
    "each fix is one more layer: logos with `geom_sdv_logos`, per-conference averages with `geom_mean_lines`, and facets\n",
    "for the conferences. The ratings come from hoopR's ESPN team box scores through `sportsdataverse.nba`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1",
   "metadata": {},
   "outputs": [],
   "source": [
    "import tempfile\n",
    "import warnings\n",
    "from pathlib import Path\n",
    "\n",
    "import polars as pl\n",
    "import sportsdataverse.nba as nba\n",
    "from IPython.display import Image\n",
    "from PIL import Image as PILImage\n",
    "from plotnine import (\n",
    "    aes,\n",
    "    element_blank,\n",
    "    element_line,\n",
    "    element_text,\n",
    "    facet_wrap,\n",
    "    geom_label,\n",
    "    geom_line,\n",
    "    geom_point,\n",
    "    geom_text,\n",
    "    ggplot,\n",
    "    labs,\n",
    "    scale_x_continuous,\n",
    "    scale_y_reverse,\n",
    "    theme,\n",
    "    theme_minimal,\n",
    ")\n",
    "\n",
    "import sdvplot\n",
    "from sdvplot.plotnine import geom_mean_lines, geom_sdv_logos\n",
    "\n",
    "SEASON = 2026  # the 2025-26 season: NBA seasons are named by the year they end\n",
    "LABEL = f\"{SEASON - 1}-{SEASON % 100:02d}\"\n",
    "OUT = Path(tempfile.mkdtemp(prefix=\"sdvplot-recipe-\"))  # where the exports go; use your own folder"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2",
   "metadata": {},
   "source": [
    "## 1. Get the data\n",
    "\n",
    "Offensive and defensive rating are points scored and allowed per 100 possessions. Possessions are estimated from the\n",
    "box score (FGA - OREB + TOV + 0.44 FTA) and averaged with the opponent's, so both teams in a game share one count."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3",
   "metadata": {},
   "outputs": [],
   "source": [
    "box = nba.load_nba_team_boxscore(seasons=[SEASON]).filter(pl.col(\"season_type\") == 2)  # regular season\n",
    "possessions = (\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 = box.with_columns(poss=possessions)\n",
    "opponents = games.select(\"game_id\", opponent_team_id=\"team_id\", opp_poss=\"poss\")\n",
    "assert games.schema[\"opponent_team_id\"] == opponents.schema[\"opponent_team_id\"]  # same dtype on both sides\n",
    "games = games.join(opponents, on=[\"game_id\", \"opponent_team_id\"]).with_columns(\n",
    "    game_poss=(pl.col(\"poss\") + pl.col(\"opp_poss\")) / 2\n",
    ")\n",
    "ratings = (\n",
    "    games.group_by(\"team_abbreviation\", maintain_order=True)\n",
    "    .agg(\n",
    "        ortg=100 * pl.col(\"team_score\").sum() / pl.col(\"game_poss\").sum(),\n",
    "        drtg=100 * pl.col(\"opponent_team_score\").sum() / pl.col(\"game_poss\").sum(),\n",
    "    )\n",
    "    .with_columns(net=pl.col(\"ortg\") - pl.col(\"drtg\"))\n",
    "    .sort(\"team_abbreviation\")  # group_by returns groups in any order; sort for a stable result\n",
    ")\n",
    "ratings.height"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4",
   "metadata": {},
   "source": [
    "33 teams in a 30-team league. The extra three are the All-Star Game's teams, which ESPN files as regular-season\n",
    "games. `resolve` is the quickest way to find them: anything that is not an NBA team comes back `None`, with one\n",
    "warning that names it. The conference comes from the same resolved ids."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5",
   "metadata": {},
   "outputs": [],
   "source": [
    "with warnings.catch_warnings(record=True) as caught:\n",
    "    warnings.simplefilter(\"always\")\n",
    "    team_ids = sdvplot.resolve(ratings[\"team_abbreviation\"].to_list(), \"nba\")\n",
    "print(caught[0].message)\n",
    "\n",
    "conferences = sdvplot.teams(\"nba\").select(\"team_id\", conference=pl.col(\"conference\").str.replace(\"ern Conference\", \"\"))\n",
    "ratings = (\n",
    "    ratings.with_columns(team_id=pl.Series(team_ids, dtype=pl.Utf8))\n",
    "    .filter(pl.col(\"team_id\").is_not_null())\n",
    "    .join(conferences, on=\"team_id\")\n",
    "    .sort(\"net\", descending=True)\n",
    ")\n",
    "ratings.head()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6",
   "metadata": {},
   "source": [
    "## 2. The first draft\n",
    "\n",
    "Points and abbreviations, plotnine's defaults."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7",
   "metadata": {},
   "outputs": [],
   "source": [
    "(ggplot(ratings, aes(\"ortg\", \"drtg\", label=\"team_abbreviation\")) + geom_point() + geom_text(nudge_y=0.4, size=8))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8",
   "metadata": {},
   "source": [
    "Three problems: the labels overlap, a low defensive rating is good but sits at the bottom, and the two conferences\n",
    "(which only meet a third of the time) are mixed together.\n",
    "\n",
    "## 3. Logos, one panel per conference, and average lines\n",
    "\n",
    "`geom_sdv_logos` replaces the points (the `team` aesthetic takes the abbreviations as they are), `facet_wrap` splits\n",
    "the conferences, and `geom_mean_lines` draws each panel's own average offense and defense, so every team is read\n",
    "against its conference."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9",
   "metadata": {},
   "outputs": [],
   "source": [
    "p = (\n",
    "    ggplot(ratings, aes(\"ortg\", \"drtg\"))\n",
    "    + geom_mean_lines(aes(x0=\"ortg\", y0=\"drtg\"), color=\"#9a9a9a\", size=0.6)\n",
    "    + geom_sdv_logos(aes(team=\"team_abbreviation\"), league=\"nba\", season=SEASON, height=0.08)\n",
    "    + facet_wrap(\"conference\")\n",
    ")\n",
    "p"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "10",
   "metadata": {},
   "source": [
    "Better, but the best defenses are at the bottom, logos at the edges are clipped (Denver, Brooklyn, Washington), and\n",
    "the axis titles are column names.\n",
    "\n",
    "## 4. Point the axes the right way and add net-rating guides\n",
    "\n",
    "`scale_y_reverse` puts the best defenses on top, so up and to the right is good on both axes, and `expand` on both\n",
    "scales leaves room for the edge logos. Net rating is the gap between the two numbers, so teams with the same net\n",
    "rating sit on a diagonal: faint lines at -10, -5, 0, +5 and +10 show it without a third axis. Each line is clipped\n",
    "to the data's range and drawn as data (`geom_line`), so the reversed scale moves it with the logos."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "11",
   "metadata": {},
   "outputs": [],
   "source": [
    "x_lo, x_hi = ratings[\"ortg\"].min() - 1, ratings[\"ortg\"].max() + 1\n",
    "y_lo, y_hi = ratings[\"drtg\"].min() - 1, ratings[\"drtg\"].max() + 1\n",
    "# the line drtg = ortg - net, cut to the box [x_lo, x_hi] x [y_lo, y_hi]\n",
    "ends = (\n",
    "    pl.DataFrame({\"net\": [-10, -5, 0, 5, 10]})\n",
    "    .with_columns(\n",
    "        x0=pl.max_horizontal(pl.lit(x_lo), pl.lit(y_lo) + pl.col(\"net\")),\n",
    "        x1=pl.min_horizontal(pl.lit(x_hi), pl.lit(y_hi) + pl.col(\"net\")),\n",
    "    )\n",
    "    .filter(pl.col(\"x0\") < pl.col(\"x1\"))\n",
    ")\n",
    "guides = (\n",
    "    ends.unpivot([\"x0\", \"x1\"], index=\"net\", value_name=\"ortg\")\n",
    "    .with_columns(drtg=pl.col(\"ortg\") - pl.col(\"net\"))\n",
    "    .join(pl.DataFrame({\"conference\": [\"East\", \"West\"]}), how=\"cross\")  # the same guides in both panels\n",
    ")\n",
    "guide_labels = (\n",
    "    ends.with_columns(ortg=pl.col(\"x0\") + 0.9 * (pl.col(\"x1\") - pl.col(\"x0\")))  # near the lower-right end\n",
    "    .with_columns(\n",
    "        drtg=pl.col(\"ortg\") - pl.col(\"net\"),\n",
    "        label=pl.when(pl.col(\"net\") > 0).then(pl.format(\"+{}\", \"net\")).otherwise(pl.col(\"net\").cast(pl.Utf8)),\n",
    "    )\n",
    "    .join(pl.DataFrame({\"conference\": [\"East\", \"West\"]}), how=\"cross\")\n",
    ")\n",
    "\n",
    "p = (\n",
    "    ggplot(ratings, aes(\"ortg\", \"drtg\"))\n",
    "    + geom_line(aes(group=\"net\"), data=guides, color=\"#d9d9d9\", size=0.5)\n",
    "    + geom_label(aes(label=\"label\"), data=guide_labels, color=\"#a6a6a6\", size=7, fill=\"white\", label_size=0)\n",
    "    + geom_mean_lines(aes(x0=\"ortg\", y0=\"drtg\"), color=\"#9a9a9a\", size=0.6)\n",
    "    + geom_sdv_logos(aes(team=\"team_abbreviation\"), league=\"nba\", season=SEASON, height=0.08)\n",
    "    + facet_wrap(\"conference\")\n",
    "    + scale_x_continuous(expand=(0.05, 0))\n",
    "    + scale_y_reverse(expand=(0.07, 0))\n",
    "    + labs(x=\"Offensive rating (points per 100 possessions)\", y=\"Defensive rating (allowed per 100)\")\n",
    ")\n",
    "p"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "12",
   "metadata": {},
   "source": [
    "## 5. Polish and tell the story\n",
    "\n",
    "A theme strips the chart junk (minor grid, panel background) and sets the type; the strip titles become plain\n",
    "bold labels. The title states the finding, the subtitle explains the reading, and the caption carries the source.\n",
    "The two conference leaders get their net rating printed under the logo."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "13",
   "metadata": {},
   "outputs": [],
   "source": [
    "leaders = (\n",
    "    ratings.group_by(\"conference\", maintain_order=True)\n",
    "    .agg(pl.all().sort_by(\"net\").last())\n",
    "    .sort(\"conference\")\n",
    "    .with_columns(\n",
    "        note=pl.format(\"{} +{}\", \"team_abbreviation\", pl.col(\"net\").round(1))  # both leaders are above zero\n",
    "    )\n",
    ")\n",
    "top = leaders.sort(\"net\", descending=True).row(0, named=True)\n",
    "east = leaders.filter(pl.col(\"conference\") == \"East\").row(0, named=True)\n",
    "title = f\"{top['team_abbreviation']} stood alone at +{top['net']:.1f}; {east['team_abbreviation']} led a crowded East\"\n",
    "\n",
    "blog_theme = theme_minimal(base_size=10) + theme(\n",
    "    figure_size=(9, 5.4),\n",
    "    plot_title=element_text(weight=\"bold\", size=14),\n",
    "    plot_subtitle=element_text(color=\"#6b6b6b\", size=9.5),\n",
    "    plot_caption=element_text(color=\"#6b6b6b\", size=7.5),\n",
    "    strip_text=element_text(weight=\"bold\", size=11, ha=\"left\"),\n",
    "    axis_title=element_text(color=\"#6b6b6b\", size=9),\n",
    "    axis_text=element_text(color=\"#8a8a8a\"),\n",
    "    panel_grid_minor=element_blank(),\n",
    "    panel_grid_major=element_line(color=\"#efefef\"),\n",
    "    plot_title_position=\"plot\",\n",
    ")\n",
    "chart = (\n",
    "    p\n",
    "    + geom_text(aes(label=\"note\"), data=leaders, size=8, fontweight=\"bold\", ha=\"left\", nudge_x=0.75, color=\"#333333\")\n",
    "    + labs(\n",
    "        title=title,\n",
    "        subtitle=f\"Points scored and allowed per 100 possessions, {LABEL} regular season; up and right is better.\\n\"\n",
    "        \"Dashed lines: each conference's average. Diagonals: net rating, in steps of 5.\",\n",
    "        caption=\"Data: hoopR (ESPN) via sportsdataverse-py  |  Chart: sdvplot + plotnine\",\n",
    "    )\n",
    "    + blog_theme\n",
    ")\n",
    "chart"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "14",
   "metadata": {},
   "source": [
    "## 6. Export for the blog and for Instagram\n",
    "\n",
    "`ggplot.save` takes the size in inches and a dpi: 8 x 4.5 in at 200 dpi is the blog's 1600 x 900. The square post\n",
    "re-lays the same plot with one more layer: `facet_wrap(..., ncol=1)` stacks the conferences, and a theme tweak\n",
    "sets the square figure size."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "15",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "plotnine scatter of 2025-26 NBA offensive and defensive ratings with team logos, faceted East and West, with conference average lines and net-rating diagonals",
     "title": "NBA offensive vs defensive rating by conference"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "blog = OUT / \"nba_net_rating_1600x900.png\"\n",
    "chart.save(blog, width=8, height=4.5, dpi=200, verbose=False)\n",
    "\n",
    "square = OUT / \"nba_net_rating_1080x1080.png\"\n",
    "(chart + facet_wrap(\"conference\", ncol=1) + theme(figure_size=(7.2, 7.2))).save(\n",
    "    square, width=7.2, height=7.2, dpi=150, verbose=False\n",
    ")\n",
    "for f in (blog, square):\n",
    "    print(f.name, PILImage.open(f).size)\n",
    "Image(blog, width=800)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "16",
   "metadata": {},
   "source": [
    "The square cut, stacked:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "17",
   "metadata": {},
   "outputs": [],
   "source": [
    "Image(square, width=540)"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "sdvplot": {
   "description": "Chart every NBA team's offensive and defensive rating by conference in plotnine, with logos, average lines and net-rating guides, for a blog post and a square social post.",
   "label": "NBA net rating quadrant",
   "position": 3
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
