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