'use strict'; /** * WEATHER MODULATION (Layer 3, Step 5) — completes the coupled environment. * * effective_environment = park_base × weather_mod * * Park is the stable BASE run/HR environment of a stadium. Weather is the * day-of MODULATION that tilts it. It multiplies, it never overrides: a * wind-out night at Oracle Park is still Oracle Park. * * ── THE SPINE: TWO WEATHER VALUES, TWO PURPOSES, NEVER CROSSED ─────────── * FORECAST at projection time → drives the live adjustment AND is what the * instrument measures. It is what we actually * knew when we projected. * ACTUAL at game time → accrues to `game_context` ONLY, as raw * material for future self-derived weather * factors. * Using the actual to drive or measure tonight's projection would be lookahead * bias — scoring ourselves on information we did not have. The two live in * different columns, written by different paths, and nothing reads across. * * ── CONSERVATIVE BY DEFAULT, LEDGER-TUNED ──────────────────────────────── * The coefficients below are deliberately SMALL. We are not asserting a known * weather edge; we are running a directional lean the instrument can size. Every * magnitude is env-tunable so the ledger can move it without a code change. */ const { DOME_VENUES } = require('./parkFactors'); /** * Bearing in degrees from home plate to CENTER FIELD, per park. * PUBLIC GEOMETRY — a fact about how a building is oriented, in the same class * as the dome list, not a derived statistic. Without it "wind out" is * unknowable, so a park missing from this table gets NO wind effect rather than * a guessed one. */ const CF_BEARING = Object.freeze({ ARI: 0, ATL: 47, BAL: 32, BOS: 45, CHC: 32, CWS: 39, CIN: 20, CLE: 0, COL: 5, DET: 27, HOU: 20, KC: 45, LAA: 44, LAD: 27, MIA: 40, MIL: 33, MIN: 26, NYM: 26, NYY: 76, ATH: 60, PHI: 3, PIT: 27, SD: 0, SF: 62, SEA: 47, STL: 62, TB: 45, TEX: 0, TOR: 0, WSH: 30, }); /** Conservative, env-tunable. Each is the multiplier delta at a reference. */ const WIND_PER_MPH = Number(process.env.WX_WIND_PER_MPH) || 0.006; // per mph of OUT component const TEMP_PER_DEG = Number(process.env.WX_TEMP_PER_DEG) || 0.003; // per °F above/below baseline const TEMP_BASELINE = Number(process.env.WX_TEMP_BASELINE) || 70; /** Below these the weather is noise — do not manufacture a tiny adjustment. */ const WIND_THRESHOLD = Number(process.env.WX_WIND_THRESHOLD) || 5; // mph OUT component const TEMP_THRESHOLD = Number(process.env.WX_TEMP_THRESHOLD) || 8; // °F from baseline /** Hard cap: weather MODULATES, never overrides. */ const MAX_MOD = Number(process.env.WX_MAX_MOD) || 0.12; // ±12% /** Which stats weather speaks to at all. Strikeouts, walks etc. are absent — * we are not going to invent a wind effect on a strikeout prop. */ const WEATHER_STATS = Object.freeze(new Set([ 'home_runs', 'home_runs_allowed', 'total_bases', 'runs', 'rbi', 'hits', 'hits_allowed', 'earned_runs', ])); const num = (v) => { if (v == null || v === '') return null; const n = typeof v === 'number' ? v : Number(v); return Number.isFinite(n) ? n : null; }; const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); const rad = (d) => (d * Math.PI) / 180; /** * outComponent(windFromDeg, speedMph, cfBearingDeg) — how much of the wind is * blowing OUT toward center field, in mph. Negative = blowing in. * * Meteorological convention: `wind_direction` is the direction the wind comes * FROM. Wind blowing out to centre therefore arrives FROM the opposite bearing, * which is why 180 is added before comparing. Getting this backwards would * invert every wind adjustment — the single easiest sign error in the file. */ function outComponent(windFromDeg, speedMph, cfBearingDeg) { const from = num(windFromDeg); const spd = num(speedMph); const cf = num(cfBearingDeg); if (from == null || spd == null || cf == null) return null; const blowingToward = (from + 180) % 360; const delta = rad(blowingToward - cf); return Math.round(spd * Math.cos(delta) * 100) / 100; } /** * weatherMod({ forecast, teamAbbr, venueName, statType, weatherNa }) — the * COMPOSABLE modulation. Always returns a multiplier; 1.0 is a true no-op and * is reached by three HONESTLY DISTINCT routes. */ function weatherMod({ forecast, teamAbbr, venueName, statType, weatherNa } = {}) { const neutral = (state, reason) => ({ multiplier: 1, state, reason, components: null, wind_out_mph: null, temp_f: null, }); const stat = String(statType || '').toLowerCase(); if (!WEATHER_STATS.has(stat)) return neutral('not_applicable', 'weather says nothing about this stat'); // 1. DOME — weather does not apply. The PARK factor still does; this flag // only tells the weather layer to stand down. Not the same as absent. const isDome = weatherNa === true || (venueName ? DOME_VENUES.has(venueName) : false); if (isDome) return neutral('dome_na', 'roofed venue — weather does not apply'); // 2. FORECAST ABSENT — we could not get one for this park/time. const f = forecast || null; const temp = num(f && f.temperature_f); const windSpeed = num(f && f.wind_speed_mph); const windDir = num(f && f.wind_direction_deg); if (temp == null && windSpeed == null) { return neutral('forecast_absent', 'no forecast available for this park/time'); } const abbr = String(teamAbbr || '').toUpperCase(); const cf = CF_BEARING[abbr]; // No orientation → wind is unknowable here. Temperature still applies. const windOut = cf == null ? null : outComponent(windDir, windSpeed, cf); const windActive = windOut != null && Math.abs(windOut) >= WIND_THRESHOLD; const tempDelta = temp == null ? null : temp - TEMP_BASELINE; const tempActive = tempDelta != null && Math.abs(tempDelta) >= TEMP_THRESHOLD; // 3. SUB-THRESHOLD — a real forecast, but a light breeze and a mild evening // are noise. Manufacturing a 0.3% nudge on them would be false precision. if (!windActive && !tempActive) { return { ...neutral('sub_threshold', 'weather present but below a meaningful threshold'), wind_out_mph: windOut, temp_f: temp, }; } const windEffect = windActive ? windOut * WIND_PER_MPH : 0; const tempEffect = tempActive ? tempDelta * TEMP_PER_DEG : 0; const raw = windEffect + tempEffect; const capped = clamp(raw, -MAX_MOD, MAX_MOD); return { multiplier: Math.round((1 + capped) * 1000) / 1000, state: 'present', reason: null, wind_out_mph: windOut, temp_f: temp, components: { wind: Math.round(windEffect * 1000) / 1000, temp: Math.round(tempEffect * 1000) / 1000, capped: capped !== raw, cf_bearing: cf ?? null, }, }; } /** * composeEnvironment({ park, weather }) — the coupled coefficient. * park_base × weather_mod, with provenance from both so a rendered effect can * say which parts are real and which stood down. */ function composeEnvironment({ park, weather, statType } = {}) { const { parkMultiplier } = require('./parkFactors'); const pm = park ? parkMultiplier({ factor: park, statType }) : 1; const wx = weather || { multiplier: 1, state: 'forecast_absent' }; const multiplier = Math.round(pm * (wx.multiplier ?? 1) * 1000) / 1000; return { multiplier, park_base: pm, weather_mod: wx.multiplier ?? 1, weather_state: wx.state, label: 'PARK × WEATHER', venue: (park && park.venue) || null, weather_na: (park && park.weather_na) ?? null, provenance: (park && park.provenance) || null, }; } /** Open-Meteo — free, keyless. Hourly forecast at the park's coordinates. */ const FORECAST_URL = (lat, lon) => `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}` + '&hourly=temperature_2m,wind_speed_10m,wind_direction_10m,precipitation_probability' + '&temperature_unit=fahrenheit&wind_speed_unit=mph&forecast_days=3'; /** Pick the forecast hour nearest first pitch. Returns null when the requested * hour is outside the returned window — absent, never the closest-anyway. */ function pickHour(payload, isoTime) { const h = payload && payload.hourly; if (!h || !Array.isArray(h.time) || !isoTime) return null; const target = new Date(isoTime).getTime(); if (!Number.isFinite(target)) return null; let best = -1; let bestDiff = Infinity; h.time.forEach((t, i) => { const diff = Math.abs(new Date(`${t}Z`).getTime() - target); if (diff < bestDiff) { bestDiff = diff; best = i; } }); if (best < 0 || bestDiff > 3 * 3600 * 1000) return null; // >3h away = no forecast return { temperature_f: num(h.temperature_2m && h.temperature_2m[best]), wind_speed_mph: num(h.wind_speed_10m && h.wind_speed_10m[best]), wind_direction_deg: num(h.wind_direction_10m && h.wind_direction_10m[best]), precip_pct: num(h.precipitation_probability && h.precipitation_probability[best]), forecast_hour: h.time[best], }; } module.exports = { weatherMod, composeEnvironment, outComponent, pickHour, FORECAST_URL, CF_BEARING, WEATHER_STATS, WIND_PER_MPH, TEMP_PER_DEG, TEMP_BASELINE, WIND_THRESHOLD, TEMP_THRESHOLD, MAX_MOD, };