Skip to main content

PWHL: shots, time on ice and on-ice Corsi from HockeyTech

What you'll build: a shot log with the league's canvas coordinates, then a per-skater Corsi table (shot attempts for and against while on the ice, with Fenwick and per-60 rates) for one PWHL game — from three HockeyTech feeds the package fetches and combines for you.

Sources used​

SourceHostCall
HockeyTech / LeagueStatlscluster.hockeytech.com/feed/index.php?feed=statviewfeed&view=gameCenterPlayByPlaysdv.hockeytech.pwhl_pbp(42)
HockeyTech / LeagueStat…?feed=modulekit&view=gameshifts and …?feed=gc&tab=gamesummarysdv.hockeytech.pwhl_player_toi(42), pwhl_game_corsi(42)

Offline fixtures: test/fixtures/hockeytech/analytics/pwhl_{pbp,gameshifts,game_summary}_42.json (PWHL game 42; the analytics are checked against sdv-py's oracle on these files).

One gateway, twenty leagues​

HockeyTech serves the PWHL, AHL, OHL, WHL, QMJHL and fifteen junior and minor leagues from one feed gateway. Each league has its own client code, site id and public key (shipped in its website's JavaScript); the runtime injects them, strips the JSONP envelope (angular.callbacks._N(...)), switches the gc feed to its tab= quirk, and turns HockeyTech's HTTP-200 error bodies into real errors. The league-parameterised form is sdv.hockeytech.hockeytech_pbp; every league also gets pinned functions — pwhl_pbp, ohl_pbp, ahl_pbp, … — and these take the game id as a positional argument, as fastRhockey does.

The analytics layer​

Three functions are ported from sdv-py's hockeytech._analytics (originally fastRhockey):

  • pwhl_pbp(game_id) — the parsed play-by-play: event (shot, goal, penalty, faceoff, goalie_change, …), period and clock, team and player ids, shot_quality, and x_coord / y_coord on the league's canvas. PWHL uses the 850×400 canvas (hockeytech_a), top-left origin; other leagues use 600×300 (hockeytech_b). @sportsdataverse/sporty ships both as frames (hockeytech-a / hockeytech-b) to map onto a rink.
  • pwhl_player_toi(game_id) — folds the shift chart into one row per player: toi_seconds, num_shifts, avg_shift_s, with first and last names.
  • pwhl_game_corsi(game_id) — for every shot attempt, credits the skaters on the ice at that clock reading (from the shifts) with a Corsi event for or against. Output per player: corsi_for / corsi_against / corsi_for_pct, the Fenwick equivalents (unblocked attempts), toi_seconds and corsi_for_per60. corsi_includes_missed tells you whether missed shots were in the feed — the PWHL feed reports shots on goal and goals only, so Corsi and Fenwick differ only by blocked shots here.

The script joins Corsi to names through the TOI frame (both key on the string player_id) and filters to skaters with ten or more minutes so the percentages mean something.

The script​

examples/09_hockeytech_pwhl_corsi.mjs
// 09 — PWHL (HockeyTech): play-by-play, time on ice and on-ice Corsi for one game.
//
// Shows: the HockeyTech family's analytics layer. One feed gateway serves 20
// leagues; `sdv.hockeytech.pwhl_*` pins the PWHL. `pwhl_pbp` is the parsed
// gameCenterPlayByPlay (shots carry x_coord / y_coord on the league's canvas),
// `pwhl_player_toi` folds the shift chart into per-player TOI, and
// `pwhl_game_corsi` credits every shot attempt to the skaters on the ice
// (ported from sdv-py's hockeytech `_analytics`). Three feeds, one game id.
//
// Sources: HockeyTech — lscluster.hockeytech.com/feed/index.php
// feed=statviewfeed&view=gameCenterPlayByPlay, feed=modulekit&view=gameshifts, feed=gc&tab=gamesummary
// Offline fixtures: test/fixtures/hockeytech/analytics/pwhl_{pbp,gameshifts,game_summary}_42.json
// (PWHL game 42, sdv-py analytics oracle captures).

import sdv from 'sportsdataverse';
import { setup } from './_offline.mjs';
import { printTable, round } from './_util.mjs';

setup();

const GAME_ID = 42;

const pbp = await sdv.hockeytech.pwhl_pbp(GAME_ID);
const shots = pbp.filter((p) => p.event === 'shot' || p.event === 'goal');
console.log(`${pbp.length} events, ${shots.length} shots/goals`);
printTable(
shots.map((p) => ({ period: p.period_of_game, time: p.time_of_period, team_id: p.team_id, shooter: `${p.player_name_first ?? ''} ${p.player_name_last ?? ''}`.trim(), event: p.event, quality: p.shot_quality, x: p.x_coord, y: p.y_coord })),
['period', 'time', 'team_id', 'shooter', 'event', 'quality', 'x', 'y'],
8,
'Shot attempts (pwhl_pbp)'
);

const toi = await sdv.hockeytech.pwhl_player_toi(GAME_ID);
const corsi = await sdv.hockeytech.pwhl_game_corsi(GAME_ID);
const names = new Map(toi.map((t) => [t.player_id, `${t.first_name} ${t.last_name}`]));

printTable(
corsi
.filter((c) => c.toi_seconds >= 600) // skaters with 10+ minutes
.map((c) => ({
player: names.get(c.player_id) ?? c.player_id,
toi: `${Math.floor(c.toi_seconds / 60)}:${String(c.toi_seconds % 60).padStart(2, '0')}`,
cf: c.corsi_for,
ca: c.corsi_against,
cf_pct: round(c.corsi_for_pct, 3),
ff_pct: round(c.fenwick_for_pct, 3),
cf_per60: round(c.corsi_for_per60, 1),
}))
.sort((a, b) => b.cf_pct - a.cf_pct),
['player', 'toi', 'cf', 'ca', 'cf_pct', 'ff_pct', 'cf_per60'],
10,
'On-ice Corsi, skaters with 10+ minutes (pwhl_game_corsi + pwhl_player_toi)'
);

Opens a Node sandbox in a new tab with this script as index.mjs; runs live (no API key for ESPN).

Output​

Output of node examples/09_hockeytech_pwhl_corsi.mjs (offline, against the committed fixtures):

173 events, 70 shots/goals

## Shot attempts (pwhl_pbp)
| period | time | team_id | shooter | event | quality | x | y |
| ------ | ---- | ------- | ------------------- | ----- | ------------------ | --- | --- |
| 1 | 3:12 | 1 | Jamie Lee Rattray | shot | Quality on net | 468 | 209 |
| 1 | 3:40 | 3 | Mélodie Daoust | shot | Non quality on net | 31 | 187 |
| 1 | 3:51 | 3 | Mélodie Daoust | shot | Non quality on net | 159 | 156 |
| 1 | 3:51 | 3 | Marie-Philip Poulin | shot | Quality goal | 90 | 123 |
| 1 | 3:51 | 3 | Marie-Philip Poulin | goal | | 90 | 123 |
| 1 | 4:38 | 1 | Hilary Knight | shot | Quality on net | 530 | 131 |
| 1 | 5:12 | 1 | Abby Cook | shot | Non quality on net | 339 | 276 |
| 1 | 5:45 | 1 | Jamie Lee Rattray | shot | Quality on net | 515 | 189 |
(70 rows, first 8 shown)

## On-ice Corsi, skaters with 10+ minutes (pwhl_game_corsi + pwhl_player_toi)
| player | toi | cf | ca | cf_pct | ff_pct | cf_per60 |
| ------------------- | ----- | -- | -- | ------ | ------ | -------- |
| Abby Cook | 13:11 | 14 | 4 | 0.778 | 0.733 | 63.7 |
| Tereza Vanišová | 18:38 | 17 | 8 | 0.68 | 0.684 | 54.7 |
| Catherine Dubois | 18:51 | 20 | 10 | 0.667 | 0.64 | 63.7 |
| Sophie Shirley | 10:01 | 14 | 7 | 0.667 | 0.538 | 83.9 |
| Laura Stacey | 21:06 | 27 | 14 | 0.659 | 0.767 | 76.8 |
| Marie-Philip Poulin | 20:47 | 26 | 14 | 0.65 | 0.71 | 75.1 |
| Hilary Knight | 20:24 | 18 | 12 | 0.6 | 0.524 | 52.9 |
| Susanna Tapani | 22:06 | 18 | 12 | 0.6 | 0.571 | 48.9 |
| Mélodie Daoust | 17:48 | 17 | 12 | 0.586 | 0.684 | 57.3 |
| Loren Gabel | 13:43 | 12 | 9 | 0.571 | 0.533 | 52.5 |
(34 rows, first 10 shown)

Variations​

  • pwhl_schedule(season), pwhl_standings, pwhl_team_roster, pwhl_leaders and the rest of the 16-endpoint surface are on the same namespace.
  • Some junior feeds are partial: USHL and MJHL gamecenter ships goals, penalties and goalie changes without coordinates, and MJHL denies the game summary, so mjhl_game_corsi returns an empty frame rather than guessing.
  • Override a league's key with SDV_<LEAGUE>_API_KEY if a site rotates it.

Next steps​