From a49959867dc778216c97ac469707279ff3e5dd81 Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 20 Jul 2026 21:53:32 -0400 Subject: [PATCH] Statcast: take the full arsenal, not each pitcher's primary pitch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj --- src/services/adapters/statcastAdapter.js | 78 +++++++++++++++++++++--- tests/unit/statcastAggregate.test.js | 20 +++++- 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/src/services/adapters/statcastAdapter.js b/src/services/adapters/statcastAdapter.js index 70a167e..0a61dda 100644 --- a/src/services/adapters/statcastAdapter.js +++ b/src/services/adapters/statcastAdapter.js @@ -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, }, }; diff --git a/tests/unit/statcastAggregate.test.js b/tests/unit/statcastAggregate.test.js index 6cdd628..807da90 100644 --- a/tests/unit/statcastAggregate.test.js +++ b/tests/unit/statcastAggregate.test.js @@ -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