/** * Weather service (Session 15). * * Open-Meteo proxy. No API key required (the service is free for * non-commercial use and sportsbook analytics is firmly non- * commercial intelligence). 5s hard timeout, 1h Redis cache, * graceful degrade (returns null on any failure — never throws, * never blocks the grade). * * Outputs are normalized to the units North-American bettors think * in: temperature in Fahrenheit, wind in mph. Open-Meteo's defaults * are Celsius + km/h, so we request the imperial units directly via * query params. * * Cache key: `weather:{lat}:{lon}:{hour}` — keyed by the current * UTC hour so two requests within the same hour hit cache. Slightly * less precise than a sliding TTL but matches Open-Meteo's hourly * forecast cadence and keeps cache churn bounded. */ const axios = require('axios'); const { cacheGet, cacheSet } = require('../utils/redis'); const BASE_URL = 'https://api.open-meteo.com/v1/forecast'; const HTTP_TIMEOUT_MS = 5_000; const CACHE_TTL_SEC = 3600; // 1h — Open-Meteo refreshes hourly function currentHourBucket() { const d = new Date(); return `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}${String(d.getUTCHours()).padStart(2, '0')}`; } function buildKey(lat, lon) { // 2-decimal precision is enough for a city-scale lookup and // collapses neighboring venues onto the same cache key (no real- // world impact — they share the same weather). const latKey = Number(lat).toFixed(2); const lonKey = Number(lon).toFixed(2); return `weather:${latKey}:${lonKey}:${currentHourBucket()}`; } /** * getWeather — fetch current weather conditions for a lat/lon. * * @param {number} lat * @param {number} lon * @returns {Promise<{temp_f:number|null, wind_mph:number|null, wind_dir:number|null, precip_mm:number|null} | null>} * * Returns null on: * - invalid coordinates * - upstream timeout / 5xx * - missing fields in the response * * The grading engine and reasoning builder both treat null as "no * signal" — features are simply omitted from the prop's overlay. */ async function getWeather(lat, lon) { if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; const cacheKey = buildKey(lat, lon); try { const cached = await cacheGet(cacheKey); if (cached !== null) return cached; } catch { // Redis hiccup — proceed to network. } try { const res = await axios.get(BASE_URL, { timeout: HTTP_TIMEOUT_MS, params: { latitude: lat, longitude: lon, current: 'temperature_2m,wind_speed_10m,wind_direction_10m,precipitation', temperature_unit: 'fahrenheit', wind_speed_unit: 'mph', precipitation_unit: 'mm', // mm is the universal precipitation unit }, }); const current = res.data?.current; if (!current) return null; const out = { temp_f: Number.isFinite(current.temperature_2m) ? current.temperature_2m : null, wind_mph: Number.isFinite(current.wind_speed_10m) ? current.wind_speed_10m : null, wind_dir: Number.isFinite(current.wind_direction_10m) ? current.wind_direction_10m : null, precip_mm: Number.isFinite(current.precipitation) ? current.precipitation : null, _fetched_at: new Date().toISOString(), }; try { await cacheSet(cacheKey, out, CACHE_TTL_SEC); } catch { /* graceful */ } return out; } catch (err) { // Open-Meteo down, timeout, 5xx — silently degrade. if (err && err.code !== 'ECONNABORTED') { console.warn('[weatherService] fetch failed:', err.message); } return null; } } module.exports = { getWeather, __internals: { BASE_URL, HTTP_TIMEOUT_MS, CACHE_TTL_SEC, buildKey, currentHourBucket }, };