Files
HealthLog/src/lib/environment/open-meteo.ts
T
Marc-André Bombeck 76dc03ae7f feat(environment): add Open-Meteo client, location resolution, and field vocabulary
Add the keyless Open-Meteo client (historical archive + geocoding) routed
through safeFetch, the per-day location-resolution + fetch/upsert service
(travel override wins over home, then degrades silently), and the shared
environmental-exposure field table the registry and correlation engine both
read. The base URL is operator-overridable for a self-hosted instance; the
fetch stays server-side so a private/LAN host is not blocked.
2026-06-28 16:58:22 +02:00

291 lines
9.4 KiB
TypeScript
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.
/**
* v1.25 (W-ENV) — Open-Meteo client (keyless historical weather + geocoding).
*
* The single outbound egress for the environmental-context module. Open-Meteo's
* Historical (Archive) API is keyless, free for non-commercial use, and offers
* a clean per-date global lat/lon endpoint — the right fit for a self-host-
* friendly, privacy-first exposure feed. Data is CC BY 4.0 ("Weather data by
* Open-Meteo.com" — credited on the settings surface).
*
* Egress goes through `safeFetch` (the documented wrapper: manual redirects +
* a request timeout). The base URL is operator config, not arbitrary user
* input, so `requirePublicHost` is deliberately NOT forced — a privacy-
* maximalist operator can point `OPENMETEO_BASE_URL` at a self-hosted Open-Meteo
* instance on a private/LAN host without it being blocked. The hosted default is
* public.
*
* Server-side only. The archive feed lags real time by a few days (reanalysis
* settling), so callers fetch with a lookback window rather than "yesterday
* only" — see the nightly job and the backfill route.
*/
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
/** Hosted archive default; override with `OPENMETEO_BASE_URL` (self-host). */
const ARCHIVE_BASE_URL =
process.env.OPENMETEO_BASE_URL?.replace(/\/$/, "") ??
"https://archive-api.open-meteo.com";
/** Hosted geocoding default; override with `OPENMETEO_GEOCODING_URL`. */
const GEOCODING_BASE_URL =
process.env.OPENMETEO_GEOCODING_URL?.replace(/\/$/, "") ??
"https://geocoding-api.open-meteo.com";
const FETCH_TIMEOUT_MS = 15_000;
/** CC BY 4.0 attribution required by Open-Meteo; surfaced on the settings UI. */
export const OPEN_METEO_ATTRIBUTION = "Weather data by Open-Meteo.com";
/** One geocoding match — coarse city placement for the home-location picker. */
export interface GeocodeResult {
/** Rounded coarse latitude (2 dp — ~1 km, the privacy floor). */
lat: number;
/** Rounded coarse longitude (2 dp). */
lon: number;
/** Human label, e.g. "Bochum, North Rhine-Westphalia, Germany". */
label: string;
/** IANA timezone for the place (anchors the day-key of stored rows). */
timezone: string;
}
/** A single day's resolved weather observation, mapped to our column shape. */
export interface DailyEnvironmentObservation {
/** YYYY-MM-DD in the requested timezone. */
date: string;
tempMin: number | null;
tempMax: number | null;
tempMean: number | null;
apparentMean: number | null;
sunshineSec: number | null;
daylightSec: number | null;
precipSum: number | null;
pressureMean: number | null;
pressureDelta: number | null;
humidityMean: number | null;
cloudMean: number | null;
weatherCode: number | null;
}
/** Round a coordinate to 2 decimals (~1 km) — the coarse-location privacy floor. */
export function roundCoarse(value: number): number {
return Math.round(value * 100) / 100;
}
function firstNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
/** Mean of the finite numbers in `xs`, or null when none. */
function meanOf(xs: number[]): number | null {
if (xs.length === 0) return null;
const sum = xs.reduce((a, b) => a + b, 0);
return sum / xs.length;
}
interface ArchiveDaily {
time?: string[];
temperature_2m_min?: (number | null)[];
temperature_2m_max?: (number | null)[];
temperature_2m_mean?: (number | null)[];
apparent_temperature_mean?: (number | null)[];
sunshine_duration?: (number | null)[];
daylight_duration?: (number | null)[];
precipitation_sum?: (number | null)[];
weather_code?: (number | null)[];
}
interface ArchiveHourly {
time?: string[];
surface_pressure?: (number | null)[];
relative_humidity_2m?: (number | null)[];
cloud_cover?: (number | null)[];
}
interface ArchiveResponse {
daily?: ArchiveDaily;
hourly?: ArchiveHourly;
}
const DAILY_FIELDS = [
"temperature_2m_min",
"temperature_2m_max",
"temperature_2m_mean",
"apparent_temperature_mean",
"sunshine_duration",
"daylight_duration",
"precipitation_sum",
"weather_code",
].join(",");
const HOURLY_FIELDS = [
"surface_pressure",
"relative_humidity_2m",
"cloud_cover",
].join(",");
/** Per-day aggregation accumulator for the hourly-only fields. */
interface HourlyDayAgg {
pressures: number[];
humidities: number[];
clouds: number[];
}
/**
* Aggregate the hourly feed (pressure / humidity / cloud — not available as a
* daily aggregate from the archive) into per-day values: mean for each, plus the
* intraday pressure delta (max − min), the derived headache/symptom feature.
*/
function aggregateHourly(hourly: ArchiveHourly): Map<string, HourlyDayAgg> {
const byDay = new Map<string, HourlyDayAgg>();
const times = hourly.time ?? [];
for (let i = 0; i < times.length; i++) {
const day = times[i]?.slice(0, 10);
if (!day) continue;
const agg = byDay.get(day) ?? { pressures: [], humidities: [], clouds: [] };
const p = firstNumber(hourly.surface_pressure?.[i]);
const h = firstNumber(hourly.relative_humidity_2m?.[i]);
const c = firstNumber(hourly.cloud_cover?.[i]);
if (p !== null) agg.pressures.push(p);
if (h !== null) agg.humidities.push(h);
if (c !== null) agg.clouds.push(c);
byDay.set(day, agg);
}
return byDay;
}
/**
* Forward-geocode a free-text place to coarse matches. Keyless. Returns at most
* `count` results; an empty array when nothing matched or the feed errored
* (the caller surfaces "no match" rather than failing the request).
*/
export async function geocodeLocation(
query: string,
count = 5,
): Promise<GeocodeResult[]> {
const trimmed = query.trim();
if (trimmed.length === 0) return [];
const url = new URL(`${GEOCODING_BASE_URL}/v1/search`);
url.searchParams.set("name", trimmed);
url.searchParams.set("count", String(Math.min(Math.max(count, 1), 10)));
url.searchParams.set("format", "json");
let res: Response;
try {
res = await safeFetch(
url,
{ headers: { accept: "application/json" } },
{
timeoutMs: FETCH_TIMEOUT_MS,
},
);
} catch (err) {
if (err instanceof SafeFetchError) return [];
throw err;
}
if (!res.ok) return [];
const body = (await res.json()) as {
results?: Array<{
name?: string;
latitude?: number;
longitude?: number;
country?: string;
admin1?: string;
timezone?: string;
}>;
};
const out: GeocodeResult[] = [];
for (const r of body.results ?? []) {
if (typeof r.latitude !== "number" || typeof r.longitude !== "number") {
continue;
}
const parts = [r.name, r.admin1, r.country].filter(
(p): p is string => typeof p === "string" && p.length > 0,
);
out.push({
lat: roundCoarse(r.latitude),
lon: roundCoarse(r.longitude),
label: parts.join(", "),
timezone:
typeof r.timezone === "string" && r.timezone.length > 0
? r.timezone
: "auto",
});
}
return out;
}
/**
* Fetch the daily environment observations for a coarse location over a date
* range (inclusive YYYY-MM-DD). The hourly-only fields (pressure / humidity /
* cloud) are requested alongside and aggregated to per-day values here. Throws
* a {@link SafeFetchError} on egress failure so the caller can classify it.
*/
export async function fetchDailyEnvironment(args: {
lat: number;
lon: number;
timezone: string;
startDate: string;
endDate: string;
}): Promise<DailyEnvironmentObservation[]> {
const url = new URL(`${ARCHIVE_BASE_URL}/v1/archive`);
url.searchParams.set("latitude", String(args.lat));
url.searchParams.set("longitude", String(args.lon));
url.searchParams.set("start_date", args.startDate);
url.searchParams.set("end_date", args.endDate);
url.searchParams.set("daily", DAILY_FIELDS);
url.searchParams.set("hourly", HOURLY_FIELDS);
url.searchParams.set("timezone", args.timezone);
const res = await safeFetch(
url,
{ headers: { accept: "application/json" } },
{
timeoutMs: FETCH_TIMEOUT_MS,
},
);
if (!res.ok) {
throw new SafeFetchError(
`open-meteo archive returned HTTP ${res.status}`,
"network",
);
}
const body = (await res.json()) as ArchiveResponse;
const daily = body.daily ?? {};
const days = daily.time ?? [];
const hourlyByDay = aggregateHourly(body.hourly ?? {});
const observations: DailyEnvironmentObservation[] = [];
for (let i = 0; i < days.length; i++) {
const date = days[i];
if (!date) continue;
const agg = hourlyByDay.get(date);
const pressures = agg?.pressures ?? [];
const pressureMean = meanOf(pressures);
const pressureDelta =
pressures.length > 0
? Math.max(...pressures) - Math.min(...pressures)
: null;
observations.push({
date,
tempMin: firstNumber(daily.temperature_2m_min?.[i]),
tempMax: firstNumber(daily.temperature_2m_max?.[i]),
tempMean: firstNumber(daily.temperature_2m_mean?.[i]),
apparentMean: firstNumber(daily.apparent_temperature_mean?.[i]),
sunshineSec: firstNumber(daily.sunshine_duration?.[i]),
daylightSec: firstNumber(daily.daylight_duration?.[i]),
precipSum: firstNumber(daily.precipitation_sum?.[i]),
pressureMean,
pressureDelta:
pressureDelta !== null ? Math.round(pressureDelta * 10) / 10 : null,
humidityMean: meanOf(agg?.humidities ?? []),
cloudMean: meanOf(agg?.clouds ?? []),
weatherCode: firstNumber(daily.weather_code?.[i]),
});
}
return observations;
}