{ "cells": [ { "cell_type": "markdown", "id": "pwhl-title", "metadata": {}, "source": [ "# πŸ’ The PWHL with `sportsdataverse-py`\n", "\n", "Welcome to **professional women's hockey**! The Professional Women's Hockey League (**PWHL**) dropped its first puck in January 2024 with six clubs β€” Boston, Minnesota, MontrΓ©al, New York, Ottawa and Toronto β€” and it's been must-watch hockey ever since. πŸŽ‰\n", "\n", "`sportsdataverse.pwhl` gives you the whole league two ways:\n", "\n", "1. πŸ“¦ **`load_pwhl_*` release loaders** β€” fast, reliable parquet snapshots (schedules, boxscores, play-by-play, scoring & penalty summaries, rosters). Perfect for season-long analysis, and they work great offline.\n", "2. πŸ›°οΈ **`pwhl_*` live wrappers + analytics** β€” straight off the HockeyTech stats feed (standings, leaders, rosters, stats, single-game PBP) **plus** derived on-ice metrics (Corsi, time-on-ice, shifts).\n", "\n", "And the best part: **no API key needed** β€” the public HockeyTech client key ships with the package. R companion: [fastRhockey](https://fastRhockey.sportsdataverse.org). Let's drop the puck! πŸ₯…" ] }, { "cell_type": "markdown", "id": "pwhl-toolbox", "metadata": {}, "source": [ "## 🧰 The toolbox\n", "\n", "Everything returns a tidy **polars** `DataFrame` by default β€” pass `return_as_pandas=True` for pandas. The πŸ“¦ **loaders** read pre-built release parquets (one season per call); the πŸ›°οΈ **live** wrappers hit the HockeyTech API in real time. Both are *premium* PWHL sources. Click any name for the full reference:\n", "\n", "| Function | What it gives you | Source |\n", "|---|---|---|\n", "| [`load_pwhl_schedule`](../pwhl/reference/loaders.md#load_pwhl_schedules) | Games + results, one row per game | πŸ“¦ loader |\n", "| [`load_pwhl_rosters`](../pwhl/reference/loaders.md#load_pwhl_rosters) | One row per player per team (skaters + goalies) | πŸ“¦ loader |\n", "| [`load_pwhl_skater_box`](../pwhl/reference/loaders.md#load_pwhl_skater_boxscores) | Skater boxscore, one row per player per game | πŸ“¦ loader |\n", "| [`load_pwhl_goalie_box`](../pwhl/reference/loaders.md#load_pwhl_goalie_boxscores) | Goalie boxscore (saves, shots against, GAA inputs) | πŸ“¦ loader |\n", "| [`load_pwhl_team_box`](../pwhl/reference/loaders.md#load_pwhl_team_boxscores) | Team boxscore (shots, PP, faceoffs) | πŸ“¦ loader |\n", "| [`load_pwhl_pbp`](../pwhl/reference/loaders.md#load_pwhl_pbp) | Event-level play-by-play (wide, with coordinates) | πŸ“¦ loader |\n", "| [`load_pwhl_scoring_summary`](../pwhl/reference/loaders.md#load_pwhl_scoring_summary) | Tidy goal log (scorer + assists + situation flags) | πŸ“¦ loader |\n", "| [`load_pwhl_penalty_summary`](../pwhl/reference/loaders.md#load_pwhl_penalty_summary) | Tidy penalty log (infraction, minutes, who took it) | πŸ“¦ loader |\n", "| [`load_pwhl_shots_by_period`](../pwhl/reference/loaders.md#load_pwhl_shots_by_period) | Per-period shot & goal totals per game | πŸ“¦ loader |\n", "| [`load_pwhl_three_stars`](../pwhl/reference/loaders.md#load_pwhl_three_stars) | Post-game three-star selections | πŸ“¦ loader |\n", "| [`pwhl_schedule`](../pwhl/reference/additional.md#pwhl_schedule) | Live schedule, one row per game | πŸ›°οΈ live |\n", "| [`pwhl_standings`](../pwhl/reference/additional.md#pwhl_standings) | Live standings, one row per team | πŸ›°οΈ live |\n", "| [`pwhl_teams`](../pwhl/reference/additional.md#pwhl_teams) | Teams in a season (grab `team_id`s) | πŸ›°οΈ live |\n", "| [`pwhl_team_roster`](../pwhl/reference/additional.md#pwhl_team_roster) | A team's roster | πŸ›°οΈ live |\n", "| [`pwhl_leaders`](../pwhl/reference/additional.md#pwhl_leaders) | Statistical leaders | πŸ›°οΈ live |\n", "| [`pwhl_stats`](../pwhl/reference/additional.md#pwhl_stats) | Aggregate skater/goalie stats | πŸ›°οΈ live |\n", "| [`pwhl_player_search`](../pwhl/reference/additional.md#pwhl_player_search) | Find a player_id by name | πŸ›°οΈ live |\n", "| [`pwhl_player_stats`](../pwhl/reference/additional.md#pwhl_player_stats) | A player's season-by-season stat lines | πŸ›°οΈ live |\n", "| [`pwhl_pbp`](../pwhl/reference/additional.md#pwhl_pbp) | Enriched single-game play-by-play | πŸ›°οΈ live |\n", "| [`pwhl_game_corsi`](../pwhl/reference/additional.md#pwhl_game_corsi) | On-ice Corsi / Fenwick per player | πŸ›°οΈ live |\n", "| [`pwhl_player_toi`](../pwhl/reference/additional.md#pwhl_player_toi) | Time-on-ice per player | πŸ›°οΈ live |\n", "| [`pwhl_game_shifts`](../pwhl/reference/additional.md#pwhl_game_shifts) | Raw shift stints | πŸ›°οΈ live |\n", "| [`most_recent_pwhl_season`](../pwhl/reference/additional.md#most_recent_pwhl_season) Β· [`pwhl_season_id`](../pwhl/reference/additional.md#pwhl_season_id) | Season helpers | πŸ›°οΈ live |\n" ] }, { "cell_type": "markdown", "id": "pwhl-setup", "metadata": {}, "source": [ "## πŸ”Œ Setup\n", "\n", "```sh\n", "pip install sportsdataverse\n", "```\n", "\n", "No key, no config β€” just import and go." ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-imports", "metadata": {}, "outputs": [], "source": [ "import polars as pl\n", "import sportsdataverse.pwhl as pwhl\n", "\n", "# The inaugural season is 2024; this helper tracks the latest known season.\n", "print(\"most recent PWHL season:\", pwhl.most_recent_pwhl_season())" ] }, { "cell_type": "markdown", "id": "pwhl-safe-md", "metadata": {}, "source": [ "The πŸ›°οΈ **live** HockeyTech feed is seasonal and occasionally rate-limited, so a tiny `safe()` helper runs those calls defensively β€” you get the frame when the feed is up, and a friendly one-liner when it isn't (never a scary traceback). The πŸ“¦ **loaders** read release parquets and are rock-solid, so they don't need the wrapper. πŸ›Ÿ" ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-safe", "metadata": {}, "outputs": [], "source": [ "from sportsdataverse.errors import AssetFetchError, NoDataError\n", "\n", "def safe(label, thunk):\n", " try:\n", " out = thunk()\n", " print(f\"βœ… {label}\")\n", " return out\n", " except (NoDataError, AssetFetchError) as e:\n", " print(f\"\\u23ed\\ufe0f {label}: {type(e).__name__}: {e}\")\n", " return None" ] }, { "cell_type": "markdown", "id": "pwhl-schedule-md", "metadata": {}, "source": [ "## πŸ“… The schedule (loader)\n", "\n", "[`load_pwhl_schedule`](../pwhl/reference/loaders.md#load_pwhl_schedules) returns one row per game with the result and a set of flag/URL columns pointing at the per-game feeds. Pass `seasons=[2024]` (a list β€” you can stack multiple seasons). ⚠️ Heads up: `home_score`/`away_score` come back as **strings**, so cast them before doing arithmetic." ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-schedule", "metadata": {}, "outputs": [], "source": [ "schedule = pwhl.load_pwhl_schedule(seasons=[2024])\n", "schedule.shape" ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-schedule-head", "metadata": {}, "outputs": [], "source": [ "schedule.select([\n", " 'game_id', 'game_date', 'home_team', 'away_team',\n", " 'home_score', 'away_score', 'winner', 'game_type',\n", "]).head()" ] }, { "cell_type": "markdown", "id": "pwhl-rosters-md", "metadata": {}, "source": [ "## πŸ‘₯ Rosters (loader)\n", "\n", "[`load_pwhl_rosters`](../pwhl/reference/loaders.md#load_pwhl_rosters) gives one row per player per team, split into skaters and goalies via the `player_type` column." ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-rosters", "metadata": {}, "outputs": [], "source": [ "rosters = pwhl.load_pwhl_rosters(seasons=[2024])\n", "rosters.select([\n", " 'team', 'team_abbr', 'player_type', 'first_name', 'last_name',\n", " 'jersey_number', 'position',\n", "]).head()" ] }, { "cell_type": "markdown", "id": "pwhl-boxscores-md", "metadata": {}, "source": [ "## πŸ“Š Boxscores (loader)\n", "\n", "Boxscores come in three flavours β€” `team_box`, `skater_box`, and `goalie_box` β€” each one row per team/player per game.\n", "\n", "| Function | One row per… |\n", "|---|---|\n", "| [`load_pwhl_team_box`](../pwhl/reference/loaders.md#load_pwhl_team_boxscores) | team per game |\n", "| [`load_pwhl_skater_box`](../pwhl/reference/loaders.md#load_pwhl_skater_boxscores) | skater per game |\n", "| [`load_pwhl_goalie_box`](../pwhl/reference/loaders.md#load_pwhl_goalie_boxscores) | goalie per game |\n" ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-skater-box", "metadata": {}, "outputs": [], "source": [ "skater_box = pwhl.load_pwhl_skater_box(seasons=[2024])\n", "skater_box.select([\n", " 'game_id', 'first_name', 'last_name', 'position',\n", " 'goals', 'assists', 'points', 'shots', 'plus_minus', 'time_on_ice',\n", "]).head()" ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-goalie-box", "metadata": {}, "outputs": [], "source": [ "goalie_box = pwhl.load_pwhl_goalie_box(seasons=[2024])\n", "goalie_box.select([\n", " 'game_id', 'first_name', 'last_name',\n", " 'saves', 'shots_against', 'goals_against', 'time_on_ice',\n", "]).head()" ] }, { "cell_type": "markdown", "id": "pwhl-pbp-md", "metadata": {}, "source": [ "## 🎬 Play-by-play (loader)\n", "\n", "[`load_pwhl_pbp`](../pwhl/reference/loaders.md#load_pwhl_pbp) returns a wide event log. The `event` column tags each row as `faceoff`, `shot`, `goal`, or `penalty` β€” and there are several coordinate systems (`x_coord`/`y_coord` plus rink-normalized `*_fixed` / `*_right` variants) for drawing rink plots." ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-pbp", "metadata": {}, "outputs": [], "source": [ "pbp = pwhl.load_pwhl_pbp(seasons=[2024])\n", "pbp.shape" ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-pbp-events", "metadata": {}, "outputs": [], "source": [ "(pbp\n", " .group_by('event')\n", " .agg(pl.len().alias('events'))\n", " .sort('events', descending=True))" ] }, { "cell_type": "markdown", "id": "pwhl-cookbook-md", "metadata": {}, "source": [ "## 🍳 Cookbook: common PWHL tasks\n", "\n", "Now the fun part β€” a baker's dozen of recipes you'll reach for constantly. Recipes **1–11** lean on the rock-solid πŸ“¦ loaders (great offline); recipes **12–13** tour the πŸ›°οΈ live wrappers, wrapped in `safe()` so an offseason or a flaky feed never breaks your run. Every recipe ends in a tidy, ready-to-read frame." ] }, { "cell_type": "markdown", "id": "pwhl-recipe1-md", "metadata": {}, "source": [ "### Recipe 1 β€” Standings from the schedule πŸ†\n", "\n", "No loader is needed for a quick standings table: the schedule's `winner` column makes a regular-season win count a one-liner." ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-recipe1", "metadata": {}, "outputs": [], "source": [ "(schedule\n", " .filter(pl.col('game_type') == 'regular')\n", " .group_by('winner')\n", " .agg(pl.len().alias('wins'))\n", " .sort('wins', descending=True))" ] }, { "cell_type": "markdown", "id": "pwhl-recipe2-md", "metadata": {}, "source": [ "### Recipe 2 β€” Season scoring leaders πŸ₯‡\n", "\n", "Aggregate the skater boxscore across every game to build a points leaderboard β€” the inaugural-season top of the table." ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-recipe2", "metadata": {}, "outputs": [], "source": [ "(skater_box\n", " .group_by(['player_id', 'first_name', 'last_name'])\n", " .agg(\n", " pl.col('goals').sum().alias('goals'),\n", " pl.col('assists').sum().alias('assists'),\n", " pl.col('points').sum().alias('points'),\n", " )\n", " .sort('points', descending=True)\n", " .select(['first_name', 'last_name', 'goals', 'assists', 'points'])\n", " .head(10))" ] }, { "cell_type": "markdown", "id": "pwhl-recipe3-md", "metadata": {}, "source": [ "### Recipe 3 β€” Goalie save-percentage leaders 🧀\n", "\n", "Sum saves and shots-against from the goalie boxscore, then compute a season save percentage. We require a minimum shot volume so a one-game cameo doesn't top the list." ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-recipe3", "metadata": {}, "outputs": [], "source": [ "(goalie_box\n", " .group_by(['player_id', 'first_name', 'last_name'])\n", " .agg(\n", " pl.col('saves').sum().alias('saves'),\n", " pl.col('shots_against').sum().alias('shots_against'),\n", " pl.col('goals_against').sum().alias('goals_against'),\n", " )\n", " .filter(pl.col('shots_against') >= 100)\n", " .with_columns(\n", " (pl.col('saves') / pl.col('shots_against')).round(3).alias('save_pct')\n", " )\n", " .sort('save_pct', descending=True)\n", " .select(['first_name', 'last_name', 'shots_against', 'goals_against', 'save_pct'])\n", " .head(10))" ] }, { "cell_type": "markdown", "id": "pwhl-recipe4-md", "metadata": {}, "source": [ "### Recipe 4 β€” Biggest blowouts of the season πŸ’₯\n", "\n", "Cast the string scores to integers, compute the margin, and sort β€” the season's most lopsided games fall right out." ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-recipe4", "metadata": {}, "outputs": [], "source": [ "(schedule\n", " .with_columns(\n", " pl.col('home_score').cast(pl.Int32),\n", " pl.col('away_score').cast(pl.Int32),\n", " )\n", " .with_columns(\n", " (pl.col('home_score') - pl.col('away_score')).abs().alias('margin')\n", " )\n", " .sort('margin', descending=True)\n", " .select(['game_date', 'home_team', 'home_score',\n", " 'away_score', 'away_team', 'winner', 'margin'])\n", " .head(10))" ] }, { "cell_type": "markdown", "id": "pwhl-recipe5-md", "metadata": {}, "source": [ "### Recipe 5 β€” Team offense: shots & shooting % ⚑\n", "\n", "Roll the **team** boxscore up to the club level for a quick offensive profile β€” total goals, shot volume, and finishing rate." ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-recipe5", "metadata": {}, "outputs": [], "source": [ "# Map each team_id to its abbreviation (both Int32-keyed), then roll up the\n", "# skater box to the club level for a quick offensive profile.\n", "team_lookup = (pwhl.load_pwhl_team_box(seasons=[2024])\n", " .select(['team_id', 'team_abbr']).unique())\n", "\n", "(skater_box\n", " .join(team_lookup, on='team_id', how='left')\n", " .group_by('team_abbr')\n", " .agg(\n", " pl.col('goals').sum().alias('goals'),\n", " pl.col('shots').sum().alias('shots'),\n", " )\n", " .with_columns(\n", " (pl.col('goals') / pl.col('shots') * 100).round(1).alias('shooting_pct')\n", " )\n", " .filter(pl.col('team_abbr').is_not_null())\n", " .sort('goals', descending=True))" ] }, { "cell_type": "markdown", "id": "pwhl-recipe6-md", "metadata": {}, "source": [ "### Recipe 6 β€” Power-play conversion leaders πŸ”Œ\n", "\n", "The team boxscore carries `pp_goals` and `pp_opportunities`, so a season power-play percentage is a single division." ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-recipe6", "metadata": {}, "outputs": [], "source": [ "team_box = pwhl.load_pwhl_team_box(seasons=[2024])\n", "\n", "(team_box\n", " .group_by('team_abbr')\n", " .agg(\n", " pl.col('pp_goals').sum().alias('pp_goals'),\n", " pl.col('pp_opportunities').sum().alias('pp_opportunities'),\n", " )\n", " .with_columns(\n", " (pl.col('pp_goals') / pl.col('pp_opportunities') * 100).round(1).alias('pp_pct')\n", " )\n", " .sort('pp_pct', descending=True))" ] }, { "cell_type": "markdown", "id": "pwhl-recipe7-md", "metadata": {}, "source": [ "### Recipe 7 β€” Faceoff specialists 🎯\n", "\n", "The skater boxscore tracks faceoff wins and attempts. Aggregate, gate on a minimum-draw threshold, and the dot-dominators rise to the top." ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-recipe7", "metadata": {}, "outputs": [], "source": [ "(skater_box\n", " .group_by(['first_name', 'last_name'])\n", " .agg(\n", " pl.col('faceoff_wins').sum().alias('fo_wins'),\n", " pl.col('faceoff_attempts').sum().alias('fo_attempts'),\n", " )\n", " .filter(pl.col('fo_attempts') >= 200)\n", " .with_columns(\n", " (pl.col('fo_wins') / pl.col('fo_attempts') * 100).round(1).alias('fo_pct')\n", " )\n", " .sort('fo_pct', descending=True)\n", " .head(10))" ] }, { "cell_type": "markdown", "id": "pwhl-recipe8-md", "metadata": {}, "source": [ "### Recipe 8 β€” Two-way workhorses: hits + blocks 🧱\n", "\n", "Not every contribution shows up on the scoresheet. Sum hits and blocked shots from the skater box to surface the players doing the dirty work β€” defenders usually own this list." ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-recipe8", "metadata": {}, "outputs": [], "source": [ "(skater_box\n", " .group_by(['first_name', 'last_name', 'position'])\n", " .agg(\n", " pl.col('hits').sum().alias('hits'),\n", " pl.col('blocked_shots').sum().alias('blocks'),\n", " )\n", " .with_columns(\n", " (pl.col('hits') + pl.col('blocks')).alias('hits_plus_blocks')\n", " )\n", " .sort('hits_plus_blocks', descending=True)\n", " .head(10))" ] }, { "cell_type": "markdown", "id": "pwhl-recipe9-md", "metadata": {}, "source": [ "### Recipe 9 β€” The penalty box 🚨\n", "\n", "[`load_pwhl_penalty_summary`](../pwhl/reference/loaders.md#load_pwhl_penalty_summary) is a tidy per-infraction log. Two quick cuts: the most common infractions league-wide, and the players spending the most time in the box." ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-recipe9", "metadata": {}, "outputs": [], "source": [ "penalties = pwhl.load_pwhl_penalty_summary(seasons=[2024])\n", "\n", "# Most common infractions\n", "top_infractions = (penalties\n", " .group_by('description')\n", " .agg(pl.len().alias('count'))\n", " .sort('count', descending=True)\n", " .head(8))\n", "top_infractions" ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-recipe9b", "metadata": {}, "outputs": [], "source": [ "# PIM leaders (players who actually took the penalty)\n", "(penalties\n", " .filter(pl.col('taken_by_last').is_not_null())\n", " .group_by(['taken_by_first', 'taken_by_last'])\n", " .agg(\n", " pl.col('minutes').sum().alias('pim'),\n", " pl.len().alias('penalties'),\n", " )\n", " .sort('pim', descending=True)\n", " .head(10))" ] }, { "cell_type": "markdown", "id": "pwhl-recipe10-md", "metadata": {}, "source": [ "### Recipe 10 β€” When do goals get scored? ⏱️\n", "\n", "Slice the goal log out of the play-by-play and bucket it by period β€” and pull the league's top finishers straight from the `event == 'goal'` rows while you're there." ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-recipe10", "metadata": {}, "outputs": [], "source": [ "goal_events = pbp.filter(pl.col('event') == 'goal')\n", "\n", "# Goals by period\n", "goals_by_period = (goal_events\n", " .group_by('period_of_game')\n", " .agg(pl.len().alias('goals'))\n", " .sort('period_of_game'))\n", "goals_by_period" ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-recipe10b", "metadata": {}, "outputs": [], "source": [ "# Top goal-scorers from the play-by-play feed\n", "(goal_events\n", " .filter(pl.col('player_name_last').is_not_null())\n", " .group_by(['player_name_first', 'player_name_last'])\n", " .agg(pl.len().alias('goals'))\n", " .sort('goals', descending=True)\n", " .head(10))" ] }, { "cell_type": "markdown", "id": "pwhl-recipe11-md", "metadata": {}, "source": [ "### Recipe 11 β€” Three-stars honour roll ⭐ and a head-to-head series\n", "\n", "Two compact joins-on-themselves. First, who collected the most **first-star** nods ([`load_pwhl_three_stars`](../pwhl/reference/loaders.md#load_pwhl_three_stars)). Then a **head-to-head** series view from the schedule β€” swap in any two clubs." ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-recipe11", "metadata": {}, "outputs": [], "source": [ "three_stars = pwhl.load_pwhl_three_stars(seasons=[2024])\n", "\n", "# First-star honour roll\n", "(three_stars\n", " .filter(pl.col('star') == 1)\n", " .group_by(['first_name', 'last_name'])\n", " .agg(pl.len().alias('first_stars'))\n", " .sort('first_stars', descending=True)\n", " .head(10))" ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-recipe11b", "metadata": {}, "outputs": [], "source": [ "# Head-to-head: Boston vs. Montreal, every meeting in 2024\n", "A, B = 'Boston', 'Montreal'\n", "(schedule\n", " .filter(\n", " ((pl.col('home_team') == A) & (pl.col('away_team') == B)) |\n", " ((pl.col('home_team') == B) & (pl.col('away_team') == A))\n", " )\n", " .select(['game_date', 'home_team', 'home_score',\n", " 'away_score', 'away_team', 'winner', 'game_status']))" ] }, { "cell_type": "markdown", "id": "pwhl-recipe12-md", "metadata": {}, "source": [ "### Recipe 12 β€” Find a player, then pull her career lines πŸ›°οΈπŸ”Ž\n", "\n", "A classic two-step lookup off the live feed: [`pwhl_player_search`](../pwhl/reference/additional.md#pwhl_player_search) resolves a name to a `player_id`, then [`pwhl_player_stats`](../pwhl/reference/additional.md#pwhl_player_stats) returns her season-by-season stat lines. Both are `safe()`-wrapped for offseason resilience." ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-recipe12", "metadata": {}, "outputs": [], "source": [ "hit = safe('player search: Spooner', lambda: pwhl.pwhl_player_search('Spooner'))\n", "if hit is not None and getattr(hit, 'height', 0):\n", " pid = int(hit['player_id'][0])\n", " career = safe(f'player stats {pid}', lambda: pwhl.pwhl_player_stats(player_id=pid))\n", " if career is not None and career.height:\n", " keep = [c for c in ['season_name', 'team_code', 'games_played',\n", " 'goals', 'assists', 'points', 'points_per_game']\n", " if c in career.columns]\n", " out = career.select(keep)\n", " else:\n", " out = 'player stats feed unavailable right now'\n", "else:\n", " out = 'player search feed unavailable right now'\n", "out" ] }, { "cell_type": "markdown", "id": "pwhl-recipe13-md", "metadata": {}, "source": [ "### Recipe 13 β€” A team, its roster, and a game's PBP + Corsi πŸ›°οΈπŸ“ˆ\n", "\n", "The full live tour. List teams with [`pwhl_teams`](../pwhl/reference/additional.md#pwhl_teams), grab a `team_id`, pull the roster with [`pwhl_team_roster`](../pwhl/reference/additional.md#pwhl_team_roster), take a `game_id` from the loader schedule, then fetch enriched events with [`pwhl_pbp`](../pwhl/reference/additional.md#pwhl_pbp) and shot-attempt share with [`pwhl_game_corsi`](../pwhl/reference/additional.md#pwhl_game_corsi) β€” all from the same feed. Everything is `safe()`-wrapped, so offline this prints a friendly note instead of raising." ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-recipe13-roster", "metadata": {}, "outputs": [], "source": [ "teams = safe('PWHL teams', lambda: pwhl.pwhl_teams(season=2024))\n", "if teams is not None and teams.height:\n", " tid = int(teams['team_id'][0])\n", " roster = safe(f'PWHL roster {tid}', lambda: pwhl.pwhl_team_roster(team_id=tid, season=2024))\n", " out = (roster.select([c for c in ['first_name', 'last_name', 'position', 'jersey_number']\n", " if c in roster.columns]).head()\n", " if roster is not None else teams.head())\n", "else:\n", " out = 'teams feed unavailable right now'\n", "out" ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-recipe13-pbp", "metadata": {}, "outputs": [], "source": [ "# A game_id from the loader schedule (offline-safe), then enrich it live.\n", "gid = int(schedule['game_id'][0])\n", "pbp_live = safe(f'PWHL pbp {gid}', lambda: pwhl.pwhl_pbp(game_id=gid))\n", "corsi = safe(f'PWHL corsi {gid}', lambda: pwhl.pwhl_game_corsi(game_id=gid))\n", "print('live pbp rows:', None if pbp_live is None else pbp_live.height,\n", " '| corsi rows:', None if corsi is None else corsi.height)" ] }, { "cell_type": "markdown", "id": "pwhl-live-md", "metadata": {}, "source": [ "## πŸ›°οΈ Live standings & leaders\n", "\n", "Straight off the HockeyTech feed: [`pwhl_standings`](../pwhl/reference/additional.md#pwhl_standings) for the live table and [`pwhl_leaders`](../pwhl/reference/additional.md#pwhl_leaders) for the statistical leaderboard. Both take a `season` end-year. We keep them `safe()`-wrapped because live endpoints are seasonal." ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-live-standings", "metadata": {}, "outputs": [], "source": [ "standings = safe('PWHL standings', lambda: pwhl.pwhl_standings(season=2024))\n", "if standings is not None and standings.height:\n", " keep = [c for c in ['team', 'team_code', 'games_played', 'wins', 'losses', 'points']\n", " if c in standings.columns]\n", " out = standings.select(keep).head(10)\n", "else:\n", " out = 'standings feed unavailable right now'\n", "out" ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-live-leaders", "metadata": {}, "outputs": [], "source": [ "leaders = safe('PWHL leaders', lambda: pwhl.pwhl_leaders(season=2024))\n", "if leaders is not None and getattr(leaders, 'height', 0):\n", " keep = [c for c in ['rank', 'name', 'team_code', 'stat_formatted', 'type_formatted']\n", " if c in leaders.columns]\n", " out = leaders.select(keep).head(10)\n", "else:\n", " out = 'leaders feed unavailable right now'\n", "out" ] }, { "cell_type": "markdown", "id": "pwhl-analytics-md", "metadata": {}, "source": [ "## πŸ₯… On-ice analytics\n", "\n", "Beyond the box score, three analytics helpers derive advanced metrics from the same shift + play-by-play feed:\n", "\n", "| Function | Metric |\n", "|---|---|\n", "| [`pwhl_game_corsi`](../pwhl/reference/additional.md#pwhl_game_corsi) | Corsi / Fenwick shot-attempt share, with per-60 rates |\n", "| [`pwhl_player_toi`](../pwhl/reference/additional.md#pwhl_player_toi) | summed time-on-ice + shift counts per player |\n", "| [`pwhl_game_shifts`](../pwhl/reference/additional.md#pwhl_game_shifts) | raw shift stints (who's on the ice, when) |\n", "\n", "⚠️ Corsi note: the HockeyTech feed has no *missed-shot* event, so Corsi and Fenwick here are proxies counting shots + blocked shots + goals only (`corsi_includes_missed = False`)." ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-toi", "metadata": {}, "outputs": [], "source": [ "toi = safe(f'PWHL TOI {gid}', lambda: pwhl.pwhl_player_toi(game_id=gid))\n", "if toi is not None and toi.height:\n", " out = (toi.select([c for c in ['first_name', 'last_name', 'toi_seconds', 'num_shifts']\n", " if c in toi.columns])\n", " .sort('toi_seconds', descending=True).head())\n", "else:\n", " out = 'time-on-ice feed unavailable right now'\n", "out" ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-corsi", "metadata": {}, "outputs": [], "source": [ "if corsi is not None and corsi.height:\n", " out = (corsi\n", " .with_columns((pl.col('corsi_for') - pl.col('corsi_against')).alias('corsi_net'))\n", " .select([c for c in ['player_id', 'corsi_for', 'corsi_against', 'corsi_net', 'corsi_for_per60']\n", " if c in corsi.columns])\n", " .sort('corsi_for_per60', descending=True)\n", " .head())\n", "else:\n", " out = 'corsi feed unavailable right now'\n", "out" ] }, { "cell_type": "markdown", "id": "pwhl-scoring-md", "metadata": {}, "source": [ "## ✨ Bonus: tidy goal log + pandas interop\n", "\n", "[`load_pwhl_scoring_summary`](../pwhl/reference/loaders.md#load_pwhl_scoring_summary) is a clean per-goal log β€” scorer plus up to two assists, with situation flags like power play, short handed, and game-winning. And because every loader takes `return_as_pandas=True`, dropping into the pandas world is one keyword away." ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-scoring", "metadata": {}, "outputs": [], "source": [ "scoring = pwhl.load_pwhl_scoring_summary(seasons=[2024])\n", "scoring.select([\n", " 'game_id', 'period', 'time', 'team_abbr',\n", " 'scorer_first', 'scorer_last', 'is_power_play', 'is_game_winning',\n", "]).head()" ] }, { "cell_type": "code", "execution_count": null, "id": "pwhl-pandas", "metadata": {}, "outputs": [], "source": [ "# Same skater box, but as a pandas DataFrame β€” group with the pandas API.\n", "skater_pd = pwhl.load_pwhl_skater_box(seasons=[2024], return_as_pandas=True)\n", "print('type:', type(skater_pd).__name__, '| shape:', skater_pd.shape)\n", "(skater_pd\n", " .groupby(['first_name', 'last_name'], as_index=False)['points'].sum()\n", " .sort_values('points', ascending=False)\n", " .head(10))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## ⏱️ Shifts, strength state, and shot-level xG\n", "\n", "two published PWHL dataset releases. `load_pwhl_shifts` is the\n", "shift-chart table backing the real on-ice `strength_state` (EV/PP/SH), and\n", "`load_pwhl_xg_pbp` is the play-by-play enriched with shot-level,\n", "coordinate-based expected goals:" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "from sportsdataverse.pwhl import load_pwhl_shifts, load_pwhl_xg_pbp\n", "\n", "shifts = load_pwhl_shifts(seasons=[2025])\n", "xg = load_pwhl_xg_pbp(seasons=[2025])\n", "print(\"shifts:\", shifts.shape, \"| xg pbp:\", xg.shape)\n", "xg.select([\"game_id\", \"event_type\", \"shot_distance\", \"shot_angle\", \"xg\"]).drop_nulls(\"xg\").head()" ] }, { "cell_type": "markdown", "id": "pwhl-next", "metadata": {}, "source": [ "## πŸŽ‰ Where to next\n", "\n", "- πŸ“¦ **Loaders** are your offline-friendly workhorses β€” stack seasons with `seasons=[2024, 2025]` and pass `return_as_pandas=True` for pandas.\n", "- πŸ›°οΈ **Live wrappers** (`pwhl_*`) pull fresh data and add analytics (Corsi, TOI, shifts) β€” no key required.\n", "- Full reference: the **PWHL β†’ [Loaders](../pwhl/reference/loaders.md)** and **[Additional functions](../pwhl/reference/additional.md)** pages in the sidebar.\n", "- Junior & minor hockey? The same HockeyTech surface powers the AHL / OHL / WHL / QMJHL β€” see `11_junior_hockey_intro.ipynb`.\n", "- The men's game and the modern NHL APIs live in `07_nhl_intro.ipynb`.\n", "- R user? The same data lives in [fastRhockey](https://fastRhockey.sportsdataverse.org) (NHL + PWHL).\n", "\n", "Now go tell the story of the PWHL β€” the data's all here. πŸ’πŸ’œ" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "name": "python" } }, "nbformat": 4, "nbformat_minor": 5 }