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:
@@ -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