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
| Source | Host | Call |
|---|---|---|
| HockeyTech / LeagueStat | lscluster.hockeytech.com/feed/index.php?feed=statviewfeed&view=gameCenterPlayByPlay | sdv.hockeytech.pwhl_pbp(42) |
| HockeyTech / LeagueStat | …?feed=modulekit&view=gameshifts and …?feed=gc&tab=gamesummary | sdv.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, andx_coord/y_coordon the league's canvas. PWHL uses the 850×400 canvas (hockeytech_a), top-left origin; other leagues use 600×300 (hockeytech_b).@sportsdataverse/sportyships 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_secondsandcorsi_for_per60.corsi_includes_missedtells 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
// 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_leadersand 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_corsireturns an empty frame rather than guessing. - Override a league's key with
SDV_<LEAGUE>_API_KEYif a site rotates it.
Next steps
- NHL: api-web and EDGE — the NHL's own feeds.
- Shot chart with sdvplot + sporty — the surface-mapping idea, on a court.