Statcast: take the full arsenal, not each pitcher's primary pitch

Caught by spot-checking a real row after the backfill landed: Skubal stored
with one pitch. The pitch-movement endpoint with an empty pitch_type returns
ONE row per pitcher — their primary offering — so 677 rows for ~700 pitchers,
and a five-pitch arsenal was being recorded as a one-pitch one. Not a
fabrication, but a silent under-representation of the single most important
pitcher-mechanism field, which is worse than useless for Layer 2: it would have
classified every pitcher as a one-pitch arm.

Mix now comes from pitch-arsenal-stats (3,205 rows = pitcher x pitch type)
carrying usage%, whiff%, K%, put-away% and run value per 100 for every pitch.
Movement still supplies velo, break and handedness, folded onto the primary
pitch; a pitcher present only in the movement feed keeps his handedness and his
one measured pitch rather than being dropped. Velo on non-primary pitches is
null — absent, not guessed.

Skubal now stores 5 pitches, throws L, FF first by usage with velo 96.7.

Tests 3583 passed / 292 suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
This commit is contained in:
Kev
2026-07-20 21:53:32 -04:00
parent a011ae79fe
commit a49959867d
2 changed files with 88 additions and 10 deletions
+70 -8
View File
@@ -55,6 +55,12 @@ const FEEDS = Object.freeze({
pitcher_discipline: (y) => `${BASE}/custom?year=${y}&type=pitcher&filter=&min=1&selections=p_formatted_ip,k_percent,bb_percent,whiff_percent,swing_percent,oz_swing_percent,groundballs_percent,flyballs_percent,linedrives_percent,barrel_batted_rate,hard_hit_percent,arm_angle&chart=false&x=p_formatted_ip&y=p_formatted_ip&r=no&chartType=beeswarm&sort=p_formatted_ip&sortDir=desc&csv=true`,
pitcher_batted_ball: (y) => `${BASE}/statcast?type=pitcher&year=${y}&position=&team=&min=1&csv=true`,
pitch_movement: (y) => `${BASE}/pitch-movement?year=${y}&team=&min=1&pitch_type=&hand=&csv=true`,
// The FULL arsenal: one row per (pitcher, pitch type) — 3,205 rows vs the
// movement feed's 677. The movement endpoint with an empty pitch_type returns
// each pitcher's PRIMARY pitch only, so using it alone would silently record
// a five-pitch arsenal as a one-pitch one. Movement still supplies velo,
// break and handedness; this supplies the mix.
pitch_arsenal: (y) => `${BASE}/pitch-arsenal-stats?type=pitcher&pitchType=&year=${y}&min=1&csv=true`,
});
async function fetchText(url, opts = {}) {
@@ -133,9 +139,9 @@ function indexBy(rows, fields) {
}
/**
* Pitch movement is LONG (one row per pitcher × pitch type). Collapses to a
* pitch-mix array per pitcher, sorted by usage. Also the only feed carrying
* HANDEDNESS (`pitch_hand`), so it doubles as the pitcher-handedness source.
* Pitch MOVEMENT — one row per pitcher (their PRIMARY pitch). Supplies velo,
* break and, uniquely, HANDEDNESS (`pitch_hand`). Not the arsenal: see
* indexArsenal.
*/
function indexPitchMix(rows) {
const out = new Map();
@@ -169,6 +175,60 @@ function indexPitchMix(rows) {
return out;
}
/**
* Pitch ARSENAL — one row per (pitcher, pitch type). The real mix: usage%,
* whiff%, K% and contact quality per pitch. Merged with the movement feed's
* velo/break for the pitcher's primary pitch.
*/
function indexArsenal(rows, movementIdx) {
const out = new Map();
for (const r of rows) {
const id = idOf(r);
if (id == null) continue;
const type = (r.pitch_type || '').trim();
if (!type) continue;
const entry = out.get(id) || { id, name: flipName(r['last_name, first_name']), throws: null, pitches: [] };
entry.pitches.push({
type,
name: (r.pitch_name || '').trim() || null,
usage_pct: numOrNull(r.pitch_usage),
thrown: numOrNull(r.pitches),
whiff_pct: numOrNull(r.whiff_percent),
k_pct: numOrNull(r.k_percent),
put_away_pct: numOrNull(r.put_away),
hard_hit_pct: numOrNull(r.hard_hit_percent),
run_value_per_100: numOrNull(r.run_value_per_100),
velo: null,
break_z_induced: null,
break_x: null,
});
out.set(id, entry);
}
// Fold in velo/break (primary pitch only — that is all the feed carries) and
// handedness, which exists on no other feed.
for (const [id, entry] of out) {
const mv = movementIdx.get(id);
if (!mv) continue;
entry.throws = mv.throws || entry.throws;
const primary = mv.pitches && mv.pitches[0];
if (primary) {
const match = entry.pitches.find((p) => p.type === primary.type);
if (match) {
match.velo = primary.velo;
match.break_z_induced = primary.break_z_induced;
match.break_x = primary.break_x;
}
}
}
// A pitcher who appears ONLY in the movement feed still gets his handedness
// and his one measured pitch — absent beats dropping him.
for (const [id, mv] of movementIdx) {
if (!out.has(id)) out.set(id, mv);
}
for (const e of out.values()) e.pitches.sort((a, b) => (b.usage_pct ?? 0) - (a.usage_pct ?? 0));
return out;
}
/**
* fetchSeason(season, opts) — pull all five feeds and return normalized indexes.
* Any single feed failing degrades to an EMPTY index for that feed (its metrics
@@ -187,9 +247,10 @@ async function fetchSeason(season = DEFAULT_SEASON, opts = {}) {
}
};
const [bd, bbb, pd, pbb, mv] = await Promise.all([
const [bd, bbb, pd, pbb, mv, ars] = await Promise.all([
get('batter_discipline'), get('batter_batted_ball'),
get('pitcher_discipline'), get('pitcher_batted_ball'), get('pitch_movement'),
get('pitcher_discipline'), get('pitcher_batted_ball'),
get('pitch_movement'), get('pitch_arsenal'),
]);
return {
@@ -198,10 +259,11 @@ async function fetchSeason(season = DEFAULT_SEASON, opts = {}) {
batterBattedBall: indexBy(bbb, BATTED_BALL),
pitcherDiscipline: indexBy(pd, PITCHER_DISCIPLINE),
pitcherBattedBall: indexBy(pbb, BATTED_BALL),
pitchMix: indexPitchMix(mv),
pitchMix: indexArsenal(ars, indexPitchMix(mv)),
counts: {
batter_discipline: bd.length, batter_batted_ball: bbb.length,
pitcher_discipline: pd.length, pitcher_batted_ball: pbb.length, pitch_movement: mv.length,
pitcher_discipline: pd.length, pitcher_batted_ball: pbb.length,
pitch_movement: mv.length, pitch_arsenal: ars.length,
},
};
}
@@ -211,7 +273,7 @@ module.exports = {
DEFAULT_SEASON,
FEEDS,
__internals: {
flipName, idOf, normalizeRow, indexBy, indexPitchMix,
flipName, idOf, normalizeRow, indexBy, indexPitchMix, indexArsenal,
BATTER_DISCIPLINE, BATTED_BALL, PITCHER_DISCIPLINE, fetchText,
},
};
+18 -2
View File
@@ -11,13 +11,13 @@ const adapter = require('../../src/services/adapters/statcastAdapter');
const { flipName, indexBy, indexPitchMix, BATTER_DISCIPLINE } = adapter.__internals;
/** Minimal feed indexes, shaped exactly like adapter.fetchSeason() output. */
function feeds({ batter = [], batterBB = [], pitcher = [], pitcherBB = [], mix = [] } = {}) {
function feeds({ batter = [], batterBB = [], pitcher = [], pitcherBB = [], mix = [], arsenal = [] } = {}) {
return {
batterDiscipline: indexBy(batter, BATTER_DISCIPLINE),
batterBattedBall: indexBy(batterBB, adapter.__internals.BATTED_BALL),
pitcherDiscipline: indexBy(pitcher, adapter.__internals.PITCHER_DISCIPLINE),
pitcherBattedBall: indexBy(pitcherBB, adapter.__internals.BATTED_BALL),
pitchMix: indexPitchMix(mix),
pitchMix: adapter.__internals.indexArsenal(arsenal, indexPitchMix(mix)),
counts: {},
};
}
@@ -45,6 +45,22 @@ describe('adapter normalisation', () => {
expect(flipName('')).toBeNull();
});
it('takes the FULL arsenal from the per-pitch feed, not the primary-only one', () => {
// The movement endpoint returns ONE row per pitcher (their primary pitch),
// so using it alone silently recorded a five-pitch arsenal as one pitch.
const ARS = ['FF', 'CH', 'SI', 'SL', 'CU'].map((t, i) => ({
'last_name, first_name': 'Skubal, Tarik', player_id: '669373',
pitch_type: t, pitch_name: t, pitch_usage: String(40 - i * 7), whiff_percent: '25',
}));
const idx = adapter.__internals.indexArsenal(ARS, indexPitchMix([SKUBAL_MIX]));
const e = idx.get(669373);
expect(e.pitches).toHaveLength(5);
expect(e.throws).toBe('L'); // handedness from movement
expect(e.pitches[0].type).toBe('FF'); // sorted by usage
expect(e.pitches[0].velo).toBe(96.7); // velo folded in
expect(e.pitches[1].velo).toBeNull(); // absent, not guessed
});
it('normalises pitch usage from a FRACTION to a percentage', () => {
const [entry] = [...indexPitchMix([SKUBAL_MIX]).values()];
expect(entry.pitches[0].usage_pct).toBe(37.1); // not 0.371