/** * Multi-model forecast comparison — pure functions, no I/O, zero imports. * Mirrors the `fireWeather.ts`/`firmsHotspots.ts` precedent: fetching the * multi-model Open-Meteo response is the service's job (T2), rendering the * agreement narrative is the handler's job (T3); every judgement call about * what the suffixed daily arrays mean lives here, unit-tested without HTTP. * * See `docs/multi-model-comparison-plan.md` D4 (this module's spec), D6 * (best_match exclusion), and the Edge cases table. * * `COMPARISON_MODELS` is the single source of truth for the curated model * set — the service (`src/services/openmeteo.ts`) imports it from here * rather than the reverse, because this module must never import a service * (design D4 vs D3 reconciliation, implementation-plan assumption A1). * * `best_match` is the reference member of `COMPARISON_MODELS`: it renders as * the headline "Best match" line but is excluded from every statistic, band, * participation count, and trimming decision (D6) — it is Open-Meteo's own * blend of (largely) these same models, not an independent member, and * including it would double-count and artificially tighten every spread. */ /** * Curated set of Open-Meteo daily models requested for a comparison, in * request order. `best_match` is the reference member (D6); the other five * — `gfs_seamless` (NOAA/NCEP), `ecmwf_ifs025` (ECMWF), `icon_seamless` * (DWD), `gem_seamless` (ECCC), `ukmo_seamless` (UK Met Office) — are the * "comparison models" that participate in every statistic below. */ export declare const COMPARISON_MODELS: readonly ['best_match', 'gfs_seamless', 'ecmwf_ifs025', 'icon_seamless', 'gem_seamless', 'ukmo_seamless']; export type ComparisonModel = (typeof COMPARISON_MODELS)[number]; /** * Local mirror of `OpenMeteoModelComparisonDaily` (`src/types/openmeteo.ts`, * D-types). Deliberately re-declared rather than imported — this module has * zero imports by design — but structurally identical, so the service's real * response object is assignable here without a cast. */ export interface RawModelComparisonDaily { time: string[]; [key: string]: string[] | (number | null)[] | undefined; } /** * Extract one model's series for one daily variable from the suffixed * multi-model response, e.g. `temperature_2m_max_gfs_seamless`. Guarded with * `Array.isArray` against the index-signature type (the key may be absent, or * present as the `time: string[]` shape for an unrelated key collision, which * cannot happen here but the guard is what keeps this TypeScript-strict-safe * without a cast). Non-numeric entries (including `NaN`/`Infinity`, which * `typeof` alone would not catch) are coerced to `null` rather than thrown * out, so every model's series stays the same length as `daily.time`. */ export declare function extractModelSeries(daily: RawModelComparisonDaily, variable: string, model: string): (number | null)[] | undefined; export interface StatSummary { min: number; max: number; range: number; median: number; /** Number of participating (non-null) values this summary was computed from. */ count: number; } /** * Min/max/range/median across a set of participating values. Returns a * zeroed, `count: 0` summary for an empty input rather than throwing — an * interior day can legitimately have zero participating models for one * variable while still rendering (D4 level 3, interior gaps retained). */ export declare function computeStatSummary(values: number[]): StatSummary; export type TempSpreadBand = 'tight' | 'moderate' | 'divergent'; /** * Classify a daily-high temperature spread (max - min across participating * comparison models, `best_match` excluded per D6) into an agreement band. * * **Project heuristic, not a published meteorological standard** (Fosberg * banding precedent, `fireWeather.ts`): `<= 4 F` (`<= 2.2 C`) tight, * `<= 8 F` (`<= 4.4 C`) moderate, else divergent. The Celsius thresholds are * independently rounded scale equivalents for band-width judgment, not exact * unit conversions of the Fahrenheit width. */ export declare function classifyTempSpread(range: number, tempUnit: 'F' | 'C'): TempSpreadBand; /** * Minimum daily precipitation sum for a model to count as "predicts * measurable precipitation" (D4). **Project heuristic threshold**, chosen to * exclude trace/rounding noise near zero: `>= 0.01 in` under imperial prefs, * `>= 0.25 mm` under metric — these are independently chosen round numbers * per unit, not a unit conversion of each other. */ export declare function precipThreshold(precipUnit: 'inch' | 'mm'): number; export type WeatherCodeBucket = 'clear' | 'cloudy' | 'fog' | 'rain' | 'snow' | 'thunderstorm' | 'other'; /** * Bucket a WMO daily weather code into a coarse category for consensus * display. **Project heuristic grouping**, not a WMO-published taxonomy: * clear (0-1), cloudy (2-3), fog (45, 48), rain (51-67, 80-82), snow (71-77, * 85-86), thunderstorm (95-99); anything else buckets to `other`. */ export declare function weatherCodeBucket(code: number): WeatherCodeBucket; export type AgreementLabel = 'Good' | 'Moderate' | 'Low'; export interface ModelValue { model: string; value: number; } export interface BestMatchDay { high: number; low: number | null; code: number | null; } export interface TemperatureComparison { high: StatSummary; low: StatSummary; /** Band classification of the daily-high spread (`high.range`). */ band: TempSpreadBand; /** * Named outlier model ("driven by X"), set only when the band is * `divergent` AND removing the candidate (farthest-from-others'-median * high) drops the band at least one level. Otherwise undefined, and * `outlierUnnamed` signals the unnamed "models broadly split" case. */ outlierModel?: string; outlierUnnamed: boolean; /** Per-model daily-high values, for `detail: "full"` rendering. */ perModelHigh: ModelValue[]; /** Per-model daily-low values, for `detail: "full"` rendering. */ perModelLow: ModelValue[]; } export interface PrecipitationComparison { /** Models predicting measurable precipitation (>= threshold, see `precipThreshold`). */ wetCount: number; /** Models with a non-null `precipitation_sum` for this day. */ wetParticipantCount: number; sum: StatSummary; /** Undefined when no participating model published a probability value that day (e.g. UKMO, every day). */ probability?: StatSummary; perModelSum: ModelValue[]; perModelProbability: ModelValue[]; } export interface WindComparison { max: StatSummary; perModel: ModelValue[]; } export interface ConditionsComparison { /** Modal (most common) weather-code bucket among participating models. */ bucket: WeatherCodeBucket; count: number; participantCount: number; /** Up to 2 non-modal models, named with their raw WMO code (never a description — A4). */ dissenters: { model: string; code: number; }[]; perModel: { model: string; code: number; }[]; } export interface DayComparison { date: string; /** Null when best_match's daily high is null for this day — the Best-match line is omitted (D6). */ bestMatch: BestMatchDay | null; /** Comparison models (best_match excluded) with a non-null `temperature_2m_max` this day — the trimming anchor (D4 level 3 / A5). */ participantCount: number; /** Size of the curated comparison set (5), for "(N of 5 models)" headings. */ totalModels: number; temperature: TemperatureComparison; precipitation: PrecipitationComparison; wind: WindComparison; conditions: ConditionsComparison; agreement: AgreementLabel; } export interface ModelComparisonResult { days: DayComparison[]; /** Comparison models dropped before any stats because every requested variable was entirely null (D4 level 1). */ droppedModels: string[]; /** Trailing days dropped because fewer than 2 comparison models had a non-null `temperature_2m_max` (D4 level 3). */ trimmedDays: number; /** Size of the curated comparison set (5) before any drops. */ totalModels: number; } /** * Build a full model-agreement comparison from a raw multi-model `daily` * block (the service's `OpenMeteoModelComparisonResponse.daily`, structurally * matching `RawModelComparisonDaily`) plus the caller's temperature and * precipitation unit preferences. * * Three-level participation (D4): (1) a comparison model whose every * requested variable is entirely null is dropped before any stats and * recorded in `droppedModels`; (2) surviving models participate per-variable * wherever they have data (e.g. UKMO publishes no precipitation * probability); (3) per-day participation is anchored on * `temperature_2m_max` only (A5) — trailing days with fewer than 2 * participating comparison models are trimmed (`trimmedDays`), while * interior gaps are retained and render with their reduced count. * `best_match` is excluded from every statistic, band, participation count, * and trimming decision (D6); it is carried through as a per-day reference * value only and may be null for a day. */ export declare function buildModelComparison(daily: RawModelComparisonDaily, tempUnit: 'F' | 'C', precipUnit: 'inch' | 'mm'): ModelComparisonResult; //# sourceMappingURL=modelComparison.d.ts.map