Codex commited on
Commit
f44fbff
·
1 Parent(s): 698d79c

Fix hosted pitcher chart resolution

Browse files
Files changed (2) hide show
  1. src/matchups.js +60 -20
  2. test/matchups.test.js +179 -0
src/matchups.js CHANGED
@@ -829,6 +829,25 @@ function resolveHostedPlayerName(row, rosterLookup, type = 'hitter') {
829
  return rawName || rosterName || null;
830
  }
831
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
832
  function withDefaults(row, slateLookup, options = {}) {
833
  const team = String(row.team ?? '').trim();
834
  const slate = slateLookup.get(team) ?? {};
@@ -843,11 +862,11 @@ function withDefaults(row, slateLookup, options = {}) {
843
  pitcher_name: playerType === 'pitcher'
844
  ? resolveHostedPlayerName(row, rosterLookup, 'pitcher')
845
  : row.pitcher_name,
846
- game_pk: row.game_pk ?? slate.gamePk ?? null,
847
- opponent_team: row.opponent_team ?? slate.opponentTeam ?? null,
848
- opposing_pitcher_id: row.opposing_pitcher_id ?? slate.opposingPitcherId ?? null,
849
- opposing_pitcher_name: row.opposing_pitcher_name ?? slate.opposingPitcherName ?? null,
850
- opposing_pitcher_hand: row.opposing_pitcher_hand ?? slate.opposingPitcherHand ?? null,
851
  };
852
  }
853
 
@@ -2312,14 +2331,27 @@ export class MatchupService {
2312
  throw new Error(`No hitter matchup profile matched "${options.player}".`);
2313
  }
2314
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2315
  const batterMap = aggregateBatterZoneMap(selectBatterZoneRows(
2316
  batterZoneRows,
2317
  String(hitterMatch.batter ?? hitterMatch.player_id ?? ''),
2318
- hitterMatch.opposing_pitcher_hand
2319
  ));
2320
  const pitcherMap = aggregatePitcherZoneMap(selectPitcherZoneRows(
2321
  pitcherZoneRows,
2322
- String(hitterMatch.opposing_pitcher_id ?? ''),
2323
  hitterMatch.stand
2324
  ));
2325
  const overlay = buildZoneOverlayMap(batterMap, pitcherMap);
@@ -2343,8 +2375,8 @@ export class MatchupService {
2343
  resolvedDate,
2344
  playerName: hitterMatch.hitter_name,
2345
  team: hitterMatch.team,
2346
- opposingPitcherName: hitterMatch.opposing_pitcher_name,
2347
- opposingPitcherHand: hitterMatch.opposing_pitcher_hand,
2348
  zone_fit_score: hitterMatch.zone_fit_score ?? overlayZoneFitScore(batterMap, pitcherMap),
2349
  cells,
2350
  bestOverlay: insights.bestOverlay,
@@ -2686,20 +2718,28 @@ export class MatchupService {
2686
  });
2687
 
2688
  const pitcherMatch = findBestPlayerMatch(base, 'pitcher_name', playerName);
2689
- if (!pitcherMatch) {
2690
- throw new Error(`No pitcher matchup profile matched "${playerName}".`);
 
 
 
 
 
 
 
 
 
2691
  }
2692
 
2693
- return {
2694
- source: 'hosted',
2695
- resolvedDate,
2696
  playerType: 'pitcher',
2697
- name: pitcherMatch.pitcher_name,
2698
- team: pitcherMatch.team ?? null,
2699
- opponentTeam: pitcherMatch.opponent_team ?? null,
2700
- hand: pitcherMatch.p_throws ?? null,
2701
- overview: pitcherMatch,
2702
- };
2703
  }
2704
 
2705
  async enrichHostedHitters(result, options = {}, methodName = 'getTopHitters') {
 
829
  return rawName || rosterName || null;
830
  }
831
 
832
+ function firstNonBlankValue(...values) {
833
+ for (const value of values) {
834
+ if (value === null || value === undefined) {
835
+ continue;
836
+ }
837
+ if (typeof value === 'string') {
838
+ const trimmed = value.trim();
839
+ if (trimmed) {
840
+ return trimmed;
841
+ }
842
+ continue;
843
+ }
844
+ if (value !== '') {
845
+ return value;
846
+ }
847
+ }
848
+ return null;
849
+ }
850
+
851
  function withDefaults(row, slateLookup, options = {}) {
852
  const team = String(row.team ?? '').trim();
853
  const slate = slateLookup.get(team) ?? {};
 
862
  pitcher_name: playerType === 'pitcher'
863
  ? resolveHostedPlayerName(row, rosterLookup, 'pitcher')
864
  : row.pitcher_name,
865
+ game_pk: firstNonBlankValue(row.game_pk, slate.gamePk),
866
+ opponent_team: firstNonBlankValue(row.opponent_team, slate.opponentTeam),
867
+ opposing_pitcher_id: firstNonBlankValue(row.opposing_pitcher_id, slate.opposingPitcherId),
868
+ opposing_pitcher_name: firstNonBlankValue(row.opposing_pitcher_name, slate.opposingPitcherName),
869
+ opposing_pitcher_hand: firstNonBlankValue(row.opposing_pitcher_hand, slate.opposingPitcherHand),
870
  };
871
  }
872
 
 
2331
  throw new Error(`No hitter matchup profile matched "${options.player}".`);
2332
  }
2333
 
2334
+ const hitterSlate = slateLookup.get(String(hitterMatch.team ?? '').trim()) ?? {};
2335
+ const opposingPitcherId = String(
2336
+ firstNonBlankValue(hitterMatch.opposing_pitcher_id, hitterSlate.opposingPitcherId) ?? ''
2337
+ ).trim();
2338
+ const opposingPitcherName = firstNonBlankValue(
2339
+ hitterMatch.opposing_pitcher_name,
2340
+ hitterSlate.opposingPitcherName,
2341
+ );
2342
+ const opposingPitcherHand = firstNonBlankValue(
2343
+ hitterMatch.opposing_pitcher_hand,
2344
+ hitterSlate.opposingPitcherHand,
2345
+ );
2346
+
2347
  const batterMap = aggregateBatterZoneMap(selectBatterZoneRows(
2348
  batterZoneRows,
2349
  String(hitterMatch.batter ?? hitterMatch.player_id ?? ''),
2350
+ opposingPitcherHand
2351
  ));
2352
  const pitcherMap = aggregatePitcherZoneMap(selectPitcherZoneRows(
2353
  pitcherZoneRows,
2354
+ opposingPitcherId,
2355
  hitterMatch.stand
2356
  ));
2357
  const overlay = buildZoneOverlayMap(batterMap, pitcherMap);
 
2375
  resolvedDate,
2376
  playerName: hitterMatch.hitter_name,
2377
  team: hitterMatch.team,
2378
+ opposingPitcherName,
2379
+ opposingPitcherHand,
2380
  zone_fit_score: hitterMatch.zone_fit_score ?? overlayZoneFitScore(batterMap, pitcherMap),
2381
  cells,
2382
  bestOverlay: insights.bestOverlay,
 
2718
  });
2719
 
2720
  const pitcherMatch = findBestPlayerMatch(base, 'pitcher_name', playerName);
2721
+ if (pitcherMatch) {
2722
+ return {
2723
+ source: 'hosted',
2724
+ resolvedDate,
2725
+ playerType: 'pitcher',
2726
+ name: pitcherMatch.pitcher_name,
2727
+ team: pitcherMatch.team ?? null,
2728
+ opponentTeam: pitcherMatch.opponent_team ?? null,
2729
+ hand: pitcherMatch.p_throws ?? null,
2730
+ overview: pitcherMatch,
2731
+ };
2732
  }
2733
 
2734
+ const broaderContext = await this.getPlayerContext({
2735
+ ...options,
2736
+ player: playerName,
2737
  playerType: 'pitcher',
2738
+ });
2739
+ if (broaderContext.playerType !== 'pitcher') {
2740
+ throw new Error(`No pitcher matchup profile matched "${playerName}".`);
2741
+ }
2742
+ return broaderContext;
 
2743
  }
2744
 
2745
  async enrichHostedHitters(result, options = {}, methodName = 'getTopHitters') {
test/matchups.test.js CHANGED
@@ -333,6 +333,185 @@ test('matchup service enriches hosted top hitter rows from Cockroach snapshots',
333
  assert.equal(result.rows[2].hitter_name, 'Paul Goldschmidt');
334
  });
335
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
  test('hosted best matchups keeps only the top three hitters per game before ranking the slate board', async () => {
337
  const responses = new Map([
338
  ['https://example.test/daily/2026-04-07/slate.parquet', [
 
333
  assert.equal(result.rows[2].hitter_name, 'Paul Goldschmidt');
334
  });
335
 
336
+ test('hr zone data falls back to the slate probable pitcher when hitter rows leave the pitcher name blank', async () => {
337
+ const responses = new Map([
338
+ ['https://example.test/daily/2026-04-07/slate.parquet', [
339
+ {
340
+ game_pk: 20,
341
+ away_team: 'LAA',
342
+ home_team: 'SEA',
343
+ away_probable_pitcher_id: 501,
344
+ home_probable_pitcher_id: 601,
345
+ away_probable_pitcher: 'Jose Soriano',
346
+ home_probable_pitcher: 'Bryan Woo',
347
+ away_probable_hand: 'R',
348
+ home_probable_hand: 'R',
349
+ },
350
+ ]],
351
+ ['https://example.test/daily/2026-04-07/daily_hitter_metrics.parquet', [
352
+ {
353
+ game_pk: 20,
354
+ team: 'LAA',
355
+ hitter_name: '142',
356
+ batter: 142,
357
+ stand: 'R',
358
+ split_key: 'overall',
359
+ recent_window: 'season',
360
+ weighted_mode: 'weighted',
361
+ opposing_pitcher_id: '',
362
+ opposing_pitcher_name: '',
363
+ opposing_pitcher_hand: 'R',
364
+ zone_fit_score: 0.057,
365
+ xwoba: 0.355,
366
+ swstr_pct: 0.11,
367
+ barrel_bbe_pct: 0.12,
368
+ barrel_bip_pct: 0.10,
369
+ pulled_barrel_pct: 0.08,
370
+ sweet_spot_pct: 0.31,
371
+ fb_pct: 0.27,
372
+ hard_hit_pct: 0.44,
373
+ avg_launch_angle: 16.1,
374
+ },
375
+ ]],
376
+ ['https://example.test/daily/2026-04-07/daily_pitcher_metrics.parquet', [
377
+ { pitcher_id: 601, pitcher_name: 'Bryan Woo', p_throws: 'R', split_key: 'overall', recent_window: 'season', weighted_mode: 'weighted' },
378
+ ]],
379
+ ['https://example.test/daily/2026-04-07/rosters.parquet', [
380
+ { team: 'LAA', player_id: 142, player_name: 'Jorge Soler' },
381
+ ]],
382
+ ['https://example.test/daily/2026-04-07/daily_batter_zone_profiles.parquet', [
383
+ { batter_id: 142, pitcher_hand_key: 'vs_rhp', zone: 5, hit_rate: 0.22, hr_rate: 0.09, sample_size: 40 },
384
+ { batter_id: 142, pitcher_hand_key: 'vs_rhp', zone: 8, hit_rate: 0.18, hr_rate: 0.05, sample_size: 20 },
385
+ ]],
386
+ ['https://example.test/daily/2026-04-07/daily_pitcher_zone_profiles.parquet', [
387
+ { pitcher_id: 601, batter_side_key: 'vs_rhb', zone: 5, usage_rate: 0.25, sample_size: 60 },
388
+ { pitcher_id: 601, batter_side_key: 'vs_rhb', zone: 8, usage_rate: 0.11, sample_size: 30 },
389
+ ]],
390
+ ]);
391
+
392
+ const hosted = new HostedArtifactSource(
393
+ {
394
+ baseUrl: 'https://example.test',
395
+ cacheTtlMs: 60_000,
396
+ fallbackDays: 0,
397
+ },
398
+ {
399
+ readParquetImpl: async (url) => {
400
+ if (!responses.has(url)) {
401
+ throw new Error(`Missing ${url}`);
402
+ }
403
+ return responses.get(url);
404
+ },
405
+ fetchTextImpl: async () => '',
406
+ logger: { debug() {}, warn() {} },
407
+ }
408
+ );
409
+
410
+ const service = new MatchupService(
411
+ { databaseUrl: 'postgresql://test', hosted: { baseUrl: 'https://example.test' } },
412
+ {
413
+ hosted,
414
+ fallback: { async close() {} },
415
+ logger: { debug() {}, warn() {} },
416
+ }
417
+ );
418
+
419
+ const result = await service.getHrZoneData({ date: '2026-04-07', player: 'jorge soler' });
420
+
421
+ assert.equal(result.playerName, 'Jorge Soler');
422
+ assert.equal(result.opposingPitcherName, 'Bryan Woo');
423
+ assert.equal(result.opposingPitcherHand, 'R');
424
+ });
425
+
426
+ test('k profile falls back to broader hosted pitcher context when the daily chart slice misses the pitcher', async () => {
427
+ const responses = new Map([
428
+ ['https://example.test/daily/2026-04-07/slate.parquet', [
429
+ {
430
+ game_pk: 40,
431
+ away_team: 'PIT',
432
+ home_team: 'CHC',
433
+ away_probable_pitcher_id: 301,
434
+ home_probable_pitcher_id: 401,
435
+ away_probable_pitcher: 'Paul Skenes',
436
+ home_probable_pitcher: 'Jameson Taillon',
437
+ away_probable_hand: 'R',
438
+ home_probable_hand: 'R',
439
+ },
440
+ ]],
441
+ ['https://example.test/daily/2026-04-07/daily_hitter_metrics.parquet', []],
442
+ ['https://example.test/daily/2026-04-07/daily_pitcher_metrics.parquet', [
443
+ { pitcher_id: 401, pitcher_name: 'Jameson Taillon', team: 'CHC', opponent_team: 'PIT', p_throws: 'R', split_key: 'overall', recent_window: 'season', weighted_mode: 'weighted' },
444
+ ]],
445
+ ['https://example.test/daily/2026-04-07/rosters.parquet', [
446
+ { team: 'PIT', player_id: 301, player_name: 'Paul Skenes' },
447
+ { team: 'CHC', player_id: 401, player_name: 'Jameson Taillon' },
448
+ ]],
449
+ ['https://example.test/reusable/hitter_metrics.parquet', []],
450
+ ['https://example.test/reusable/pitcher_metrics.parquet', [
451
+ {
452
+ pitcher_id: 301,
453
+ player_id: 301,
454
+ pitcher_name: '301',
455
+ team: 'PIT',
456
+ opponent_team: 'CHC',
457
+ p_throws: 'R',
458
+ split_key: 'overall',
459
+ recent_window: 'season',
460
+ weighted_mode: 'weighted',
461
+ pitcher_score: 92.2,
462
+ strikeout_score: 89.5,
463
+ pitcher_matchup_adjustment: 6.3,
464
+ strikeout_matchup_adjustment: 7.1,
465
+ csw_pct: 0.312,
466
+ swstr_pct: 0.176,
467
+ putaway_pct: 0.281,
468
+ opponent_whiff_tendency: 27.4,
469
+ },
470
+ ]],
471
+ ['https://example.test/reusable/hitter_rolling.parquet', []],
472
+ ['https://example.test/reusable/pitcher_rolling.parquet', []],
473
+ ['https://example.test/reusable/batter_zone_profiles.parquet', []],
474
+ ['https://example.test/reusable/pitcher_zone_profiles.parquet', []],
475
+ ['https://example.test/reusable/pitcher_arsenal.parquet', []],
476
+ ['https://example.test/reusable/pitcher_usage_by_count.parquet', []],
477
+ ]);
478
+
479
+ const hosted = new HostedArtifactSource(
480
+ {
481
+ baseUrl: 'https://example.test',
482
+ cacheTtlMs: 60_000,
483
+ fallbackDays: 0,
484
+ },
485
+ {
486
+ readParquetImpl: async (url) => {
487
+ if (!responses.has(url)) {
488
+ throw new Error(`Missing ${url}`);
489
+ }
490
+ return responses.get(url);
491
+ },
492
+ fetchTextImpl: async () => '',
493
+ logger: { debug() {}, warn() {} },
494
+ }
495
+ );
496
+
497
+ const service = new MatchupService(
498
+ { databaseUrl: 'postgresql://test', hosted: { baseUrl: 'https://example.test' } },
499
+ {
500
+ hosted,
501
+ fallback: { async close() {} },
502
+ logger: { debug() {}, warn() {} },
503
+ }
504
+ );
505
+
506
+ const result = await service.getKProfileData({ date: '2026-04-07', pitcher: 'paul skenes' });
507
+
508
+ assert.equal(result.pitcherName, 'Paul Skenes');
509
+ assert.equal(result.team, 'PIT');
510
+ assert.equal(result.opponentTeam, 'CHC');
511
+ assert.equal(result.pitcher_score, 92.2);
512
+ assert.equal(result.strikeout_score, 89.5);
513
+ });
514
+
515
  test('hosted best matchups keeps only the top three hitters per game before ranking the slate board', async () => {
516
  const responses = new Map([
517
  ['https://example.test/daily/2026-04-07/slate.parquet', [