Files
vyndr/src/services/weatherService.js
T

104 lines
3.7 KiB
JavaScript

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