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
+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