/* ============================================================ 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/); }); });