Codex commited on
Commit ·
f44fbff
1
Parent(s): 698d79c
Fix hosted pitcher chart resolution
Browse files- src/matchups.js +60 -20
- 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
|
| 847 |
-
opponent_team: row.opponent_team
|
| 848 |
-
opposing_pitcher_id: row.opposing_pitcher_id
|
| 849 |
-
opposing_pitcher_name: row.opposing_pitcher_name
|
| 850 |
-
opposing_pitcher_hand: row.opposing_pitcher_hand
|
| 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 |
-
|
| 2319 |
));
|
| 2320 |
const pitcherMap = aggregatePitcherZoneMap(selectPitcherZoneRows(
|
| 2321 |
pitcherZoneRows,
|
| 2322 |
-
|
| 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
|
| 2347 |
-
opposingPitcherHand
|
| 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 (
|
| 2690 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2691 |
}
|
| 2692 |
|
| 2693 |
-
|
| 2694 |
-
|
| 2695 |
-
|
| 2696 |
playerType: 'pitcher',
|
| 2697 |
-
|
| 2698 |
-
|
| 2699 |
-
|
| 2700 |
-
|
| 2701 |
-
|
| 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', [
|