Files
vyndr/tests/unit/weatherMod.test.js
T
builtbykev 9f60ceba10 Layer 3 Step 5: weather modulation composed onto the park base
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
2026-07-21 01:43:13 -04:00

168 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
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/);
});
});