/** * Pure daily aggregation for MET Norway's Locationforecast `complete` product. * * Zero network, zero caching: a parsed response in, a typed daily aggregate (or * a thrown, fixed, sanitized message) out. The service layer owns fetching, * conditional revalidation and the contract-vs-garnish decision; this module * owns every shape and size constant and every aggregation decision. The * service imports the constants from here, never the reverse * (`src/utils/jmaParse.ts` is the in-repo precedent). * * **met.no publishes no daily product.** Daily values are aggregated from * `properties.timeseries`, and three properties of that series — all measured * live 2026-09-03 at Oslo, Tokyo and Denver, recorded in * `.devdocs/plan-metno-fallback.md` D3 — make the aggregation less obvious than * it looks: * * 1. **The series step changes partway through, and the seam moves by * location** — h+53 at Oslo, h+65 at Tokyo and Denver, so 2.2 to 2.7 days * rather than the ~3 days earlier research recorded. The step is therefore * read from the timestamps, per entry, as the gap to the *next* entry. A * hard-coded index or a hard-coded day count is wrong. * 2. **One rendered temperature column is fed by two upstream sources.** * `next_1_hours.details` is precipitation-only and carries no temperature at * all; only `next_6_hours` / `next_12_hours` carry `air_temperature_max` and * `_min`. Inside the hourly segment the daily extremes therefore come from * `instant.air_temperature`, and beyond the seam from the window extrema. * Which source fed a day is returned on the day (`temperatureBasis`) rather * than rendered here. * 3. **The final entry carries no aggregation window at all.** A loop that * assumes every entry has a `next_*` block emits a degenerate final day, so * coverage is accumulated per day and incomplete trailing days are dropped. * * **This module computes in SI and returns SI.** It does not round, does not * band and does not convert: the caller's unit preferences are not its business * and the conversion and the rounding both happen once, at the render site * (G69). Nothing here imports `src/utils/unitFormat.ts`. * * **Every numeric field is read through `finiteNumber`.** met.no encodes "not * recorded" as JSON `null`, which survives a `!== undefined` guard and then * coerces to `0` in arithmetic — the shape behind the v1.20.0 F1 bug and the * normals-averaging bug. A real `0 °C` is a valid reading and must survive, so * the guard tests for a finite number rather than for truthiness (G56). */ import type { MetnoForecastResponse } from '../types/metno.js'; /** * Maximum accepted byte size of a Locationforecast response. * * Mirrors `JMA_MAX_DOCUMENT_BYTES`. The service records the payload it actually * observed in its own `maxContentLength` comment, so the cap is known to sit * above a measured figure rather than at a default. */ export declare const METNO_MAX_RESPONSE_BYTES = 2000000; /** * Maximum number of `properties.timeseries` entries aggregated from one * response. * * Defence in depth, not a working limit: the series ran to 85-92 entries at the * three points measured for D3, so ~5x headroom bounds the loop against a * pathological response without ever trimming a real one. The run's live * verification re-measures the entry count per probe and records it. * * A trim is disclosed, never silent — and it trims a render list, it is never * used as a membership test (G8). */ export declare const METNO_MAX_TIMESERIES_ENTRIES = 500; /** Which upstream field(s) fed a day's rendered min/max — see the seam note above. */ export type MetnoTemperatureBasis = 'instant' | 'window' | 'mixed'; /** One aggregated local day. Every value is SI and unrounded. */ export interface MetnoDailyForecast { /** Local calendar date, `YYYY-MM-DD`, in the timezone this aggregate was built for. */ date: string; /** ISO timestamp of the first entry attributed to this day, in that timezone. */ startsAt: string; /** * Hours of the day covered by published aggregation windows. * * **This can exceed 24 on the day the seam falls in**, and that is the * attribution rule showing through rather than a miscount: a 6-hour window * starting at 20:00 local is attributed wholly to the day it starts in, so a * day holding both hourly entries and four 6-hour windows measures 26. The * hours it borrows are hours the following day does not also count, so no * value is read twice. */ hoursCovered: number; /** `hoursCovered` reaches a whole day. A leading partial day is kept; see `aggregateMetnoDaily`. */ complete: boolean; /** Highest temperature, °C. */ temperatureMaxC?: number; /** Lowest temperature, °C. */ temperatureMinC?: number; /** Which source(s) fed the two fields above. Absent when neither was published. */ temperatureBasis?: MetnoTemperatureBasis; /** Total precipitation, mm, summed over this day's non-overlapping windows. */ precipitationMm?: number; /** Highest published probability of precipitation, percent. */ precipitationProbabilityMaxPct?: number; /** Highest published probability of thunder, percent. */ thunderProbabilityMaxPct?: number; /** Highest instantaneous wind speed, m/s. */ windSpeedMaxMps?: number; /** Wind direction at the strongest instantaneous reading, degrees from north. */ windFromDirectionDeg?: number; /** met.no's own `symbol_code` for the window nearest local midday. Not a WMO code. */ symbolCode?: string; } /** The whole aggregate. */ export interface MetnoDailyAggregate { /** The timezone every `date` and `startsAt` above is expressed in. */ timezone: string; /** * Model elevation of the point, metres, from `geometry.coordinates[2]`. * * The tuple is GeoJSON order — **longitude, latitude, altitude** — which is * the reverse of every other coordinate pair in this project, so it is read * positionally at index 2 and not by convention. */ elevationM?: number; /** The days served, in ascending order, trailing incomplete days already dropped. */ days: MetnoDailyForecast[]; /** How many of `days` cover a whole day. Never larger than `days.length`. */ completeDayCount: number; /** Entries the response carried, before any trim. */ entriesSeen: number; /** Entries actually aggregated, after the trim and after unusable timestamps were skipped. */ entriesAggregated: number; /** The response carried more than `METNO_MAX_TIMESERIES_ENTRIES` and was trimmed. */ entriesTrimmed: boolean; } /** * Aggregate a Locationforecast response into local days. * * **Attribution is by window start.** Every value an entry publishes is * attributed to the local day its own timestamp falls in, including a window * that runs past local midnight. Splitting a window would be exact for a sum * and meaningless for a min/max, so one rule is applied to all of them and * stated here rather than varying by field. * * **A leading partial day is kept; trailing partial days are dropped.** The * series begins at the current hour by construction, so the first local day is * always short — that is "today", which every forecast product carries, and * dropping it would answer a different question than the caller asked. The * trailing partial day is the artifact D3 names: the final entry has no window, * so the last day's coverage falls short and it is removed. Both cases are * decided by the same accumulated-coverage test rather than by position, and * `complete` plus `hoursCovered` ride on every day so the render site can * disclose rather than having a sentence baked in here. * * Throws a fixed message when the response cannot be aggregated at all. A * response that parsed but carries no usable series is a *shape* failure, not * "no forecast" — and this path exists to answer an outage, so an empty-looking * success would be the worst possible answer. * * @param response The parsed Locationforecast body. * @param timezone An IANA zone the days are expressed in. */ export declare function aggregateMetnoDaily(response: MetnoForecastResponse, timezone: string): MetnoDailyAggregate; /** * An English gloss for a met.no `symbol_code`. * * **An unmapped code comes back verbatim.** met.no may add vocabulary at any * time, and a code the reader can look up is strictly better than a blank or a * thrown error on a path that exists to answer an outage. */ export declare function describeMetnoSymbol(symbolCode: string): string; //# sourceMappingURL=metnoParse.d.ts.map