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:
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user