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_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`,
|
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`,
|
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 = {}) {
|
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 MOVEMENT — one row per pitcher (their PRIMARY pitch). Supplies velo,
|
||||||
* pitch-mix array per pitcher, sorted by usage. Also the only feed carrying
|
* break and, uniquely, HANDEDNESS (`pitch_hand`). Not the arsenal: see
|
||||||
* HANDEDNESS (`pitch_hand`), so it doubles as the pitcher-handedness source.
|
* indexArsenal.
|
||||||
*/
|
*/
|
||||||
function indexPitchMix(rows) {
|
function indexPitchMix(rows) {
|
||||||
const out = new Map();
|
const out = new Map();
|
||||||
@@ -169,6 +175,60 @@ function indexPitchMix(rows) {
|
|||||||
return out;
|
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.
|
* 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
|
* 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('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 {
|
return {
|
||||||
@@ -198,10 +259,11 @@ async function fetchSeason(season = DEFAULT_SEASON, opts = {}) {
|
|||||||
batterBattedBall: indexBy(bbb, BATTED_BALL),
|
batterBattedBall: indexBy(bbb, BATTED_BALL),
|
||||||
pitcherDiscipline: indexBy(pd, PITCHER_DISCIPLINE),
|
pitcherDiscipline: indexBy(pd, PITCHER_DISCIPLINE),
|
||||||
pitcherBattedBall: indexBy(pbb, BATTED_BALL),
|
pitcherBattedBall: indexBy(pbb, BATTED_BALL),
|
||||||
pitchMix: indexPitchMix(mv),
|
pitchMix: indexArsenal(ars, indexPitchMix(mv)),
|
||||||
counts: {
|
counts: {
|
||||||
batter_discipline: bd.length, batter_batted_ball: bbb.length,
|
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,
|
DEFAULT_SEASON,
|
||||||
FEEDS,
|
FEEDS,
|
||||||
__internals: {
|
__internals: {
|
||||||
flipName, idOf, normalizeRow, indexBy, indexPitchMix,
|
flipName, idOf, normalizeRow, indexBy, indexPitchMix, indexArsenal,
|
||||||
BATTER_DISCIPLINE, BATTED_BALL, PITCHER_DISCIPLINE, fetchText,
|
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;
|
const { flipName, indexBy, indexPitchMix, BATTER_DISCIPLINE } = adapter.__internals;
|
||||||
|
|
||||||
/** Minimal feed indexes, shaped exactly like adapter.fetchSeason() output. */
|
/** 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 {
|
return {
|
||||||
batterDiscipline: indexBy(batter, BATTER_DISCIPLINE),
|
batterDiscipline: indexBy(batter, BATTER_DISCIPLINE),
|
||||||
batterBattedBall: indexBy(batterBB, adapter.__internals.BATTED_BALL),
|
batterBattedBall: indexBy(batterBB, adapter.__internals.BATTED_BALL),
|
||||||
pitcherDiscipline: indexBy(pitcher, adapter.__internals.PITCHER_DISCIPLINE),
|
pitcherDiscipline: indexBy(pitcher, adapter.__internals.PITCHER_DISCIPLINE),
|
||||||
pitcherBattedBall: indexBy(pitcherBB, adapter.__internals.BATTED_BALL),
|
pitcherBattedBall: indexBy(pitcherBB, adapter.__internals.BATTED_BALL),
|
||||||
pitchMix: indexPitchMix(mix),
|
pitchMix: adapter.__internals.indexArsenal(arsenal, indexPitchMix(mix)),
|
||||||
counts: {},
|
counts: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -45,6 +45,22 @@ describe('adapter normalisation', () => {
|
|||||||
expect(flipName('')).toBeNull();
|
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', () => {
|
it('normalises pitch usage from a FRACTION to a percentage', () => {
|
||||||
const [entry] = [...indexPitchMix([SKUBAL_MIX]).values()];
|
const [entry] = [...indexPitchMix([SKUBAL_MIX]).values()];
|
||||||
expect(entry.pitches[0].usage_pct).toBe(37.1); // not 0.371
|
expect(entry.pitches[0].usage_pct).toBe(37.1); // not 0.371
|
||||||
|
|||||||
Reference in New Issue
Block a user