9f60ceba10
Completes the coupled environment: effective = park_base x weather_mod. Weather tilts the park, it never overrides it — a wind-out night at Oracle Park is still Oracle Park. PHASE 0 — both feeds are free and keyless. statsapi /venues gives every park's coordinates in one call; Open-Meteo returns hourly temperature, wind speed and wind direction for those coordinates hours before first pitch, which is when we project. Verified live. THE SPINE — two weather values, two purposes, never crossed. The FORECAST we held at projection time drives the live adjustment AND is what the instrument measures, because it is what we actually knew. It lands on the ledger row beside p_win. The ACTUAL goes only to game_context as raw material for future self-derived weather factors, and is read by nothing that scores a projection. Using the actual to measure tonight would be scoring ourselves on information we did not have. The actual is also pulled from Open-Meteo's ARCHIVE endpoint rather than the forecast endpoint, because asking a forecaster after the fact returns a re-forecast, not what happened. WIND IS PARK-ORIENTATION CONDITIONED. Wind direction is meteorological — the direction it comes FROM — so blowing out to centre means arriving from the opposite bearing. Getting that backwards would invert every wind adjustment in the system, so the 180-degree rotation is commented at the site and pinned by a test on all three cases: straight out, straight in, and crosswind. Centre-field bearings are public geometry, in the same class as the dome list; a park missing from the table gets no wind effect at all rather than a guessed one, and keeps its temperature effect. THREE HONEST DO-NOTHING STATES, all multiplier 1.0, none fabricating an effect. Dome: weather does not apply, and the PARK factor still does — verified that a domed venue keeps its sub-1.0 park base while weather stands down. Forecast absent: none available for this park and time. Sub-threshold: a real forecast below a meaningful bar, because manufacturing a 0.3% nudge on a light breeze is false precision. Weather also says nothing about a strikeout prop and returns not-applicable rather than a neutral it might later be tempted to fill. Conservative and ledger-tunable: every magnitude is an env var, the total is capped at 12%, and nothing here is asserted. This is a nominated challenger that earns its place on the instrument or is cut. Induced at Wrigley, whose centre field bears 32 degrees: wind from 212 at 15 mph computes as 15 mph straight out, weather 1.12 composed with park 1.06 for an effective 1.187 and a +0.043 nudge; the under mirrors exactly; the pitcher's home-runs-allowed prop moves with the hitter's, since both are P(over) on a ball leaving the park. Wind in drops the coefficient to 0.955. A calm 72-degree evening, a dome, and a missing forecast all return 1.0 by three different honest routes, with the park base still applying in each. One correction to the order worth recording: it describes a wind-out night as helping the hitter and hurting "the pitcher there's HR-allowed" as opposite sides. In prop terms both go the same way — the HR-allowed OVER is more likely too. The sign lives in the stat, exactly as established for park factors, and the implementation follows that rather than the phrasing. Migration 036. Tests 3707 passed / 299 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
168 lines
7.6 KiB
JavaScript
168 lines
7.6 KiB
JavaScript
/* ============================================================
|
||
Session 75 — WEATHER MODULATION. Composes onto the park base.
|
||
Forecast drives + measures; actual accrues. Never crossed.
|
||
============================================================ */
|
||
|
||
const wx = require('../../src/services/weatherMod');
|
||
const pb = require('../../src/services/parkBase');
|
||
const ch = require('../../src/services/challengerProjection');
|
||
const gc = require('../../src/services/gameContext');
|
||
|
||
// Wrigley's centre field bears 32°; wind FROM 212° blows straight out.
|
||
const OUT = { temperature_f: 85, wind_speed_mph: 15, wind_direction_deg: 212 };
|
||
const IN = { temperature_f: 85, wind_speed_mph: 15, wind_direction_deg: 32 };
|
||
const CALM = { temperature_f: 72, wind_speed_mph: 3, wind_direction_deg: 180 };
|
||
const COLD = { temperature_f: 48, wind_speed_mph: 2, wind_direction_deg: 180 };
|
||
const mod = (fc, team = 'CHC', stat = 'home_runs', na = false) =>
|
||
wx.weatherMod({ forecast: fc, teamAbbr: team, statType: stat, weatherNa: na });
|
||
|
||
describe('wind is PARK-ORIENTATION conditioned', () => {
|
||
it('computes the OUT component from the bearing, not raw speed', () => {
|
||
// Meteorological convention: direction is where wind comes FROM. Getting
|
||
// this backwards would invert every wind adjustment.
|
||
expect(wx.outComponent(212, 15, 32)).toBeCloseTo(15, 1); // straight out
|
||
expect(wx.outComponent(32, 15, 32)).toBeCloseTo(-15, 1); // straight in
|
||
expect(wx.outComponent(122, 15, 32)).toBeCloseTo(0, 1); // cross-wind
|
||
});
|
||
|
||
it('wind OUT raises the coefficient, wind IN lowers it', () => {
|
||
expect(mod(OUT).multiplier).toBeGreaterThan(1);
|
||
expect(mod(IN).multiplier).toBeLessThan(1);
|
||
});
|
||
|
||
it('a park with no known orientation gets NO wind effect, only temperature', () => {
|
||
const r = wx.weatherMod({ forecast: OUT, teamAbbr: 'ZZZ', statType: 'home_runs' });
|
||
expect(r.wind_out_mph).toBeNull();
|
||
expect(r.components.wind).toBe(0);
|
||
});
|
||
|
||
it('cold suppresses offence on temperature alone', () => {
|
||
expect(mod(COLD).multiplier).toBeLessThan(1);
|
||
});
|
||
});
|
||
|
||
describe('three HONEST do-nothing states — all 1.0, distinct reasons', () => {
|
||
it('DOME — weather does not apply, but the PARK factor still does', () => {
|
||
const r = mod(OUT, 'TB', 'home_runs', true);
|
||
expect(r.multiplier).toBe(1);
|
||
expect(r.state).toBe('dome_na');
|
||
const park = pb.resolveParkBase({ teamAbbr: 'TB' });
|
||
const env = wx.composeEnvironment({ park, weather: r, statType: 'home_runs' });
|
||
expect(env.park_base).toBeLessThan(1); // park STILL applies indoors
|
||
expect(env.weather_mod).toBe(1);
|
||
});
|
||
|
||
it('FORECAST ABSENT — no forecast for this park/time', () => {
|
||
const r = mod(null);
|
||
expect(r.multiplier).toBe(1);
|
||
expect(r.state).toBe('forecast_absent');
|
||
});
|
||
|
||
it('SUB-THRESHOLD — real forecast, but a light breeze is noise', () => {
|
||
const r = mod(CALM);
|
||
expect(r.multiplier).toBe(1);
|
||
expect(r.state).toBe('sub_threshold');
|
||
expect(r.wind_out_mph).not.toBeNull(); // measured, just not meaningful
|
||
});
|
||
|
||
it('weather says NOTHING about a strikeout prop', () => {
|
||
expect(mod(OUT, 'CHC', 'strikeouts').state).toBe('not_applicable');
|
||
});
|
||
|
||
it('none of the three fabricate an effect', () => {
|
||
for (const r of [mod(null), mod(CALM), mod(OUT, 'TB', 'home_runs', true)]) {
|
||
expect(r.multiplier).toBe(1);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('composition — weather MULTIPLIES the park base', () => {
|
||
it('effective = park × weather, and both parts stay visible', () => {
|
||
const park = pb.resolveParkBase({ teamAbbr: 'CHC' });
|
||
const w = mod(OUT);
|
||
const env = wx.composeEnvironment({ park, weather: w, statType: 'home_runs' });
|
||
expect(env.multiplier).toBeCloseTo(env.park_base * env.weather_mod, 3);
|
||
expect(env.weather_state).toBe('present');
|
||
});
|
||
|
||
it('MODULATES, never overrides — capped at ±12%', () => {
|
||
const hurricane = { temperature_f: 110, wind_speed_mph: 60, wind_direction_deg: 212 };
|
||
expect(mod(hurricane).multiplier).toBeLessThanOrEqual(1 + wx.MAX_MOD + 1e-9);
|
||
});
|
||
|
||
it('drives the challenger directionally and mirrors on the under', () => {
|
||
const park = pb.resolveParkBase({ teamAbbr: 'CHC' });
|
||
const env = wx.composeEnvironment({ park, weather: mod(OUT), statType: 'home_runs' });
|
||
const over = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', environment: env });
|
||
const under = ch.adjust({ pWin: 0.5, direction: 'under', statType: 'home_runs', environment: env });
|
||
expect(over.delta).toBeGreaterThan(0);
|
||
expect(under.delta).toBeCloseTo(-over.delta, 3);
|
||
});
|
||
|
||
it('same night, the pitcher HR-allowed prop moves WITH the hitter HR prop', () => {
|
||
// Both are P(over) on a ball leaving the park — the sign lives in the stat,
|
||
// exactly as with park factors.
|
||
const park = pb.resolveParkBase({ teamAbbr: 'CHC' });
|
||
const h = wx.composeEnvironment({ park, weather: mod(OUT), statType: 'home_runs' });
|
||
const p = wx.composeEnvironment({ park, weather: mod(OUT, 'CHC', 'home_runs_allowed'), statType: 'home_runs_allowed' });
|
||
expect(p.multiplier).toBe(h.multiplier);
|
||
});
|
||
});
|
||
|
||
describe('forecast selection — absent rather than nearest-anyway', () => {
|
||
const payload = { hourly: {
|
||
time: ['2026-07-21T18:00', '2026-07-21T19:00', '2026-07-21T20:00'],
|
||
temperature_2m: [80, 82, 84], wind_speed_10m: [5, 7, 9],
|
||
wind_direction_10m: [180, 200, 212], precipitation_probability: [0, 5, 10],
|
||
} };
|
||
|
||
it('picks the hour nearest first pitch', () => {
|
||
const f = wx.pickHour(payload, '2026-07-21T19:10:00Z');
|
||
expect(f.forecast_hour).toBe('2026-07-21T19:00');
|
||
expect(f.temperature_f).toBe(82);
|
||
});
|
||
|
||
it('returns NULL when the requested time is outside the window', () => {
|
||
expect(wx.pickHour(payload, '2026-07-25T19:00:00Z')).toBeNull();
|
||
expect(wx.pickHour(payload, null)).toBeNull();
|
||
expect(wx.pickHour(null, '2026-07-21T19:00:00Z')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('THE SPINE — forecast and actual are never crossed', () => {
|
||
it('the LEDGER stores the forecast that drove the projection', () => {
|
||
const src = require('fs').readFileSync(require.resolve('../../src/services/ledgerService'), 'utf8');
|
||
expect(src).toMatch(/wx_forecast: g\.wx_forecast/);
|
||
expect(src).toMatch(/env_weather_mod/);
|
||
// and says why
|
||
expect(src).toMatch(/The FORECAST,\s*\n\s*\/\/ not the actual/);
|
||
});
|
||
|
||
it('the ACTUAL is written only to game_context, from the ARCHIVE endpoint', () => {
|
||
// Asking the forecast endpoint after the fact returns a re-forecast, not
|
||
// what happened.
|
||
expect(gc.ARCHIVE_URL(39.7, -104.9, '2026-07-20')).toMatch(/archive-api\.open-meteo\.com/);
|
||
const src = require('fs').readFileSync(require.resolve('../../src/services/gameContext'), 'utf8');
|
||
expect(src).toMatch(/Never read back into a projection or a measurement/);
|
||
});
|
||
|
||
it('an unavailable actual stays NULL — never imputed', async () => {
|
||
const sb = {
|
||
from: () => ({
|
||
select: () => ({ eq: () => ({ is: () => ({ limit: async () => ({ data: [{ game_id: 'g1', venue_id: 19, game_date: '2026-07-20' }], error: null }) }) }) }),
|
||
update: () => ({ eq: async () => ({ error: null }) }),
|
||
}),
|
||
};
|
||
// No coordinates for the venue → absent, not a guessed temperature.
|
||
const r = await gc.captureWeatherActual('2026-07-20', { sb, venueCoords: {} });
|
||
expect(r.updated).toBe(0);
|
||
expect(r.absent).toBe(1);
|
||
});
|
||
|
||
it('the weather columns on game_context are named-purpose only', () => {
|
||
const src = require('fs').readFileSync(require.resolve('../../src/services/gameContext'), 'utf8');
|
||
expect(src).toMatch(/named-purpose: raw material for FUTURE/);
|
||
expect(src).toMatch(/self-derived weather factors/);
|
||
});
|
||
});
|