diff --git a/app/domain/meteo/cache.ts b/app/domain/meteo/cache.ts new file mode 100644 index 0000000..ff13f8f --- /dev/null +++ b/app/domain/meteo/cache.ts @@ -0,0 +1,67 @@ +import type { MeteoProvider, MeteoProviderContext, MeteoProviderSnapshot, MeteoProviderStation } from "./provider"; + +export type MeteoProviderCacheOptions = { + ttlMs: number; + timestampBucketMs?: number; + coordinatePrecision?: number; + now?: () => number; +}; + +type CacheEntry = { + expiresAt: number; + value: Promise; +}; + +const defaultTimestampBucketMs = 15 * 60 * 1_000; +const defaultCoordinatePrecision = 3; + +export function createCachedMeteoProvider(provider: MeteoProvider, options: MeteoProviderCacheOptions): MeteoProvider { + const now = options.now ?? Date.now; + const stationCache = new Map>(); + const snapshotCache = new Map>(); + + return { + ...provider, + findStations: provider.findStations + ? (request) => readThroughCache(stationCache, cacheKey(provider.name, "stations", request, options), now, options.ttlMs, () => provider.findStations?.(request) ?? Promise.resolve([])) + : undefined, + getSnapshot(request) { + return readThroughCache(snapshotCache, cacheKey(provider.name, "snapshot", request, options), now, options.ttlMs, () => provider.getSnapshot(request)); + }, + }; +} + +function readThroughCache( + cache: Map>, + key: string, + now: () => number, + ttlMs: number, + load: () => Promise, +) { + const currentTime = now(); + const cached = cache.get(key); + if (cached && cached.expiresAt > currentTime) return cached.value; + + const value = load(); + cache.set(key, { value, expiresAt: currentTime + ttlMs }); + value.catch(() => cache.delete(key)); + return value; +} + +function cacheKey(providerName: string, operation: string, request: MeteoProviderContext, options: MeteoProviderCacheOptions) { + const precision = options.coordinatePrecision ?? defaultCoordinatePrecision; + const timestampBucketMs = options.timestampBucketMs ?? defaultTimestampBucketMs; + const timestampBucket = Math.floor(request.timestamp.getTime() / timestampBucketMs); + + return JSON.stringify({ + providerName, + operation, + latitude: request.latitude.toFixed(precision), + longitude: request.longitude.toFixed(precision), + timestampBucket, + mode: request.mode, + maxObservationAgeMinutes: request.maxObservationAgeMinutes, + maxStationDistanceNm: request.maxStationDistanceNm, + allowFallbackEstimate: request.allowFallbackEstimate, + }); +} diff --git a/app/domain/meteo/default-service.ts b/app/domain/meteo/default-service.ts new file mode 100644 index 0000000..105eb12 --- /dev/null +++ b/app/domain/meteo/default-service.ts @@ -0,0 +1,80 @@ +import { createCachedMeteoProvider } from "./cache"; +import { createLocalAstronomyProvider } from "./local-astronomy"; +import { createMetarProvider } from "./metar"; +import { createNoaaCoopsProvider } from "./noaa-coops"; +import { createNoaaNdbcProvider } from "./noaa-ndbc"; +import { createOpenMeteoProvider } from "./open-meteo"; +import { createMeteoService, type MeteoProvider, type MeteoServiceOptions } from "./provider"; +import type { MeteoService, MeteoSnapshotRequest } from "./types"; + +export type FreeMeteoProviderName = "noaa-ndbc" | "noaa-coops" | "metar" | "local-astronomy" | "open-meteo"; + +export type FreeMeteoServiceOptions = { + fetcher?: typeof fetch; + enabledProviders?: FreeMeteoProviderName[]; + metarStationIds?: string[]; + maxObservationAgeMinutes?: number; + maxStationDistanceNm?: number; + allowFallbackEstimate?: boolean; + cacheTtlMs?: number; +}; + +export const defaultFreeMeteoProviderOrder: FreeMeteoProviderName[] = [ + "noaa-ndbc", + "noaa-coops", + "metar", + "local-astronomy", + "open-meteo", +]; + +export const defaultFreeMeteoRequestOptions = { + maxObservationAgeMinutes: 120, + maxStationDistanceNm: 50, + allowFallbackEstimate: true, +} as const; + +export function createFreeMeteoService(options: FreeMeteoServiceOptions = {}): MeteoService { + const providers = createFreeMeteoProviders(options); + return withDefaultRequestOptions(createMeteoService({ providers }), options); +} + +export function createFreeMeteoProviders(options: FreeMeteoServiceOptions = {}): MeteoProvider[] { + const enabledProviders = options.enabledProviders ?? defaultFreeMeteoProviderOrder; + const enabled = new Set(enabledProviders); + const providerFactories: Record MeteoProvider> = { + "noaa-ndbc": () => createNoaaNdbcProvider({ fetcher: options.fetcher }), + "noaa-coops": () => createNoaaCoopsProvider({ fetcher: options.fetcher }), + metar: () => createMetarProvider({ fetcher: options.fetcher, stationIds: options.metarStationIds }), + "local-astronomy": createLocalAstronomyProvider, + "open-meteo": () => createOpenMeteoProvider({ fetcher: options.fetcher }), + }; + + const providers = defaultFreeMeteoProviderOrder + .filter((providerName) => enabled.has(providerName)) + .map((providerName) => providerFactories[providerName]()); + + if (!options.cacheTtlMs) return providers; + return providers.map((provider) => createCachedMeteoProvider(provider, { ttlMs: options.cacheTtlMs ?? 0 })); +} + +function withDefaultRequestOptions(service: MeteoService, options: FreeMeteoServiceOptions): MeteoService { + return { + getSnapshot(request) { + return service.getSnapshot(applyDefaultRequestOptions(request, options)); + }, + }; +} + +function applyDefaultRequestOptions(request: MeteoSnapshotRequest, options: FreeMeteoServiceOptions): MeteoSnapshotRequest { + const mode = request.mode ?? "auto"; + return { + ...request, + mode, + maxObservationAgeMinutes: request.maxObservationAgeMinutes ?? options.maxObservationAgeMinutes ?? defaultFreeMeteoRequestOptions.maxObservationAgeMinutes, + maxStationDistanceNm: request.maxStationDistanceNm ?? options.maxStationDistanceNm ?? defaultFreeMeteoRequestOptions.maxStationDistanceNm, + allowFallbackEstimate: request.allowFallbackEstimate ?? options.allowFallbackEstimate ?? mode !== "observed-only", + }; +} + + +export type { MeteoServiceOptions }; diff --git a/app/domain/meteo/index.ts b/app/domain/meteo/index.ts new file mode 100644 index 0000000..acc73ab --- /dev/null +++ b/app/domain/meteo/index.ts @@ -0,0 +1,61 @@ +export { createCachedMeteoProvider } from "./cache"; +export { createFreeMeteoProviders, createFreeMeteoService, defaultFreeMeteoProviderOrder, defaultFreeMeteoRequestOptions } from "./default-service"; +export { calculateMoonPhase, createLocalAstronomyProvider } from "./local-astronomy"; +export { meteoSnapshotToLogLineAutofill } from "./log-line-autofill"; +export { createMetarProvider, fetchMetarObservations, parseMetarObservations } from "./metar"; +export { createNoaaCoopsProvider, fetchNoaaCoopsStations, parseNoaaCoopsStations } from "./noaa-coops"; +export { createOpenMeteoProvider } from "./open-meteo"; +export { createNoaaNdbcProvider, fetchLatestNoaaNdbcObservations, parseNoaaNdbcLatestObservations } from "./noaa-ndbc"; +export { createMeteoProvider, createMeteoService } from "./provider"; +export { createMeteoSourceRemark, createMeteoSourceRemarkParts } from "./remarks"; + +export type { + MeteoAstronomy, + MeteoProviderName, + MeteoProviderReference, + MeteoReason, + MeteoReasonCode, + MeteoQuality, + MeteoSea, + MeteoService, + MeteoSnapshot, + MeteoSnapshotMode, + MeteoSnapshotRequest, + MeteoSource, + MeteoSourceType, + MeteoStationReference, + MeteoTide, + MeteoUnit, + MeteoValue, + MeteoValueProvenance, + MeteoWeather, + MeteoWind, +} from "./types"; + +export type { + MeteoCapability, + MeteoProvider, + MeteoProviderCapabilities, + MeteoProviderContext, + MeteoProviderSnapshot, + MeteoProviderStation, + MeteoServiceOptions, +} from "./provider"; + +export type { MoonPhase, MoonPhaseDetails } from "./local-astronomy"; + +export type { NoaaNdbcObservation, NoaaNdbcProviderOptions } from "./noaa-ndbc"; + +export type { NoaaCoopsProviderOptions, NoaaCoopsStation } from "./noaa-coops"; + +export type { MetarObservation, MetarProviderOptions } from "./metar"; + +export type { OpenMeteoProviderOptions } from "./open-meteo"; + +export type { FreeMeteoProviderName, FreeMeteoServiceOptions } from "./default-service"; + +export type { MeteoProviderCacheOptions } from "./cache"; + +export type { MeteoRemarkFieldKey, MeteoSourceRemarkPart } from "./remarks"; + +export type { MeteoLogLineAutofill, MeteoLogLineAutofillOptions } from "./log-line-autofill"; diff --git a/app/domain/meteo/local-astronomy.ts b/app/domain/meteo/local-astronomy.ts new file mode 100644 index 0000000..99b681e --- /dev/null +++ b/app/domain/meteo/local-astronomy.ts @@ -0,0 +1,103 @@ +import type { MeteoProvider, MeteoProviderContext } from "./provider"; +import { createMeteoProvider } from "./provider"; + +const knownNewMoon = Date.UTC(2000, 0, 6, 18, 14); +const lunarCycleDays = 29.530588853; +const millisecondsPerDay = 86_400_000; + +export type MoonPhase = + | "new moon" + | "waxing crescent" + | "first quarter" + | "waxing gibbous" + | "full moon" + | "waning gibbous" + | "last quarter" + | "waning crescent"; + +export type MoonPhaseDetails = { + phase: MoonPhase; + ageDays: number; + illuminationPercent: number; +}; + +export function calculateMoonPhase(date: Date): MoonPhaseDetails { + const ageDays = calculateMoonAgeDays(date); + const phase = moonPhaseName(ageDays); + const illuminationPercent = Math.round((1 - Math.cos((2 * Math.PI * ageDays) / lunarCycleDays)) * 50); + + return { phase, ageDays, illuminationPercent }; +} + +export function createLocalAstronomyProvider(): MeteoProvider { + return createMeteoProvider({ + name: "local-astronomy", + label: "Local astronomy calculation", + sourceType: "calculated", + capabilities: { astronomy: true }, + async getSnapshot(context) { + const phase = calculateMoonPhase(context.timestamp); + const provenance = createLocalAstronomyProvenance(context); + + return { + validAt: context.timestamp, + astronomy: { + moonPhase: { + value: phase.phase, + unit: "text", + provenance, + }, + moonIlluminationPercent: { + value: phase.illuminationPercent, + unit: "%", + provenance, + }, + }, + sources: [{ + provider: "local-astronomy", + providerLabel: "Local astronomy calculation", + sourceType: "calculated", + latitude: context.latitude, + longitude: context.longitude, + calculatedAt: new Date(), + validAt: context.timestamp, + quality: "medium", + qualityReason: { code: "meteo.reason.localAstronomyCalculated" }, + }], + }; + }, + }); +} + +function createLocalAstronomyProvenance(context: MeteoProviderContext) { + return { + provider: "local-astronomy", + providerLabel: "Local astronomy calculation", + sourceType: "calculated", + calculatedAt: new Date(), + validAt: context.timestamp, + quality: "medium", + qualityReason: { code: "meteo.reason.localAstronomyCalculated" }, + } as const; +} + +function calculateMoonAgeDays(date: Date) { + const daysSinceKnownNewMoon = (date.getTime() - knownNewMoon) / millisecondsPerDay; + return positiveModulo(daysSinceKnownNewMoon, lunarCycleDays); +} + +function positiveModulo(value: number, divisor: number) { + return ((value % divisor) + divisor) % divisor; +} + +function moonPhaseName(ageDays: number): MoonPhase { + if (ageDays < 1.84566) return "new moon"; + if (ageDays < 5.53699) return "waxing crescent"; + if (ageDays < 9.22831) return "first quarter"; + if (ageDays < 12.91963) return "waxing gibbous"; + if (ageDays < 16.61096) return "full moon"; + if (ageDays < 20.30228) return "waning gibbous"; + if (ageDays < 23.99361) return "last quarter"; + if (ageDays < 27.68493) return "waning crescent"; + return "new moon"; +} diff --git a/app/domain/meteo/log-line-autofill.ts b/app/domain/meteo/log-line-autofill.ts new file mode 100644 index 0000000..cfbacb6 --- /dev/null +++ b/app/domain/meteo/log-line-autofill.ts @@ -0,0 +1,130 @@ +import type { LineForm, TemperatureUnit, WindUnit } from "../../models/logbook"; +import type { MeteoSnapshot } from "./types"; +import { createMeteoSourceRemarkParts, type MeteoSourceRemarkPart } from "./remarks"; + +export type MeteoLogLineAutofillOptions = { + windUnit?: WindUnit; + seaUnit?: "m" | "ft"; + tideUnit?: "m" | "ft"; + temperatureUnit?: TemperatureUnit; +}; + +export type MeteoLogLineAutofill = { + fields: Partial; + remarkParts: MeteoSourceRemarkPart[]; +}; + +export function meteoSnapshotToLogLineAutofill( + snapshot: MeteoSnapshot, + options: MeteoLogLineAutofillOptions = {}, +): MeteoLogLineAutofill { + const windUnit = options.windUnit ?? "bft"; + const seaUnit = options.seaUnit ?? "m"; + const tideUnit = options.tideUnit ?? "m"; + const temperatureUnit = options.temperatureUnit ?? "°C"; + const fields: Partial = {}; + + setField(fields, "weather", weatherSymbol(snapshot.weather?.condition?.value, snapshot.weather?.cloudCoverPercent?.value)); + setField(fields, "temperature", convertTemperature(snapshot.weather?.temperatureC?.value, temperatureUnit)); + setField(fields, "temperatureUnit", temperatureUnit); + setField(fields, "barometer", rounded(snapshot.weather?.pressureHpa?.value)); + setField(fields, "windDirection", compassDirection(snapshot.wind?.directionDeg?.value)); + setField(fields, "windStrength", convertWind(snapshot.wind?.speedKnots?.value, windUnit)); + setField(fields, "windUnit", windUnit); + setField(fields, "waves", convertDistance(snapshot.sea?.waveHeightM?.value, seaUnit)); + setField(fields, "seaUnit", seaUnit); + setField(fields, "tide", convertDistance(snapshot.tide?.heightM?.value, tideUnit)); + setField(fields, "tideUnit", tideUnit); + setField(fields, "moon", moonSymbol(snapshot.astronomy?.moonPhase?.value)); + + return { fields, remarkParts: createMeteoSourceRemarkParts(snapshot) }; +} + +function setField(fields: Partial, field: TField, value: string | number | undefined) { + if (value !== undefined && value !== "") fields[field] = String(value); +} + +function rounded(value: number | undefined) { + return value === undefined ? undefined : Math.round(value); +} + +function convertTemperature(valueC: number | undefined, unit: TemperatureUnit) { + if (valueC === undefined) return undefined; + const value = unit === "f" || unit === "°F" ? (valueC * 9) / 5 + 32 : valueC; + return Math.round(value); +} + +function convertWind(valueKnots: number | undefined, unit: WindUnit) { + if (valueKnots === undefined) return undefined; + switch (unit) { + case "bft": + return knotsToBeaufort(valueKnots); + case "km/h": + return roundOne(valueKnots * 1.852); + case "mp/h": + return roundOne(valueKnots * 1.150779448); + case "m/s": + return roundOne(valueKnots * 0.514444444); + case "kn": + return roundOne(valueKnots); + } +} + +function convertDistance(valueM: number | undefined, unit: "m" | "ft") { + if (valueM === undefined) return undefined; + return roundOne(unit === "ft" ? valueM * 3.280839895 : valueM); +} + +function roundOne(value: number) { + return Math.round(value * 10) / 10; +} + +function compassDirection(degrees: number | undefined) { + if (degrees === undefined) return undefined; + const directions = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]; + return directions[Math.round((((degrees % 360) + 360) % 360) / 45) % directions.length]; +} + +function knotsToBeaufort(knots: number) { + const thresholds = [1, 4, 7, 11, 17, 22, 28, 34, 41, 48, 56, 64]; + const force = thresholds.findIndex((threshold) => knots < threshold); + return force === -1 ? 12 : force; +} + +function weatherSymbol(condition: string | undefined, cloudCoverPercent: number | undefined) { + const normalized = condition?.toLowerCase() ?? ""; + if (normalized.includes("thunder")) return "⛈️"; + if (normalized.includes("snow")) return "🌨️"; + if (normalized.includes("rain") || normalized.includes("drizzle") || normalized.includes("shower")) return "🌦️"; + if (normalized.includes("fog") || normalized.includes("mist")) return "🌫️"; + if (cloudCoverPercent !== undefined) { + if (cloudCoverPercent < 15) return "☀️"; + if (cloudCoverPercent < 50) return "🌤️"; + if (cloudCoverPercent < 85) return "⛅"; + return "☁️"; + } + return condition ? "⛅" : undefined; +} + +function moonSymbol(phase: string | undefined) { + switch (phase) { + case "new moon": + return "🌑"; + case "waxing crescent": + return "🌒"; + case "first quarter": + return "🌓"; + case "waxing gibbous": + return "🌔"; + case "full moon": + return "🌕"; + case "waning gibbous": + return "🌖"; + case "last quarter": + return "🌗"; + case "waning crescent": + return "🌘"; + default: + return undefined; + } +} diff --git a/app/domain/meteo/metar.ts b/app/domain/meteo/metar.ts new file mode 100644 index 0000000..8baaf89 --- /dev/null +++ b/app/domain/meteo/metar.ts @@ -0,0 +1,231 @@ +import { calculateGlobeDistanceNm } from "../nautical/globe-distance"; +import type { Position } from "../nautical/position"; +import type { MeteoProvider, MeteoProviderContext } from "./provider"; +import { createMeteoProvider } from "./provider"; +import type { MeteoSource, MeteoValueProvenance } from "./types"; + +const metarEndpoint = "https://aviationweather.gov/api/data/metar"; +const statuteMileToMeters = 1_609.344; +const inchesMercuryToHpa = 33.8638866667; + +export type MetarProviderOptions = { + fetcher?: typeof fetch; + endpoint?: string; + stationIds?: string[]; +}; + +export type MetarObservation = Position & { + stationId: string; + rawText?: string; + observedAt: Date; + cloudCoverPercent?: number; + condition?: string; + pressureHpa?: number; + temperatureC?: number; + visibilityM?: number; + windDirectionDeg?: number; + windSpeedKnots?: number; + windGustKnots?: number; +}; + +type AviationWeatherMetar = { + icaoId?: string; + rawOb?: string; + obsTime?: string; + lat?: number | string; + lon?: number | string; + wdir?: number | string; + wspd?: number | string; + wgst?: number | string; + altim?: number | string; + temp?: number | string; + visib?: number | string; + wxString?: string; + clouds?: Array<{ cover?: string }>; +}; + +export function createMetarProvider(options: MetarProviderOptions = {}): MeteoProvider { + const fetcher = options.fetcher ?? fetch; + const endpoint = options.endpoint ?? metarEndpoint; + const stationIds = options.stationIds ?? []; + + return createMeteoProvider({ + name: "metar", + label: "AviationWeather METAR", + sourceType: "observed", + capabilities: { weather: true, wind: true }, + async getSnapshot(context) { + if (stationIds.length === 0) { + return { warnings: [{ code: "meteo.reason.metarStationIdsRequired" }] }; + } + + const observations = await fetchMetarObservations(stationIds, fetcher, endpoint); + const nearest = nearbyFreshObservations(observations, context)[0]; + if (!nearest) { + return { warnings: [{ code: "meteo.reason.noFreshMetarObservation" }] }; + } + + const { observation, distanceNm } = nearest; + const provenance = metarProvenance(observation, distanceNm); + const source = metarSource(observation, distanceNm); + + return { + validAt: observation.observedAt, + weather: { + cloudCoverPercent: value(observation.cloudCoverPercent, "%", provenance), + condition: value(observation.condition, "text", provenance), + pressureHpa: value(observation.pressureHpa, "hPa", provenance), + temperatureC: value(observation.temperatureC, "c", provenance), + visibilityM: value(observation.visibilityM, "m", provenance), + }, + wind: { + directionDeg: value(observation.windDirectionDeg, "deg", provenance), + speedKnots: value(observation.windSpeedKnots, "kn", provenance), + gustKnots: value(observation.windGustKnots, "kn", provenance), + }, + sources: [source], + }; + }, + }); +} + +export async function fetchMetarObservations(stationIds: string[], fetcher: typeof fetch = fetch, endpoint = metarEndpoint) { + const url = new URL(endpoint); + url.searchParams.set("ids", stationIds.join(",")); + url.searchParams.set("format", "json"); + + const response = await fetcher(url); + if (response.status === 204) return []; + if (!response.ok) { + throw new Error(`METAR request failed with status ${response.status}.`); + } + + return parseMetarObservations(await response.json() as AviationWeatherMetar[]); +} + +export function parseMetarObservations(reports: AviationWeatherMetar[]): MetarObservation[] { + return reports.flatMap((report) => { + const latitude = numberFrom(report.lat); + const longitude = numberFrom(report.lon); + const observedAt = report.obsTime ? new Date(report.obsTime) : undefined; + if (!report.icaoId || latitude === undefined || longitude === undefined || !observedAt) return []; + + return [{ + stationId: report.icaoId, + rawText: report.rawOb, + latitude, + longitude, + observedAt, + cloudCoverPercent: cloudCoverPercent(report.clouds), + condition: report.wxString || report.rawOb, + pressureHpa: pressureHpa(report.altim), + temperatureC: numberFrom(report.temp), + visibilityM: visibilityM(report.visib), + windDirectionDeg: numberFrom(report.wdir), + windSpeedKnots: numberFrom(report.wspd), + windGustKnots: numberFrom(report.wgst), + }]; + }); +} + +function nearbyFreshObservations(observations: MetarObservation[], context: MeteoProviderContext) { + const maxAgeMinutes = context.maxObservationAgeMinutes ?? 120; + const maxDistanceNm = context.maxStationDistanceNm ?? 50; + const requestedPosition = { latitude: context.latitude, longitude: context.longitude }; + + return observations + .map((observation) => ({ + observation, + distanceNm: calculateGlobeDistanceNm(requestedPosition, observation), + ageMinutes: Math.abs(context.timestamp.getTime() - observation.observedAt.getTime()) / 60_000, + })) + .filter(({ distanceNm, ageMinutes }) => distanceNm <= maxDistanceNm && ageMinutes <= maxAgeMinutes) + .sort((left, right) => left.distanceNm - right.distanceNm || left.ageMinutes - right.ageMinutes); +} + +function metarProvenance(observation: MetarObservation, distanceNm: number): MeteoValueProvenance { + return { + provider: "metar", + providerLabel: "AviationWeather METAR", + sourceType: "observed", + sourceUrl: `${metarEndpoint}?ids=${observation.stationId}&format=json`, + station: { + id: observation.stationId, + latitude: observation.latitude, + longitude: observation.longitude, + distanceNm, + }, + observedAt: observation.observedAt, + validAt: observation.observedAt, + quality: "medium", + qualityReason: { code: "meteo.reason.metarAirportBased" }, + raw: observation.rawText, + }; +} + +function metarSource(observation: MetarObservation, distanceNm: number): MeteoSource { + return { + provider: "metar", + providerLabel: "AviationWeather METAR", + sourceType: "observed", + sourceUrl: `${metarEndpoint}?ids=${observation.stationId}&format=json`, + id: observation.stationId, + latitude: observation.latitude, + longitude: observation.longitude, + observedAt: observation.observedAt, + validAt: observation.observedAt, + distanceNm, + quality: "medium", + qualityReason: { code: "meteo.reason.metarAirportBased" }, + }; +} + +function value( + input: TValue | undefined, + unit: TUnit, + provenance: MeteoValueProvenance, +) { + if (input === undefined) return undefined; + return { value: input, unit, provenance }; +} + +function cloudCoverPercent(clouds: AviationWeatherMetar["clouds"]) { + if (!clouds?.length) return undefined; + return Math.max(...clouds.map((cloud) => cloudCoverValue(cloud.cover)).filter((value) => value !== undefined)); +} + +function cloudCoverValue(cover: string | undefined) { + switch (cover) { + case "CLR": + case "SKC": + return 0; + case "FEW": + return 25; + case "SCT": + return 50; + case "BKN": + return 75; + case "OVC": + return 100; + default: + return undefined; + } +} + +function pressureHpa(value: string | number | undefined) { + const parsed = numberFrom(value); + if (parsed === undefined) return undefined; + return parsed > 100 ? parsed : parsed * inchesMercuryToHpa; +} + +function visibilityM(value: string | number | undefined) { + const parsed = numberFrom(value); + if (parsed === undefined) return undefined; + return parsed <= 100 ? parsed * statuteMileToMeters : parsed; +} + +function numberFrom(value: string | number | undefined) { + if (value === undefined || value === "" || value === "M") return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} diff --git a/app/domain/meteo/noaa-coops.ts b/app/domain/meteo/noaa-coops.ts new file mode 100644 index 0000000..5c1eb80 --- /dev/null +++ b/app/domain/meteo/noaa-coops.ts @@ -0,0 +1,183 @@ +import { calculateGlobeDistanceNm } from "../nautical/globe-distance"; +import type { Position } from "../nautical/position"; +import type { MeteoProvider, MeteoProviderContext } from "./provider"; +import { createMeteoProvider } from "./provider"; +import type { MeteoSource, MeteoValueProvenance } from "./types"; + +const dataApiEndpoint = "https://api.tidesandcurrents.noaa.gov/api/prod/datagetter"; +const stationsEndpoint = "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations.json?type=waterlevels"; + +export type NoaaCoopsProviderOptions = { + fetcher?: typeof fetch; + dataEndpoint?: string; + stationsEndpoint?: string; +}; + +export type NoaaCoopsStation = Position & { + id: string; + name?: string; +}; + +type NoaaCoopsStationResponse = { + stations?: Array<{ + id?: string; + name?: string; + lat?: string | number; + lng?: string | number; + }>; +}; + +type NoaaCoopsDataResponse = { + data?: Array<{ t?: string; v?: string }>; + predictions?: Array<{ t?: string; v?: string }>; + error?: { message?: string }; +}; + +export function createNoaaCoopsProvider(options: NoaaCoopsProviderOptions = {}): MeteoProvider { + const fetcher = options.fetcher ?? fetch; + const stationSource = options.stationsEndpoint ?? stationsEndpoint; + const dataSource = options.dataEndpoint ?? dataApiEndpoint; + + return createMeteoProvider({ + name: "noaa-coops", + label: "NOAA Tides & Currents", + sourceType: "observed", + capabilities: { tide: true }, + async findStations(context) { + const stations = await fetchNoaaCoopsStations(fetcher, stationSource); + return nearbyStations(stations, context).map(({ station, distanceNm }) => ({ ...station, distanceNm })); + }, + async getSnapshot(context) { + const stations = await fetchNoaaCoopsStations(fetcher, stationSource); + const nearest = nearbyStations(stations, context)[0]; + + if (!nearest) { + return { warnings: [{ code: "meteo.reason.noCoopsStationNearby" }] }; + } + + const { station, distanceNm } = nearest; + const waterLevel = await fetchNoaaCoopsWaterLevel(station.id, context, fetcher, dataSource); + if (!waterLevel) { + return { warnings: [{ code: "meteo.reason.noCoopsWaterLevel", values: { stationId: station.id } }] }; + } + + const provenance = noaaCoopsProvenance(station, distanceNm, waterLevel.observedAt); + const source = noaaCoopsSource(station, distanceNm, waterLevel.observedAt); + + return { + validAt: waterLevel.observedAt, + tide: { + heightM: { + value: waterLevel.heightM, + unit: "m", + provenance, + }, + }, + sources: [source], + }; + }, + }); +} + +export async function fetchNoaaCoopsStations(fetcher: typeof fetch = fetch, endpoint = stationsEndpoint): Promise { + const response = await fetcher(endpoint); + if (!response.ok) { + throw new Error(`NOAA CO-OPS stations request failed with status ${response.status}.`); + } + + return parseNoaaCoopsStations(await response.json() as NoaaCoopsStationResponse); +} + +export function parseNoaaCoopsStations(response: NoaaCoopsStationResponse): NoaaCoopsStation[] { + return (response.stations ?? []).flatMap((station) => { + const latitude = numberFrom(station.lat); + const longitude = numberFrom(station.lng); + if (!station.id || latitude === undefined || longitude === undefined) return []; + + return [{ + id: station.id, + name: station.name, + latitude, + longitude, + }]; + }); +} + +async function fetchNoaaCoopsWaterLevel(stationId: string, context: MeteoProviderContext, fetcher: typeof fetch, endpoint: string) { + const url = new URL(endpoint); + url.searchParams.set("station", stationId); + url.searchParams.set("product", "water_level"); + url.searchParams.set("application", "ultilog"); + url.searchParams.set("date", "latest"); + url.searchParams.set("datum", "MLLW"); + url.searchParams.set("time_zone", "gmt"); + url.searchParams.set("units", "metric"); + url.searchParams.set("format", "json"); + + const response = await fetcher(url); + if (!response.ok) { + throw new Error(`NOAA CO-OPS water-level request failed with status ${response.status}.`); + } + + const body = await response.json() as NoaaCoopsDataResponse; + if (body.error?.message) { + throw new Error(`NOAA CO-OPS water-level request failed: ${body.error.message}`); + } + + const latest = body.data?.[0]; + const heightM = numberFrom(latest?.v); + const observedAt = latest?.t ? new Date(`${latest.t.replace(" ", "T")}Z`) : undefined; + if (heightM === undefined || !observedAt) return undefined; + + const maxAgeMinutes = context.maxObservationAgeMinutes ?? 120; + const ageMinutes = Math.abs(context.timestamp.getTime() - observedAt.getTime()) / 60_000; + if (ageMinutes > maxAgeMinutes) return undefined; + + return { heightM, observedAt }; +} + +function nearbyStations(stations: NoaaCoopsStation[], context: MeteoProviderContext) { + const maxDistanceNm = context.maxStationDistanceNm ?? 50; + const requestedPosition = { latitude: context.latitude, longitude: context.longitude }; + + return stations + .map((station) => ({ station, distanceNm: calculateGlobeDistanceNm(requestedPosition, station) })) + .filter(({ distanceNm }) => distanceNm <= maxDistanceNm) + .sort((left, right) => left.distanceNm - right.distanceNm); +} + +function noaaCoopsProvenance(station: NoaaCoopsStation, distanceNm: number, observedAt: Date): MeteoValueProvenance { + return { + provider: "noaa-coops", + providerLabel: "NOAA Tides & Currents", + sourceType: "observed", + sourceUrl: `https://tidesandcurrents.noaa.gov/stationhome.html?id=${station.id}`, + station: { ...station, distanceNm }, + observedAt, + validAt: observedAt, + quality: "high", + }; +} + +function noaaCoopsSource(station: NoaaCoopsStation, distanceNm: number, observedAt: Date): MeteoSource { + return { + provider: "noaa-coops", + providerLabel: "NOAA Tides & Currents", + sourceType: "observed", + sourceUrl: `https://tidesandcurrents.noaa.gov/stationhome.html?id=${station.id}`, + id: station.id, + name: station.name, + latitude: station.latitude, + longitude: station.longitude, + observedAt, + validAt: observedAt, + distanceNm, + quality: "high", + }; +} + +function numberFrom(value: string | number | undefined) { + if (value === undefined || value === "") return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} diff --git a/app/domain/meteo/noaa-ndbc.ts b/app/domain/meteo/noaa-ndbc.ts new file mode 100644 index 0000000..2bb3841 --- /dev/null +++ b/app/domain/meteo/noaa-ndbc.ts @@ -0,0 +1,215 @@ +import { calculateGlobeDistanceNm } from "../nautical/globe-distance"; +import type { Position } from "../nautical/position"; +import type { MeteoProvider, MeteoProviderContext } from "./provider"; +import { createMeteoProvider } from "./provider"; +import type { MeteoSource, MeteoValueProvenance } from "./types"; + +const latestObservationsUrl = "https://www.ndbc.noaa.gov/data/latest_obs/latest_obs.txt"; +const metersPerSecondToKnots = 1.9438444924406048; + +export type NoaaNdbcProviderOptions = { + fetcher?: typeof fetch; + latestObservationsEndpoint?: string; +}; + +export type NoaaNdbcObservation = Position & { + stationId: string; + observedAt: Date; + windDirectionDeg?: number; + windSpeedKnots?: number; + windGustKnots?: number; + waveHeightM?: number; + wavePeriodS?: number; + waveDirectionDeg?: number; + pressureHpa?: number; + airTemperatureC?: number; + seaSurfaceTemperatureC?: number; + visibilityM?: number; + tideM?: number; +}; + +export function createNoaaNdbcProvider(options: NoaaNdbcProviderOptions = {}): MeteoProvider { + const fetcher = options.fetcher ?? fetch; + const endpoint = options.latestObservationsEndpoint ?? latestObservationsUrl; + + return createMeteoProvider({ + name: "noaa-ndbc", + label: "NOAA National Data Buoy Center", + sourceType: "observed", + capabilities: { weather: true, wind: true, sea: true, tide: true }, + async findStations(context) { + const observations = await fetchLatestNoaaNdbcObservations(fetcher, endpoint); + return nearbyFreshObservations(observations, context).map(({ observation, distanceNm }) => ({ + id: observation.stationId, + latitude: observation.latitude, + longitude: observation.longitude, + distanceNm, + })); + }, + async getSnapshot(context) { + const observations = await fetchLatestNoaaNdbcObservations(fetcher, endpoint); + const nearest = nearbyFreshObservations(observations, context)[0]; + + if (!nearest) { + return { warnings: [{ code: "meteo.reason.noFreshNdbcObservation" }] }; + } + + const { observation, distanceNm } = nearest; + const provenance = noaaNdbcProvenance(observation, distanceNm); + const source = noaaNdbcSource(observation, distanceNm); + + return { + validAt: observation.observedAt, + weather: { + pressureHpa: value(observation.pressureHpa, "hPa", provenance), + temperatureC: value(observation.airTemperatureC, "c", provenance), + visibilityM: value(observation.visibilityM, "m", provenance), + }, + wind: { + directionDeg: value(observation.windDirectionDeg, "deg", provenance), + speedKnots: value(observation.windSpeedKnots, "kn", provenance), + gustKnots: value(observation.windGustKnots, "kn", provenance), + }, + sea: { + waveHeightM: value(observation.waveHeightM, "m", provenance), + waveDirectionDeg: value(observation.waveDirectionDeg, "deg", provenance), + wavePeriodS: value(observation.wavePeriodS, "s", provenance), + seaSurfaceTemperatureC: value(observation.seaSurfaceTemperatureC, "c", provenance), + }, + tide: { + heightM: value(observation.tideM, "m", provenance), + }, + sources: [source], + }; + }, + }); +} + +export async function fetchLatestNoaaNdbcObservations(fetcher: typeof fetch = fetch, endpoint = latestObservationsUrl) { + const response = await fetcher(endpoint); + if (!response.ok) { + throw new Error(`NOAA NDBC latest observations request failed with status ${response.status}.`); + } + + return parseNoaaNdbcLatestObservations(await response.text()); +} + +export function parseNoaaNdbcLatestObservations(text: string): NoaaNdbcObservation[] { + const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const headerLine = lines.find((line) => line.startsWith("#STN ")); + if (!headerLine) return []; + + const headers = headerLine.replace(/^#/, "").split(/\s+/); + + return lines + .filter((line) => !line.startsWith("#")) + .map((line) => parseNoaaNdbcLatestObservationLine(headers, line)) + .filter((observation): observation is NoaaNdbcObservation => observation !== undefined); +} + +function parseNoaaNdbcLatestObservationLine(headers: string[], line: string): NoaaNdbcObservation | undefined { + const columns = line.split(/\s+/); + const row = Object.fromEntries(headers.map((header, index) => [header, columns[index]])); + const latitude = numeric(row.LAT); + const longitude = numeric(row.LON); + const observedAt = observedAtFromRow(row); + + if (!row.STN || latitude === undefined || longitude === undefined || !observedAt) return undefined; + + return { + stationId: row.STN, + latitude, + longitude, + observedAt, + windDirectionDeg: numeric(row.WDIR), + windSpeedKnots: convert(row.WSPD, metersPerSecondToKnots), + windGustKnots: convert(row.GST, metersPerSecondToKnots), + waveHeightM: numeric(row.WVHT), + wavePeriodS: numeric(row.DPD) ?? numeric(row.APD), + waveDirectionDeg: numeric(row.MWD), + pressureHpa: numeric(row.PRES, 2_000), + airTemperatureC: numeric(row.ATMP), + seaSurfaceTemperatureC: numeric(row.WTMP), + visibilityM: convert(row.VIS, 1_000), + tideM: numeric(row.TIDE), + }; +} + +function nearbyFreshObservations(observations: NoaaNdbcObservation[], context: MeteoProviderContext) { + const maxAgeMinutes = context.maxObservationAgeMinutes ?? 120; + const maxDistanceNm = context.maxStationDistanceNm ?? 50; + const requestedPosition = { latitude: context.latitude, longitude: context.longitude }; + + return observations + .map((observation) => ({ + observation, + distanceNm: calculateGlobeDistanceNm(requestedPosition, observation), + ageMinutes: Math.abs(context.timestamp.getTime() - observation.observedAt.getTime()) / 60_000, + })) + .filter(({ distanceNm, ageMinutes }) => distanceNm <= maxDistanceNm && ageMinutes <= maxAgeMinutes) + .sort((left, right) => left.distanceNm - right.distanceNm || left.ageMinutes - right.ageMinutes); +} + +function noaaNdbcProvenance(observation: NoaaNdbcObservation, distanceNm: number): MeteoValueProvenance { + return { + provider: "noaa-ndbc", + providerLabel: "NOAA National Data Buoy Center", + sourceType: "observed", + sourceUrl: `${latestObservationsUrl}#${observation.stationId}`, + station: { + id: observation.stationId, + latitude: observation.latitude, + longitude: observation.longitude, + distanceNm, + }, + observedAt: observation.observedAt, + validAt: observation.observedAt, + quality: "high", + }; +} + +function noaaNdbcSource(observation: NoaaNdbcObservation, distanceNm: number): MeteoSource { + return { + provider: "noaa-ndbc", + providerLabel: "NOAA National Data Buoy Center", + sourceType: "observed", + sourceUrl: `${latestObservationsUrl}#${observation.stationId}`, + id: observation.stationId, + latitude: observation.latitude, + longitude: observation.longitude, + observedAt: observation.observedAt, + validAt: observation.observedAt, + distanceNm, + quality: "high", + }; +} + +function value( + input: TValue | undefined, + unit: TUnit, + provenance: MeteoValueProvenance, +) { + if (input === undefined) return undefined; + return { value: input, unit, provenance }; +} + +function observedAtFromRow(row: Record) { + const year = numeric(row.YY ?? row.YYYY, Number.POSITIVE_INFINITY); + const month = numeric(row.MM); + const day = numeric(row.DD); + const hour = numeric(row.hh); + const minute = numeric(row.mm); + if (year === undefined || month === undefined || day === undefined || hour === undefined || minute === undefined) return undefined; + return new Date(Date.UTC(year, month - 1, day, hour, minute)); +} + +function convert(valueText: string | undefined, factor: number) { + const value = numeric(valueText); + return value === undefined ? undefined : value * factor; +} + +function numeric(value: string | undefined, maximumValidValue = 999) { + if (!value || value === "MM") return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed < maximumValidValue ? parsed : undefined; +} diff --git a/app/domain/meteo/open-meteo.ts b/app/domain/meteo/open-meteo.ts new file mode 100644 index 0000000..434388a --- /dev/null +++ b/app/domain/meteo/open-meteo.ts @@ -0,0 +1,219 @@ +import type { MeteoProvider, MeteoProviderContext } from "./provider"; +import { createMeteoProvider } from "./provider"; +import type { MeteoSource, MeteoValueProvenance } from "./types"; + +const forecastEndpoint = "https://api.open-meteo.com/v1/forecast"; +const marineEndpoint = "https://marine-api.open-meteo.com/v1/marine"; + +export type OpenMeteoProviderOptions = { + fetcher?: typeof fetch; + forecastEndpoint?: string; + marineEndpoint?: string; +}; + +type OpenMeteoResponse = { + current?: Record; + current_units?: Record; + hourly?: Record; + hourly_units?: Record; + utc_offset_seconds?: number; + timezone?: string; +}; + +export function createOpenMeteoProvider(options: OpenMeteoProviderOptions = {}): MeteoProvider { + const fetcher = options.fetcher ?? fetch; + const weatherSource = options.forecastEndpoint ?? forecastEndpoint; + const seaSource = options.marineEndpoint ?? marineEndpoint; + + return createMeteoProvider({ + name: "open-meteo", + label: "Open-Meteo", + sourceType: "fallback", + capabilities: { weather: true, wind: true, sea: true }, + async getSnapshot(context) { + if (!context.allowFallbackEstimate) { + return { warnings: [{ code: "meteo.reason.openMeteoFallbackSkipped" }] }; + } + + const [weather, marine] = await Promise.all([ + fetchOpenMeteoWeather(context, fetcher, weatherSource), + fetchOpenMeteoMarine(context, fetcher, seaSource), + ]); + const weatherProvenance = openMeteoProvenance(context, weatherSource, weather.validAt); + const marineProvenance = openMeteoProvenance(context, seaSource, marine.validAt); + const sources = [ + openMeteoSource(context, weatherSource, weather.validAt), + openMeteoSource(context, seaSource, marine.validAt), + ]; + + return { + validAt: weather.validAt ?? marine.validAt ?? context.timestamp, + weather: { + cloudCoverPercent: value(numberField(weather.current.cloud_cover), "%", weatherProvenance), + condition: value(weatherCodeDescription(numberField(weather.current.weather_code)), "text", weatherProvenance), + pressureHpa: value(numberField(weather.current.pressure_msl) ?? numberField(weather.current.surface_pressure), "hPa", weatherProvenance), + temperatureC: value(numberField(weather.current.temperature_2m), "c", weatherProvenance), + humidityPercent: value(numberField(weather.current.relative_humidity_2m), "%", weatherProvenance), + precipitationMm: value(numberField(weather.current.precipitation), "mm", weatherProvenance), + }, + wind: { + directionDeg: value(numberField(weather.current.wind_direction_10m), "deg", weatherProvenance), + speedKnots: value(numberField(weather.current.wind_speed_10m), "kn", weatherProvenance), + gustKnots: value(numberField(weather.current.wind_gusts_10m), "kn", weatherProvenance), + }, + sea: { + waveHeightM: value(numberField(marine.current.wave_height), "m", marineProvenance), + waveDirectionDeg: value(numberField(marine.current.wave_direction), "deg", marineProvenance), + wavePeriodS: value(numberField(marine.current.wave_period), "s", marineProvenance), + swellHeightM: value(numberField(marine.current.swell_wave_height), "m", marineProvenance), + swellDirectionDeg: value(numberField(marine.current.swell_wave_direction), "deg", marineProvenance), + swellPeriodS: value(numberField(marine.current.swell_wave_period), "s", marineProvenance), + }, + sources, + warnings: [{ code: "meteo.reason.openMeteoFallbackEstimate" }], + }; + }, + }); +} + +async function fetchOpenMeteoWeather(context: MeteoProviderContext, fetcher: typeof fetch, endpoint: string) { + const url = new URL(endpoint); + url.searchParams.set("latitude", String(context.latitude)); + url.searchParams.set("longitude", String(context.longitude)); + url.searchParams.set("current", [ + "temperature_2m", + "relative_humidity_2m", + "precipitation", + "weather_code", + "cloud_cover", + "pressure_msl", + "surface_pressure", + "wind_speed_10m", + "wind_direction_10m", + "wind_gusts_10m", + ].join(",")); + url.searchParams.set("wind_speed_unit", "kn"); + url.searchParams.set("timezone", "UTC"); + + return fetchOpenMeteoCurrent(url, fetcher); +} + +async function fetchOpenMeteoMarine(context: MeteoProviderContext, fetcher: typeof fetch, endpoint: string) { + const url = new URL(endpoint); + url.searchParams.set("latitude", String(context.latitude)); + url.searchParams.set("longitude", String(context.longitude)); + url.searchParams.set("current", [ + "wave_height", + "wave_direction", + "wave_period", + "swell_wave_height", + "swell_wave_direction", + "swell_wave_period", + ].join(",")); + url.searchParams.set("timezone", "UTC"); + + return fetchOpenMeteoCurrent(url, fetcher); +} + +async function fetchOpenMeteoCurrent(url: URL, fetcher: typeof fetch) { + const response = await fetcher(url); + if (!response.ok) { + throw new Error(`Open-Meteo request failed with status ${response.status}.`); + } + + const body = await response.json() as OpenMeteoResponse; + const current = normalizeCurrent(body.current); + const validAt = timestampFrom(current.time) ?? new Date(); + return { current, validAt }; +} + +function normalizeCurrent(current: OpenMeteoResponse["current"] = {}) { + return Object.fromEntries(Object.entries(current).map(([key, rawValue]) => [key, numberOrString(rawValue)])); +} + +function openMeteoProvenance(context: MeteoProviderContext, sourceUrl: string, validAt: Date): MeteoValueProvenance { + return { + provider: "open-meteo", + providerLabel: "Open-Meteo", + sourceType: "fallback", + sourceUrl, + validAt, + station: { + id: "open-meteo-grid-point", + latitude: context.latitude, + longitude: context.longitude, + distanceNm: 0, + }, + quality: "low", + qualityReason: { code: "meteo.reason.openMeteoFallbackEstimate" }, + }; +} + +function openMeteoSource(context: MeteoProviderContext, sourceUrl: string, validAt: Date): MeteoSource { + return { + provider: "open-meteo", + providerLabel: "Open-Meteo", + sourceType: "fallback", + sourceUrl, + id: "open-meteo-grid-point", + latitude: context.latitude, + longitude: context.longitude, + validAt, + distanceNm: 0, + quality: "low", + qualityReason: { code: "meteo.reason.openMeteoFallbackEstimate" }, + }; +} + +function value( + input: TValue | undefined, + unit: TUnit, + provenance: MeteoValueProvenance, +) { + if (input === undefined) return undefined; + return { value: input, unit, provenance }; +} + +function numberOrString(value: unknown) { + if (typeof value === "number") return value; + if (typeof value === "string") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : value; + } + return undefined; +} + +function timestampFrom(value: unknown) { + if (typeof value !== "string") return undefined; + return new Date(value.endsWith("Z") ? value : `${value}Z`); +} + +function weatherCodeDescription(code: unknown) { + if (typeof code !== "number") return undefined; + const descriptions: Record = { + 0: "clear sky", + 1: "mainly clear", + 2: "partly cloudy", + 3: "overcast", + 45: "fog", + 48: "depositing rime fog", + 51: "light drizzle", + 53: "moderate drizzle", + 55: "dense drizzle", + 61: "slight rain", + 63: "moderate rain", + 65: "heavy rain", + 71: "slight snow", + 73: "moderate snow", + 75: "heavy snow", + 80: "slight rain showers", + 81: "moderate rain showers", + 82: "violent rain showers", + 95: "thunderstorm", + }; + return descriptions[code] ?? `weather code ${code}`; +} + +function numberField(value: unknown) { + return typeof value === "number" ? value : undefined; +} diff --git a/app/domain/meteo/provider.ts b/app/domain/meteo/provider.ts new file mode 100644 index 0000000..a12f062 --- /dev/null +++ b/app/domain/meteo/provider.ts @@ -0,0 +1,145 @@ +import type { Position } from "../nautical/position"; +import type { + MeteoProviderName, + MeteoSea, + MeteoService, + MeteoSnapshot, + MeteoSnapshotRequest, + MeteoSource, + MeteoSourceType, + MeteoTide, + MeteoWeather, + MeteoWind, + MeteoAstronomy, + MeteoValue, + MeteoReason, +} from "./types"; + +export type MeteoCapability = "weather" | "wind" | "sea" | "tide" | "astronomy"; + +export type MeteoProviderCapabilities = Record; + +export type MeteoProviderStation = Position & { + id: string; + name?: string; + distanceNm?: number; +}; + +export type MeteoProviderSnapshot = Partial> & { + validAt?: Date; + sources?: MeteoSource[]; + warnings?: MeteoReason[]; +}; + +export type MeteoProviderContext = Required> & MeteoSnapshotRequest; + +export type MeteoProvider = { + name: MeteoProviderName; + label?: string; + sourceType: MeteoSourceType; + capabilities: MeteoProviderCapabilities; + findStations?: (request: MeteoProviderContext) => Promise; + getSnapshot: (request: MeteoProviderContext) => Promise; +}; + +export type MeteoServiceOptions = { + providers: MeteoProvider[]; +}; + +const emptyCapabilities: MeteoProviderCapabilities = { + weather: false, + wind: false, + sea: false, + tide: false, + astronomy: false, +}; + +export function createMeteoProvider( + provider: Omit & { capabilities?: Partial }, +): MeteoProvider { + return { + ...provider, + capabilities: { ...emptyCapabilities, ...provider.capabilities }, + }; +} + +export function createMeteoService({ providers }: MeteoServiceOptions): MeteoService { + return { + async getSnapshot(request) { + const context = normalizeRequest(request); + const enabledProviders = selectProviders(providers, context); + const fragments = await Promise.all(enabledProviders.map((provider) => provider.getSnapshot(context))); + + return fragments.reduce((snapshot, fragment) => mergeSnapshotFragment(snapshot, fragment), { + requestedAt: new Date(), + validAt: context.timestamp, + position: { latitude: context.latitude, longitude: context.longitude }, + mode: context.mode, + sources: [], + warnings: [], + }); + }, + }; +} + +function normalizeRequest(request: MeteoSnapshotRequest): MeteoProviderContext { + const mode = request.mode ?? "auto"; + return { + ...request, + timestamp: request.timestamp ?? new Date(), + mode, + allowFallbackEstimate: request.allowFallbackEstimate ?? mode !== "observed-only", + }; +} + +function selectProviders(providers: MeteoProvider[], request: MeteoProviderContext) { + if (!request.preferredProviders?.length) return providers; + const preferred = new Set(request.preferredProviders); + return providers.filter((provider) => preferred.has(provider.name)); +} + +function mergeSnapshotFragment(snapshot: MeteoSnapshot, fragment: MeteoProviderSnapshot): MeteoSnapshot { + return { + ...snapshot, + validAt: fragment.validAt ?? snapshot.validAt, + weather: mergeSection(snapshot.weather, fragment.weather), + wind: mergeSection(snapshot.wind, fragment.wind), + sea: mergeSection(snapshot.sea, fragment.sea), + tide: mergeSection(snapshot.tide, fragment.tide), + astronomy: mergeSection(snapshot.astronomy, fragment.astronomy), + sources: [...snapshot.sources, ...(fragment.sources ?? [])], + warnings: [...snapshot.warnings, ...(fragment.warnings ?? [])], + }; +} + +function mergeSection( + current: TSection | undefined, + incoming: TSection | undefined, +) { + if (!incoming) return current; + if (!current) return incoming; + + const merged = { ...current } as Record | undefined>; + for (const [key, incomingValue] of Object.entries(incoming) as Array<[string, MeteoValue | undefined]>) { + merged[key] = selectBestValue(merged[key], incomingValue); + } + + return merged as TSection; +} + +const sourceTypeRank: Record = { + observed: 5, + calculated: 4, + predicted: 3, + estimated: 2, + fallback: 1, +}; + +function selectBestValue | undefined>(current: TValue, incoming: TValue) { + if (!incoming) return current; + if (!current) return incoming; + + const currentRank = sourceTypeRank[current.provenance.sourceType]; + const incomingRank = sourceTypeRank[incoming.provenance.sourceType]; + return incomingRank > currentRank ? incoming : current; +} diff --git a/app/domain/meteo/remarks.ts b/app/domain/meteo/remarks.ts new file mode 100644 index 0000000..130e79e --- /dev/null +++ b/app/domain/meteo/remarks.ts @@ -0,0 +1,195 @@ +import type { MeteoSnapshot, MeteoValue, MeteoValueProvenance } from "./types"; + +export type MeteoRemarkFieldKey = + | "cloudCover" + | "weatherCondition" + | "barometer" + | "temperature" + | "humidity" + | "precipitation" + | "visibility" + | "windDirection" + | "windSpeed" + | "windGusts" + | "windForce" + | "waveHeight" + | "waveDirection" + | "wavePeriod" + | "swellHeight" + | "swellDirection" + | "swellPeriod" + | "seaTemperature" + | "currentSpeed" + | "currentDirection" + | "tideHeight" + | "tidePhase" + | "moonPhase" + | "moonIllumination" + | "sunrise" + | "sunset" + | "moonrise" + | "moonset"; + +export type MeteoSourceRemarkPart = { + fields: MeteoRemarkFieldKey[]; + provenance: MeteoValueProvenance; +}; + +type FieldSource = { + field: MeteoRemarkFieldKey; + provenance: MeteoValueProvenance; +}; + +export function createMeteoSourceRemark(snapshot: MeteoSnapshot) { + const parts = createMeteoSourceRemarkParts(snapshot); + if (parts.length === 0) return "No meteo source metadata available."; + + return parts + .map((group) => `${humanList(group.fields.map(fieldLabel))} from ${describeProvenance(group.provenance)}.`) + .join(" "); +} + +export function createMeteoSourceRemarkParts(snapshot: MeteoSnapshot): MeteoSourceRemarkPart[] { + return groupFieldSources(collectFieldSources(snapshot)); +} + +function collectFieldSources(snapshot: MeteoSnapshot): FieldSource[] { + return [ + ...fieldsFromSection("weather", snapshot.weather, { + cloudCoverPercent: "cloudCover", + condition: "weatherCondition", + pressureHpa: "barometer", + temperatureC: "temperature", + humidityPercent: "humidity", + precipitationMm: "precipitation", + visibilityM: "visibility", + }), + ...fieldsFromSection("wind", snapshot.wind, { + directionDeg: "windDirection", + speedKnots: "windSpeed", + gustKnots: "windGusts", + beaufort: "windForce", + }), + ...fieldsFromSection("sea", snapshot.sea, { + waveHeightM: "waveHeight", + waveDirectionDeg: "waveDirection", + wavePeriodS: "wavePeriod", + swellHeightM: "swellHeight", + swellDirectionDeg: "swellDirection", + swellPeriodS: "swellPeriod", + seaSurfaceTemperatureC: "seaTemperature", + currentSpeedKnots: "currentSpeed", + currentDirectionDeg: "currentDirection", + }), + ...fieldsFromSection("tide", snapshot.tide, { + heightM: "tideHeight", + phase: "tidePhase", + }), + ...fieldsFromSection("astronomy", snapshot.astronomy, { + moonPhase: "moonPhase", + moonIlluminationPercent: "moonIllumination", + sunrise: "sunrise", + sunset: "sunset", + moonrise: "moonrise", + moonset: "moonset", + }), + ]; +} + +function fieldsFromSection( + _sectionName: string, + section: Record | undefined> | undefined, + labels: Record, +): FieldSource[] { + if (!section) return []; + + return Object.entries(labels).flatMap(([fieldName, label]) => { + const value = section[fieldName]; + if (!value) return []; + return [{ field: label, provenance: value.provenance }]; + }); +} + +function groupFieldSources(fieldSources: FieldSource[]) { + const groups = new Map(); + + for (const fieldSource of fieldSources) { + const key = provenanceKey(fieldSource.provenance); + const group = groups.get(key); + if (group) { + group.fields.push(fieldSource.field); + } else { + groups.set(key, { fields: [fieldSource.field], provenance: fieldSource.provenance }); + } + } + + return [...groups.values()]; +} + +function provenanceKey(provenance: MeteoValueProvenance) { + return JSON.stringify({ + provider: provenance.provider, + sourceType: provenance.sourceType, + stationId: provenance.station?.id, + observedAt: provenance.observedAt?.toISOString(), + calculatedAt: provenance.calculatedAt?.toISOString(), + validAt: provenance.validAt?.toISOString(), + }); +} + +function describeProvenance(provenance: MeteoValueProvenance) { + const provider = provenance.providerLabel ?? provenance.provider; + const station = provenance.station ? ` station ${provenance.station.id}${distanceText(provenance.station.distanceNm)}` : ""; + const time = provenance.observedAt ?? provenance.calculatedAt ?? provenance.validAt; + const sourceType = provenance.sourceType === "fallback" ? "fallback estimate" : provenance.sourceType; + + return `${provider}${station} (${sourceType}${time ? ` ${formatUtc(time)}` : ""})`; +} + +function distanceText(distanceNm: number | undefined) { + if (distanceNm === undefined) return ""; + return `, ${distanceNm.toFixed(1)} NM away`; +} + +function formatUtc(date: Date) { + return date.toISOString().replace(/\.\d{3}Z$/, "Z"); +} + +function humanList(items: string[]) { + if (items.length <= 2) return items.join(" and "); + return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`; +} + +function fieldLabel(field: MeteoRemarkFieldKey) { + const labels: Record = { + cloudCover: "cloud cover", + weatherCondition: "weather condition", + barometer: "barometer", + temperature: "temperature", + humidity: "humidity", + precipitation: "precipitation", + visibility: "visibility", + windDirection: "wind direction", + windSpeed: "wind speed", + windGusts: "wind gusts", + windForce: "wind force", + waveHeight: "wave height", + waveDirection: "wave direction", + wavePeriod: "wave period", + swellHeight: "swell height", + swellDirection: "swell direction", + swellPeriod: "swell period", + seaTemperature: "sea temperature", + currentSpeed: "current speed", + currentDirection: "current direction", + tideHeight: "tide height", + tidePhase: "tide phase", + moonPhase: "moon phase", + moonIllumination: "moon illumination", + sunrise: "sunrise", + sunset: "sunset", + moonrise: "moonrise", + moonset: "moonset", + }; + return labels[field]; +} diff --git a/app/domain/meteo/types.ts b/app/domain/meteo/types.ts new file mode 100644 index 0000000..d778068 --- /dev/null +++ b/app/domain/meteo/types.ts @@ -0,0 +1,154 @@ +import type { Position } from "../nautical/position"; + +export type MeteoSnapshotMode = "auto" | "observed-only" | "allow-estimated"; + +export type MeteoProviderName = + | "auto" + | "noaa-ndbc" + | "noaa-coops" + | "metar" + | "open-meteo" + | "local-astronomy" + | string; + +export type MeteoSourceType = "observed" | "calculated" | "predicted" | "estimated" | "fallback"; + +export type MeteoQuality = "high" | "medium" | "low" | "unknown"; + +export type MeteoReasonCode = + | "meteo.reason.noFreshNdbcObservation" + | "meteo.reason.noCoopsStationNearby" + | "meteo.reason.noCoopsWaterLevel" + | "meteo.reason.metarStationIdsRequired" + | "meteo.reason.noFreshMetarObservation" + | "meteo.reason.openMeteoFallbackSkipped" + | "meteo.reason.openMeteoFallbackEstimate" + | "meteo.reason.localAstronomyCalculated" + | "meteo.reason.metarAirportBased"; + +export type MeteoReason = { + code: MeteoReasonCode; + values?: Record; +}; + +export type MeteoUnit = + | "%" + | "bft" + | "c" + | "deg" + | "hPa" + | "kn" + | "m" + | "mm" + | "s" + | "text"; + +export type MeteoProviderReference = { + provider: MeteoProviderName; + providerLabel?: string; + sourceType: MeteoSourceType; + sourceUrl?: string; +}; + +export type MeteoStationReference = Position & { + id: string; + name?: string; + distanceNm?: number; +}; + +export type MeteoValueProvenance = MeteoProviderReference & { + station?: MeteoStationReference; + observedAt?: Date; + calculatedAt?: Date; + validAt?: Date; + quality?: MeteoQuality; + qualityReason?: MeteoReason; + raw?: unknown; +}; + +export type MeteoSource = Position & MeteoProviderReference & { + id?: string; + name?: string; + observedAt?: Date; + calculatedAt?: Date; + validAt?: Date; + distanceNm?: number; + quality?: MeteoQuality; + qualityReason?: MeteoReason; +}; + +export type MeteoValue = { + value: TValue; + unit?: TUnit; + provenance: MeteoValueProvenance; +}; + +export type MeteoSnapshotRequest = Position & { + timestamp?: Date; + mode?: MeteoSnapshotMode; + maxObservationAgeMinutes?: number; + maxStationDistanceNm?: number; + preferredProviders?: MeteoProviderName[]; + allowFallbackEstimate?: boolean; +}; + +export type MeteoWeather = { + cloudCoverPercent?: MeteoValue; + condition?: MeteoValue; + pressureHpa?: MeteoValue; + temperatureC?: MeteoValue; + humidityPercent?: MeteoValue; + precipitationMm?: MeteoValue; + visibilityM?: MeteoValue; +}; + +export type MeteoWind = { + directionDeg?: MeteoValue; + speedKnots?: MeteoValue; + gustKnots?: MeteoValue; + beaufort?: MeteoValue; +}; + +export type MeteoSea = { + waveHeightM?: MeteoValue; + waveDirectionDeg?: MeteoValue; + wavePeriodS?: MeteoValue; + swellHeightM?: MeteoValue; + swellDirectionDeg?: MeteoValue; + swellPeriodS?: MeteoValue; + seaSurfaceTemperatureC?: MeteoValue; + currentSpeedKnots?: MeteoValue; + currentDirectionDeg?: MeteoValue; +}; + +export type MeteoTide = { + heightM?: MeteoValue; + phase?: MeteoValue; +}; + +export type MeteoAstronomy = { + moonPhase?: MeteoValue; + moonIlluminationPercent?: MeteoValue; + sunrise?: MeteoValue; + sunset?: MeteoValue; + moonrise?: MeteoValue; + moonset?: MeteoValue; +}; + +export type MeteoSnapshot = { + requestedAt: Date; + validAt: Date; + position: Position; + mode: MeteoSnapshotMode; + weather?: MeteoWeather; + wind?: MeteoWind; + sea?: MeteoSea; + tide?: MeteoTide; + astronomy?: MeteoAstronomy; + sources: MeteoSource[]; + warnings: MeteoReason[]; +}; + +export type MeteoService = { + getSnapshot(request: MeteoSnapshotRequest): Promise; +}; diff --git a/app/lib/i18n/dictionaries/de.ts b/app/lib/i18n/dictionaries/de.ts index 81ea2dd..9f07685 100644 --- a/app/lib/i18n/dictionaries/de.ts +++ b/app/lib/i18n/dictionaries/de.ts @@ -429,5 +429,47 @@ export const de: Dictionary = { "profile.unableUpdateOnboarding": "Onboarding-Fortschritt konnte nicht aktualisiert werden.", "profile.unableUpdatePreferences": "Profileinstellungen konnten nicht aktualisiert werden.", "profile.preferencesUpdated": "Profile preferences updated.", + "meteo.reason.noFreshNdbcObservation": "Keine aktuelle NOAA-NDBC-Beobachtung innerhalb der konfigurierten Stationsentfernung gefunden.", + "meteo.reason.noCoopsStationNearby": "Keine NOAA-CO-OPS-Wasserstandsstation innerhalb der konfigurierten Stationsentfernung gefunden.", + "meteo.reason.noCoopsWaterLevel": "NOAA-CO-OPS-Station {stationId} lieferte keinen aktuellen Wasserstand.", + "meteo.reason.metarStationIdsRequired": "Der METAR-Anbieter benötigt konfigurierte Stations-IDs, bevor Beobachtungen abgerufen werden können.", + "meteo.reason.noFreshMetarObservation": "Keine aktuelle METAR-Beobachtung innerhalb der konfigurierten Stationsentfernung gefunden.", + "meteo.reason.openMeteoFallbackSkipped": "Open-Meteo-Fallback wurde übersprungen, weil geschätzte Daten nicht erlaubt sind.", + "meteo.reason.openMeteoFallbackEstimate": "Open-Meteo-Werte sind Fallback-Schätzungen und sollten durch aktuelle Beobachtungen ersetzt werden, wenn diese verfügbar sind.", + "meteo.reason.localAstronomyCalculated": "Die Mondphase wird lokal aus dem Zeitpunkt des Snapshots berechnet.", + "meteo.reason.metarAirportBased": "METAR-Daten stammen von Flughäfen und können Offshore-Bedingungen abweichen.", + "meteo.sourceType.observed": "beobachtet", + "meteo.sourceType.calculated": "berechnet", + "meteo.sourceType.predicted": "vorhergesagt", + "meteo.sourceType.estimated": "geschätzt", + "meteo.sourceType.fallback": "Fallback-Schätzung", + "meteo.field.cloudCover": "Bewölkung", + "meteo.field.weatherCondition": "Wetterlage", + "meteo.field.barometer": "Barometer", + "meteo.field.temperature": "Temperatur", + "meteo.field.humidity": "Luftfeuchte", + "meteo.field.precipitation": "Niederschlag", + "meteo.field.visibility": "Sicht", + "meteo.field.windDirection": "Windrichtung", + "meteo.field.windSpeed": "Windgeschwindigkeit", + "meteo.field.windGusts": "Windböen", + "meteo.field.windForce": "Windstärke", + "meteo.field.waveHeight": "Wellenhöhe", + "meteo.field.waveDirection": "Wellenrichtung", + "meteo.field.wavePeriod": "Wellenperiode", + "meteo.field.swellHeight": "Dünungshöhe", + "meteo.field.swellDirection": "Dünungsrichtung", + "meteo.field.swellPeriod": "Dünungsperiode", + "meteo.field.seaTemperature": "Wassertemperatur", + "meteo.field.currentSpeed": "Strömungsgeschwindigkeit", + "meteo.field.currentDirection": "Strömungsrichtung", + "meteo.field.tideHeight": "Tidenhöhe", + "meteo.field.tidePhase": "Tidenphase", + "meteo.field.moonPhase": "Mondphase", + "meteo.field.moonIllumination": "Mondbeleuchtung", + "meteo.field.sunrise": "Sonnenaufgang", + "meteo.field.sunset": "Sonnenuntergang", + "meteo.field.moonrise": "Mondaufgang", + "meteo.field.moonset": "Monduntergang", "logbook.saveError": "Die neuesten Änderungen konnten nicht gespeichert werden. Bitte versuche es erneut.", }; diff --git a/app/lib/i18n/dictionaries/en.ts b/app/lib/i18n/dictionaries/en.ts index 69790d2..184168b 100644 --- a/app/lib/i18n/dictionaries/en.ts +++ b/app/lib/i18n/dictionaries/en.ts @@ -427,6 +427,48 @@ export const en = { "profile.unableUpdateOnboarding": "Unable to update onboarding progress.", "profile.unableUpdatePreferences": "Unable to update profile preferences.", "profile.preferencesUpdated": "Profile preferences updated.", + "meteo.reason.noFreshNdbcObservation": "No fresh NOAA NDBC observation was found within the configured station distance.", + "meteo.reason.noCoopsStationNearby": "No NOAA CO-OPS water-level station was found within the configured station distance.", + "meteo.reason.noCoopsWaterLevel": "NOAA CO-OPS station {stationId} did not return a current water-level observation.", + "meteo.reason.metarStationIdsRequired": "METAR provider requires configured station IDs before it can fetch observations.", + "meteo.reason.noFreshMetarObservation": "No fresh METAR observation was found within the configured station distance.", + "meteo.reason.openMeteoFallbackSkipped": "Open-Meteo fallback was skipped because estimated data is not allowed.", + "meteo.reason.openMeteoFallbackEstimate": "Open-Meteo values are fallback estimates and should be replaced by fresh observations when available.", + "meteo.reason.localAstronomyCalculated": "Moon phase is calculated locally from the snapshot timestamp.", + "meteo.reason.metarAirportBased": "METAR data is airport-based and may not represent offshore vessel conditions.", + "meteo.sourceType.observed": "observed", + "meteo.sourceType.calculated": "calculated", + "meteo.sourceType.predicted": "predicted", + "meteo.sourceType.estimated": "estimated", + "meteo.sourceType.fallback": "fallback estimate", + "meteo.field.cloudCover": "Cloud cover", + "meteo.field.weatherCondition": "Weather condition", + "meteo.field.barometer": "Barometer", + "meteo.field.temperature": "Temperature", + "meteo.field.humidity": "Humidity", + "meteo.field.precipitation": "Precipitation", + "meteo.field.visibility": "Visibility", + "meteo.field.windDirection": "Wind direction", + "meteo.field.windSpeed": "Wind speed", + "meteo.field.windGusts": "Wind gusts", + "meteo.field.windForce": "Wind force", + "meteo.field.waveHeight": "Wave height", + "meteo.field.waveDirection": "Wave direction", + "meteo.field.wavePeriod": "Wave period", + "meteo.field.swellHeight": "Swell height", + "meteo.field.swellDirection": "Swell direction", + "meteo.field.swellPeriod": "Swell period", + "meteo.field.seaTemperature": "Sea temperature", + "meteo.field.currentSpeed": "Current speed", + "meteo.field.currentDirection": "Current direction", + "meteo.field.tideHeight": "Tide height", + "meteo.field.tidePhase": "Tide phase", + "meteo.field.moonPhase": "Moon phase", + "meteo.field.moonIllumination": "Moon illumination", + "meteo.field.sunrise": "Sunrise", + "meteo.field.sunset": "Sunset", + "meteo.field.moonrise": "Moonrise", + "meteo.field.moonset": "Moonset", "logbook.saveError": "Unable to save the latest changes. Please try again.", } as const; diff --git a/app/lib/i18n/dictionaries/fr.ts b/app/lib/i18n/dictionaries/fr.ts index 0dae7b4..dbe8dde 100644 --- a/app/lib/i18n/dictionaries/fr.ts +++ b/app/lib/i18n/dictionaries/fr.ts @@ -429,5 +429,47 @@ export const fr: Dictionary = { "profile.unableUpdateOnboarding": "Impossible de mettre à jour la progression d’intégration.", "profile.unableUpdatePreferences": "Impossible de mettre à jour les préférences du profil.", "profile.preferencesUpdated": "Profile preferences updated.", + "meteo.reason.noFreshNdbcObservation": "Aucune observation NOAA NDBC récente trouvée dans la distance de station configurée.", + "meteo.reason.noCoopsStationNearby": "Aucune station de niveau d’eau NOAA CO-OPS trouvée dans la distance de station configurée.", + "meteo.reason.noCoopsWaterLevel": "La station NOAA CO-OPS {stationId} n’a pas renvoyé de niveau d’eau actuel.", + "meteo.reason.metarStationIdsRequired": "Le fournisseur METAR nécessite des identifiants de station configurés avant de récupérer les observations.", + "meteo.reason.noFreshMetarObservation": "Aucune observation METAR récente trouvée dans la distance de station configurée.", + "meteo.reason.openMeteoFallbackSkipped": "Le repli Open-Meteo a été ignoré car les données estimées ne sont pas autorisées.", + "meteo.reason.openMeteoFallbackEstimate": "Les valeurs Open-Meteo sont des estimations de repli et doivent être remplacées par des observations récentes si disponibles.", + "meteo.reason.localAstronomyCalculated": "La phase de lune est calculée localement à partir de l’heure du snapshot.", + "meteo.reason.metarAirportBased": "Les données METAR proviennent d’aéroports et peuvent ne pas représenter les conditions au large.", + "meteo.sourceType.observed": "observé", + "meteo.sourceType.calculated": "calculé", + "meteo.sourceType.predicted": "prévu", + "meteo.sourceType.estimated": "estimé", + "meteo.sourceType.fallback": "estimation de repli", + "meteo.field.cloudCover": "Nébulosité", + "meteo.field.weatherCondition": "Conditions météo", + "meteo.field.barometer": "Baromètre", + "meteo.field.temperature": "Température", + "meteo.field.humidity": "Humidité", + "meteo.field.precipitation": "Précipitations", + "meteo.field.visibility": "Visibilité", + "meteo.field.windDirection": "Direction du vent", + "meteo.field.windSpeed": "Vitesse du vent", + "meteo.field.windGusts": "Rafales", + "meteo.field.windForce": "Force du vent", + "meteo.field.waveHeight": "Hauteur des vagues", + "meteo.field.waveDirection": "Direction des vagues", + "meteo.field.wavePeriod": "Période des vagues", + "meteo.field.swellHeight": "Hauteur de la houle", + "meteo.field.swellDirection": "Direction de la houle", + "meteo.field.swellPeriod": "Période de la houle", + "meteo.field.seaTemperature": "Température de la mer", + "meteo.field.currentSpeed": "Vitesse du courant", + "meteo.field.currentDirection": "Direction du courant", + "meteo.field.tideHeight": "Hauteur de marée", + "meteo.field.tidePhase": "Phase de marée", + "meteo.field.moonPhase": "Phase de lune", + "meteo.field.moonIllumination": "Illumination lunaire", + "meteo.field.sunrise": "Lever du soleil", + "meteo.field.sunset": "Coucher du soleil", + "meteo.field.moonrise": "Lever de lune", + "meteo.field.moonset": "Coucher de lune", "logbook.saveError": "Impossible d’enregistrer les dernières modifications. Veuillez réessayer.", }; diff --git a/app/lib/i18n/dictionaries/it.ts b/app/lib/i18n/dictionaries/it.ts index 9776718..f9b73d8 100644 --- a/app/lib/i18n/dictionaries/it.ts +++ b/app/lib/i18n/dictionaries/it.ts @@ -429,5 +429,47 @@ export const it: Dictionary = { "profile.unableUpdateOnboarding": "Impossibile aggiornare l’avanzamento dell’onboarding.", "profile.unableUpdatePreferences": "Impossibile aggiornare le preferenze del profilo.", "profile.preferencesUpdated": "Profile preferences updated.", + "meteo.reason.noFreshNdbcObservation": "Nessuna osservazione NOAA NDBC recente trovata entro la distanza stazione configurata.", + "meteo.reason.noCoopsStationNearby": "Nessuna stazione NOAA CO-OPS per il livello dell’acqua trovata entro la distanza stazione configurata.", + "meteo.reason.noCoopsWaterLevel": "La stazione NOAA CO-OPS {stationId} non ha restituito un livello dell’acqua corrente.", + "meteo.reason.metarStationIdsRequired": "Il provider METAR richiede ID stazione configurati prima di recuperare le osservazioni.", + "meteo.reason.noFreshMetarObservation": "Nessuna osservazione METAR recente trovata entro la distanza stazione configurata.", + "meteo.reason.openMeteoFallbackSkipped": "Il fallback Open-Meteo è stato saltato perché i dati stimati non sono consentiti.", + "meteo.reason.openMeteoFallbackEstimate": "I valori Open-Meteo sono stime di fallback e dovrebbero essere sostituiti da osservazioni recenti quando disponibili.", + "meteo.reason.localAstronomyCalculated": "La fase lunare viene calcolata localmente dall’orario dello snapshot.", + "meteo.reason.metarAirportBased": "I dati METAR provengono da aeroporti e potrebbero non rappresentare le condizioni al largo.", + "meteo.sourceType.observed": "osservato", + "meteo.sourceType.calculated": "calcolato", + "meteo.sourceType.predicted": "previsto", + "meteo.sourceType.estimated": "stimato", + "meteo.sourceType.fallback": "stima di fallback", + "meteo.field.cloudCover": "Nuvolosità", + "meteo.field.weatherCondition": "Condizioni meteo", + "meteo.field.barometer": "Barometro", + "meteo.field.temperature": "Temperatura", + "meteo.field.humidity": "Umidità", + "meteo.field.precipitation": "Precipitazioni", + "meteo.field.visibility": "Visibilità", + "meteo.field.windDirection": "Direzione del vento", + "meteo.field.windSpeed": "Velocità del vento", + "meteo.field.windGusts": "Raffiche", + "meteo.field.windForce": "Forza del vento", + "meteo.field.waveHeight": "Altezza onde", + "meteo.field.waveDirection": "Direzione onde", + "meteo.field.wavePeriod": "Periodo onde", + "meteo.field.swellHeight": "Altezza swell", + "meteo.field.swellDirection": "Direzione swell", + "meteo.field.swellPeriod": "Periodo swell", + "meteo.field.seaTemperature": "Temperatura del mare", + "meteo.field.currentSpeed": "Velocità corrente", + "meteo.field.currentDirection": "Direzione corrente", + "meteo.field.tideHeight": "Altezza marea", + "meteo.field.tidePhase": "Fase marea", + "meteo.field.moonPhase": "Fase lunare", + "meteo.field.moonIllumination": "Illuminazione lunare", + "meteo.field.sunrise": "Alba", + "meteo.field.sunset": "Tramonto", + "meteo.field.moonrise": "Sorgere della luna", + "meteo.field.moonset": "Tramonto della luna", "logbook.saveError": "Impossibile salvare le ultime modifiche. Riprova.", }; diff --git a/tests/app/domain-meteo-cache.test.ts b/tests/app/domain-meteo-cache.test.ts new file mode 100644 index 0000000..f0e187c --- /dev/null +++ b/tests/app/domain-meteo-cache.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { createCachedMeteoProvider, createFreeMeteoProviders, createMeteoProvider } from "../../app/domain/meteo"; + +const request = { + latitude: 54.12345, + longitude: 10.23456, + timestamp: new Date("2026-07-17T12:05:00Z"), + mode: "auto" as const, + allowFallbackEstimate: true, +}; + +describe("meteo provider cache", () => { + it("caches provider snapshots by rounded position and timestamp bucket", async () => { + let calls = 0; + let now = 1_000; + const provider = createCachedMeteoProvider(createMeteoProvider({ + name: "cache-test", + sourceType: "calculated", + capabilities: { astronomy: true }, + async getSnapshot() { + calls += 1; + return { warnings: [{ code: "meteo.reason.noFreshMetarObservation", values: { call: calls } }] }; + }, + }), { ttlMs: 1_000, now: () => now }); + + await expect(provider.getSnapshot(request)).resolves.toMatchObject({ warnings: [{ code: "meteo.reason.noFreshMetarObservation", values: { call: 1 } }] }); + await expect(provider.getSnapshot({ ...request, latitude: 54.12349 })).resolves.toMatchObject({ warnings: [{ code: "meteo.reason.noFreshMetarObservation", values: { call: 1 } }] }); + + now = 2_001; + await expect(provider.getSnapshot(request)).resolves.toMatchObject({ warnings: [{ code: "meteo.reason.noFreshMetarObservation", values: { call: 2 } }] }); + expect(calls).toBe(2); + }); + + it("caches station lookups separately from snapshots", async () => { + let stationCalls = 0; + let snapshotCalls = 0; + const provider = createCachedMeteoProvider(createMeteoProvider({ + name: "station-cache-test", + sourceType: "observed", + capabilities: { weather: true }, + async findStations() { + stationCalls += 1; + return [{ id: "station-1", latitude: 54.1, longitude: 10.2 }]; + }, + async getSnapshot() { + snapshotCalls += 1; + return { warnings: [{ code: "meteo.reason.noFreshMetarObservation", values: { snapshot: snapshotCalls } }] }; + }, + }), { ttlMs: 1_000, now: () => 1_000 }); + + await expect(provider.findStations?.(request)).resolves.toHaveLength(1); + await expect(provider.findStations?.(request)).resolves.toHaveLength(1); + await expect(provider.getSnapshot(request)).resolves.toMatchObject({ warnings: [{ code: "meteo.reason.noFreshMetarObservation", values: { snapshot: 1 } }] }); + await expect(provider.getSnapshot(request)).resolves.toMatchObject({ warnings: [{ code: "meteo.reason.noFreshMetarObservation", values: { snapshot: 1 } }] }); + + expect(stationCalls).toBe(1); + expect(snapshotCalls).toBe(1); + }); + + it("wraps free providers when a cache TTL is configured", () => { + const providers = createFreeMeteoProviders({ enabledProviders: ["local-astronomy"], cacheTtlMs: 60_000 }); + + expect(providers).toHaveLength(1); + expect(providers[0].name).toBe("local-astronomy"); + }); +}); diff --git a/tests/app/domain-meteo-default-service.test.ts b/tests/app/domain-meteo-default-service.test.ts new file mode 100644 index 0000000..aa3b1bf --- /dev/null +++ b/tests/app/domain-meteo-default-service.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { createFreeMeteoProviders, createFreeMeteoService, defaultFreeMeteoProviderOrder, defaultFreeMeteoRequestOptions } from "../../app/domain/meteo"; + +describe("free meteo service configuration", () => { + it("builds free providers in the default priority order", () => { + const providers = createFreeMeteoProviders({ metarStationIds: ["KSFO"] }); + + expect(defaultFreeMeteoProviderOrder).toEqual([ + "noaa-ndbc", + "noaa-coops", + "metar", + "local-astronomy", + "open-meteo", + ]); + expect(providers.map((provider) => provider.name)).toEqual(defaultFreeMeteoProviderOrder); + }); + + it("allows provider subsets for scoped deployments and tests", () => { + const providers = createFreeMeteoProviders({ enabledProviders: ["local-astronomy", "open-meteo"] }); + + expect(providers.map((provider) => provider.name)).toEqual(["local-astronomy", "open-meteo"]); + }); + + it("applies default request thresholds before calling providers", async () => { + const service = createFreeMeteoService({ + enabledProviders: ["local-astronomy"], + maxObservationAgeMinutes: 45, + maxStationDistanceNm: 12, + allowFallbackEstimate: false, + }); + + const snapshot = await service.getSnapshot({ + latitude: 54.1, + longitude: 10.2, + timestamp: new Date("2026-07-16T12:00:00Z"), + }); + + expect(defaultFreeMeteoRequestOptions).toEqual({ + maxObservationAgeMinutes: 120, + maxStationDistanceNm: 50, + allowFallbackEstimate: true, + }); + expect(snapshot.astronomy?.moonPhase?.provenance).toMatchObject({ + provider: "local-astronomy", + sourceType: "calculated", + }); + }); + + it("keeps observed-only mode from enabling fallback estimates", async () => { + const service = createFreeMeteoService({ enabledProviders: ["open-meteo"] }); + + await expect(service.getSnapshot({ + latitude: 54.1, + longitude: 10.2, + timestamp: new Date("2026-07-16T12:00:00Z"), + mode: "observed-only", + })).resolves.toMatchObject({ + warnings: [{ code: "meteo.reason.openMeteoFallbackSkipped" }], + }); + }); +}); diff --git a/tests/app/domain-meteo-i18n.test.ts b/tests/app/domain-meteo-i18n.test.ts new file mode 100644 index 0000000..0112528 --- /dev/null +++ b/tests/app/domain-meteo-i18n.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { locales, t, type Locale, type TranslationKey } from "../../app/lib/i18n"; + +const meteoReasonKeys = [ + "meteo.reason.noFreshNdbcObservation", + "meteo.reason.noCoopsStationNearby", + "meteo.reason.noCoopsWaterLevel", + "meteo.reason.metarStationIdsRequired", + "meteo.reason.noFreshMetarObservation", + "meteo.reason.openMeteoFallbackSkipped", + "meteo.reason.openMeteoFallbackEstimate", + "meteo.reason.localAstronomyCalculated", + "meteo.reason.metarAirportBased", +] as const satisfies readonly TranslationKey[]; + +const meteoFieldKeys = [ + "meteo.field.cloudCover", + "meteo.field.weatherCondition", + "meteo.field.barometer", + "meteo.field.temperature", + "meteo.field.windDirection", + "meteo.field.windSpeed", + "meteo.field.waveHeight", + "meteo.field.tideHeight", + "meteo.field.moonPhase", +] as const satisfies readonly TranslationKey[]; + +describe("meteo i18n keys", () => { + it("translates meteo reason and field keys in every supported locale", () => { + for (const locale of locales) { + expectTranslated(locale, meteoReasonKeys); + expectTranslated(locale, meteoFieldKeys); + } + }); +}); + +function expectTranslated(locale: Locale, keys: readonly TranslationKey[]) { + for (const key of keys) { + expect(t(locale, key), `${locale} ${key}`).toBeTruthy(); + } +} diff --git a/tests/app/domain-meteo-local-astronomy.test.ts b/tests/app/domain-meteo-local-astronomy.test.ts new file mode 100644 index 0000000..a901954 --- /dev/null +++ b/tests/app/domain-meteo-local-astronomy.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { calculateMoonPhase, createLocalAstronomyProvider, createMeteoService } from "../../app/domain/meteo"; + +describe("local astronomy meteo provider", () => { + it("calculates known moon phases from the snapshot timestamp", () => { + expect(calculateMoonPhase(new Date("2000-01-06T18:14:00Z"))).toMatchObject({ + phase: "new moon", + illuminationPercent: 0, + }); + expect(calculateMoonPhase(new Date("2000-01-21T12:00:00Z"))).toMatchObject({ + phase: "full moon", + illuminationPercent: 100, + }); + }); + + it("returns moon phase and illumination with calculated provenance", async () => { + const timestamp = new Date("2026-07-16T12:00:00Z"); + const service = createMeteoService({ providers: [createLocalAstronomyProvider()] }); + + const snapshot = await service.getSnapshot({ latitude: 54.1, longitude: 10.2, timestamp }); + + expect(snapshot.astronomy?.moonPhase).toMatchObject({ + value: "new moon", + unit: "text", + provenance: { + provider: "local-astronomy", + providerLabel: "Local astronomy calculation", + sourceType: "calculated", + validAt: timestamp, + quality: "medium", + }, + }); + expect(snapshot.astronomy?.moonIlluminationPercent).toMatchObject({ + value: 3, + unit: "%", + provenance: { provider: "local-astronomy", sourceType: "calculated" }, + }); + expect(snapshot.sources).toHaveLength(1); + expect(snapshot.sources[0]).toMatchObject({ + provider: "local-astronomy", + sourceType: "calculated", + latitude: 54.1, + longitude: 10.2, + validAt: timestamp, + }); + }); +}); diff --git a/tests/app/domain-meteo-log-line-autofill.test.ts b/tests/app/domain-meteo-log-line-autofill.test.ts new file mode 100644 index 0000000..a003b20 --- /dev/null +++ b/tests/app/domain-meteo-log-line-autofill.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { meteoSnapshotToLogLineAutofill, type MeteoSnapshot, type MeteoValueProvenance } from "../../app/domain/meteo"; + +const observedProvenance: MeteoValueProvenance = { + provider: "noaa-ndbc", + sourceType: "observed", + station: { id: "44013", latitude: 42.346, longitude: -70.651, distanceNm: 3.2 }, + observedAt: new Date("2026-07-18T12:10:00Z"), +}; + +const calculatedProvenance: MeteoValueProvenance = { + provider: "local-astronomy", + sourceType: "calculated", + calculatedAt: new Date("2026-07-18T12:30:00Z"), +}; + +const snapshot: MeteoSnapshot = { + requestedAt: new Date("2026-07-18T12:30:00Z"), + validAt: new Date("2026-07-18T12:10:00Z"), + position: { latitude: 42.35, longitude: -70.65 }, + mode: "auto", + weather: { + cloudCoverPercent: { value: 35, unit: "%", provenance: observedProvenance }, + pressureHpa: { value: 1013.2, unit: "hPa", provenance: observedProvenance }, + temperatureC: { value: 21.4, unit: "c", provenance: observedProvenance }, + }, + wind: { + directionDeg: { value: 190, unit: "deg", provenance: observedProvenance }, + speedKnots: { value: 12.1, unit: "kn", provenance: observedProvenance }, + }, + sea: { + waveHeightM: { value: 1.5, unit: "m", provenance: observedProvenance }, + }, + tide: { + heightM: { value: 0.3, unit: "m", provenance: observedProvenance }, + }, + astronomy: { + moonPhase: { value: "waxing crescent", unit: "text", provenance: calculatedProvenance }, + }, + sources: [], + warnings: [], +}; + +describe("meteo log-line autofill", () => { + it("maps a meteo snapshot into log line weather fields", () => { + const autofill = meteoSnapshotToLogLineAutofill(snapshot); + + expect(autofill.fields).toMatchObject({ + weather: "🌤️", + temperature: "21", + temperatureUnit: "°C", + barometer: "1013", + windDirection: "S", + windStrength: "4", + windUnit: "bft", + waves: "1.5", + seaUnit: "m", + tide: "0.3", + tideUnit: "m", + moon: "🌒", + }); + expect(autofill.remarkParts[0]).toMatchObject({ + fields: ["cloudCover", "barometer", "temperature", "windDirection", "windSpeed", "waveHeight", "tideHeight"], + }); + }); + + it("respects user unit preferences", () => { + expect(meteoSnapshotToLogLineAutofill(snapshot, { + temperatureUnit: "°F", + windUnit: "kn", + seaUnit: "ft", + tideUnit: "ft", + }).fields).toMatchObject({ + temperature: "71", + temperatureUnit: "°F", + windStrength: "12.1", + windUnit: "kn", + waves: "4.9", + seaUnit: "ft", + tide: "1", + tideUnit: "ft", + }); + }); + + it("uses translated-later remark parts rather than filling an English weather remark", () => { + const autofill = meteoSnapshotToLogLineAutofill(snapshot); + + expect(autofill.fields.weatherRemark).toBeUndefined(); + expect(autofill.remarkParts[0]).toMatchObject({ + fields: expect.arrayContaining(["barometer", "windSpeed"]), + provenance: { provider: "noaa-ndbc", sourceType: "observed" }, + }); + }); +}); diff --git a/tests/app/domain-meteo-metar.test.ts b/tests/app/domain-meteo-metar.test.ts new file mode 100644 index 0000000..f9b7952 --- /dev/null +++ b/tests/app/domain-meteo-metar.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { createMetarProvider, createMeteoService, parseMetarObservations } from "../../app/domain/meteo"; + +const metarReports = [{ + icaoId: "KSFO", + rawOb: "KSFO 161256Z 28012G18KT 10SM SCT020 BKN040 17/12 A2992", + obsTime: "2026-07-16T12:56:00Z", + lat: 37.619, + lon: -122.375, + wdir: 280, + wspd: 12, + wgst: 18, + altim: 29.92, + temp: 17, + visib: 10, + wxString: "VFR", + clouds: [{ cover: "SCT" }, { cover: "BKN" }], +}, { + icaoId: "KOAK", + rawOb: "KOAK 161253Z 27008KT 10SM FEW015 18/12 A2993", + obsTime: "2026-07-16T12:53:00Z", + lat: 37.721, + lon: -122.221, + wdir: 270, + wspd: 8, + altim: 1013.5, + temp: 18, + visib: 10, + clouds: [{ cover: "FEW" }], +}]; + +describe("METAR meteo provider", () => { + it("parses aviation weather METAR JSON into normalized observations", () => { + const observations = parseMetarObservations(metarReports); + + expect(observations).toHaveLength(2); + expect(observations[0]).toMatchObject({ + stationId: "KSFO", + latitude: 37.619, + longitude: -122.375, + observedAt: new Date("2026-07-16T12:56:00Z"), + cloudCoverPercent: 75, + condition: "VFR", + temperatureC: 17, + visibilityM: 16_093.44, + windDirectionDeg: 280, + windSpeedKnots: 12, + windGustKnots: 18, + }); + expect(observations[0].pressureHpa).toBeCloseTo(1013.207, 3); + expect(observations[1].pressureHpa).toBe(1013.5); + }); + + it("returns nearest fresh METAR weather and wind values with provenance", async () => { + const fetcher = async () => Response.json(metarReports); + const service = createMeteoService({ providers: [createMetarProvider({ fetcher, stationIds: ["KSFO", "KOAK"] })] }); + + const snapshot = await service.getSnapshot({ + latitude: 37.62, + longitude: -122.38, + timestamp: new Date("2026-07-16T13:00:00Z"), + maxObservationAgeMinutes: 30, + maxStationDistanceNm: 20, + }); + + expect(snapshot.validAt).toEqual(new Date("2026-07-16T12:56:00Z")); + expect(snapshot.weather?.cloudCoverPercent?.value).toBe(75); + expect(snapshot.weather?.pressureHpa?.value).toBeCloseTo(1013.207, 3); + expect(snapshot.wind?.speedKnots).toMatchObject({ + value: 12, + unit: "kn", + provenance: { + provider: "metar", + providerLabel: "AviationWeather METAR", + sourceType: "observed", + station: { id: "KSFO" }, + quality: "medium", + }, + }); + expect(snapshot.sources).toHaveLength(1); + }); + + it("warns when station IDs are not configured", async () => { + const service = createMeteoService({ providers: [createMetarProvider()] }); + + await expect(service.getSnapshot({ + latitude: 37.62, + longitude: -122.38, + timestamp: new Date("2026-07-16T13:00:00Z"), + })).resolves.toMatchObject({ + warnings: [{ code: "meteo.reason.metarStationIdsRequired" }], + sources: [], + }); + }); +}); diff --git a/tests/app/domain-meteo-noaa-coops.test.ts b/tests/app/domain-meteo-noaa-coops.test.ts new file mode 100644 index 0000000..63a73a4 --- /dev/null +++ b/tests/app/domain-meteo-noaa-coops.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { createMeteoService, createNoaaCoopsProvider, parseNoaaCoopsStations } from "../../app/domain/meteo"; + +const stationsResponse = { + stations: [ + { id: "9414290", name: "San Francisco", lat: "37.8063", lng: "-122.4659" }, + { id: "9414750", name: "Alameda", lat: "37.7717", lng: "-122.3000" }, + { id: "bad", name: "Broken" }, + ], +}; + +const waterLevelResponse = { + data: [{ t: "2026-07-16 12:24", v: "1.234" }], +}; + +describe("NOAA CO-OPS meteo provider", () => { + it("parses station metadata with positions", () => { + expect(parseNoaaCoopsStations(stationsResponse)).toEqual([ + { id: "9414290", name: "San Francisco", latitude: 37.8063, longitude: -122.4659 }, + { id: "9414750", name: "Alameda", latitude: 37.7717, longitude: -122.3 }, + ]); + }); + + it("returns the nearest fresh observed water level with provenance", async () => { + const fetcher = async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("stations")) return Response.json(stationsResponse); + return Response.json(waterLevelResponse); + }; + const service = createMeteoService({ providers: [createNoaaCoopsProvider({ fetcher })] }); + + const snapshot = await service.getSnapshot({ + latitude: 37.807, + longitude: -122.466, + timestamp: new Date("2026-07-16T12:30:00Z"), + maxObservationAgeMinutes: 30, + maxStationDistanceNm: 10, + }); + + expect(snapshot.validAt).toEqual(new Date("2026-07-16T12:24:00Z")); + expect(snapshot.tide?.heightM).toMatchObject({ + value: 1.234, + unit: "m", + provenance: { + provider: "noaa-coops", + providerLabel: "NOAA Tides & Currents", + sourceType: "observed", + station: { id: "9414290", name: "San Francisco" }, + observedAt: new Date("2026-07-16T12:24:00Z"), + quality: "high", + }, + }); + expect(snapshot.sources).toHaveLength(1); + expect(snapshot.sources[0]).toMatchObject({ id: "9414290", name: "San Francisco" }); + }); + + it("warns when no nearby water-level station is available", async () => { + const fetcher = async () => Response.json(stationsResponse); + const service = createMeteoService({ providers: [createNoaaCoopsProvider({ fetcher })] }); + + await expect(service.getSnapshot({ + latitude: 10, + longitude: 10, + timestamp: new Date("2026-07-16T12:30:00Z"), + maxStationDistanceNm: 5, + })).resolves.toMatchObject({ + warnings: [{ code: "meteo.reason.noCoopsStationNearby" }], + sources: [], + }); + }); +}); diff --git a/tests/app/domain-meteo-noaa-ndbc.test.ts b/tests/app/domain-meteo-noaa-ndbc.test.ts new file mode 100644 index 0000000..78a779d --- /dev/null +++ b/tests/app/domain-meteo-noaa-ndbc.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { createMeteoService, createNoaaNdbcProvider, parseNoaaNdbcLatestObservations } from "../../app/domain/meteo"; + +const latestObservations = `#STN LAT LON YYYY MM DD hh mm WDIR WSPD GST WVHT DPD APD MWD PRES ATMP WTMP DEWP VIS PTDY TIDE +#text header ignored +44013 42.346 -70.651 2026 07 16 12 10 190 6.2 8.0 1.5 7 6.1 180 1013.2 21.4 18.7 19.0 10.0 -0.4 0.3 +44008 40.503 -69.248 2026 07 16 11 40 200 4.1 5.3 MM MM MM MM 1014.1 20.1 17.9 MM MM MM MM +`; + +describe("NOAA NDBC meteo provider", () => { + it("parses latest observation rows into normalized units", () => { + const observations = parseNoaaNdbcLatestObservations(latestObservations); + + expect(observations).toHaveLength(2); + expect(observations[0]).toMatchObject({ + stationId: "44013", + latitude: 42.346, + longitude: -70.651, + observedAt: new Date("2026-07-16T12:10:00Z"), + windDirectionDeg: 190, + waveHeightM: 1.5, + pressureHpa: 1013.2, + airTemperatureC: 21.4, + seaSurfaceTemperatureC: 18.7, + visibilityM: 10_000, + tideM: 0.3, + }); + expect(observations[0].windSpeedKnots).toBeCloseTo(12.052, 3); + expect(observations[1].waveHeightM).toBeUndefined(); + }); + + it("returns the nearest fresh observation with per-value provenance", async () => { + const fetcher = async () => new Response(latestObservations); + const service = createMeteoService({ providers: [createNoaaNdbcProvider({ fetcher })] }); + + const snapshot = await service.getSnapshot({ + latitude: 42.35, + longitude: -70.65, + timestamp: new Date("2026-07-16T12:30:00Z"), + maxObservationAgeMinutes: 60, + maxStationDistanceNm: 20, + }); + + expect(snapshot.validAt).toEqual(new Date("2026-07-16T12:10:00Z")); + expect(snapshot.wind?.speedKnots).toMatchObject({ + unit: "kn", + provenance: { + provider: "noaa-ndbc", + providerLabel: "NOAA National Data Buoy Center", + sourceType: "observed", + station: { id: "44013" }, + observedAt: new Date("2026-07-16T12:10:00Z"), + quality: "high", + }, + }); + expect(snapshot.wind?.speedKnots?.value).toBeCloseTo(12.052, 3); + expect(snapshot.weather?.pressureHpa?.value).toBe(1013.2); + expect(snapshot.sea?.waveHeightM?.value).toBe(1.5); + expect(snapshot.tide?.heightM?.value).toBe(0.3); + expect(snapshot.sources).toHaveLength(1); + }); + + it("warns when no fresh nearby station is available", async () => { + const fetcher = async () => new Response(latestObservations); + const service = createMeteoService({ providers: [createNoaaNdbcProvider({ fetcher })] }); + + await expect(service.getSnapshot({ + latitude: 10, + longitude: 10, + timestamp: new Date("2026-07-16T12:30:00Z"), + maxStationDistanceNm: 5, + })).resolves.toMatchObject({ + warnings: [{ code: "meteo.reason.noFreshNdbcObservation" }], + sources: [], + }); + }); +}); diff --git a/tests/app/domain-meteo-open-meteo.test.ts b/tests/app/domain-meteo-open-meteo.test.ts new file mode 100644 index 0000000..9be1d77 --- /dev/null +++ b/tests/app/domain-meteo-open-meteo.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { createMeteoService, createOpenMeteoProvider } from "../../app/domain/meteo"; + +const weatherResponse = { + current: { + time: "2026-07-16T12:00", + temperature_2m: 22.4, + relative_humidity_2m: 68, + precipitation: 0.2, + weather_code: 2, + cloud_cover: 40, + pressure_msl: 1012.8, + surface_pressure: 1008.1, + wind_speed_10m: 11.5, + wind_direction_10m: 285, + wind_gusts_10m: 18.2, + }, +}; + +const marineResponse = { + current: { + time: "2026-07-16T12:00", + wave_height: 1.2, + wave_direction: 270, + wave_period: 6, + swell_wave_height: 0.8, + swell_wave_direction: 260, + swell_wave_period: 9, + }, +}; + +describe("Open-Meteo fallback provider", () => { + it("returns estimated weather, wind, and sea values with fallback provenance", async () => { + const fetcher = async (input: RequestInfo | URL) => { + const url = String(input); + return Response.json(url.includes("marine") ? marineResponse : weatherResponse); + }; + const service = createMeteoService({ providers: [createOpenMeteoProvider({ fetcher })] }); + + const snapshot = await service.getSnapshot({ + latitude: 54.1, + longitude: 10.2, + timestamp: new Date("2026-07-16T12:30:00Z"), + }); + + expect(snapshot.validAt).toEqual(new Date("2026-07-16T12:00:00Z")); + expect(snapshot.weather?.condition?.value).toBe("partly cloudy"); + expect(snapshot.weather?.cloudCoverPercent).toMatchObject({ + value: 40, + unit: "%", + provenance: { + provider: "open-meteo", + providerLabel: "Open-Meteo", + sourceType: "fallback", + station: { id: "open-meteo-grid-point", distanceNm: 0 }, + quality: "low", + }, + }); + expect(snapshot.wind?.speedKnots?.value).toBe(11.5); + expect(snapshot.sea?.waveHeightM?.value).toBe(1.2); + expect(snapshot.sources).toHaveLength(2); + expect(snapshot.warnings).toEqual([ + { code: "meteo.reason.openMeteoFallbackEstimate" }, + ]); + }); + + it("skips fallback estimates when estimated data is not allowed", async () => { + const service = createMeteoService({ providers: [createOpenMeteoProvider()] }); + + await expect(service.getSnapshot({ + latitude: 54.1, + longitude: 10.2, + timestamp: new Date("2026-07-16T12:30:00Z"), + mode: "observed-only", + })).resolves.toMatchObject({ + warnings: [{ code: "meteo.reason.openMeteoFallbackSkipped" }], + sources: [], + }); + }); +}); diff --git a/tests/app/domain-meteo-provider.test.ts b/tests/app/domain-meteo-provider.test.ts new file mode 100644 index 0000000..79f1555 --- /dev/null +++ b/tests/app/domain-meteo-provider.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; +import { createMeteoProvider, createMeteoService } from "../../app/domain/meteo"; + +const request = { + latitude: 54.1, + longitude: 10.2, + timestamp: new Date("2026-07-16T12:00:00Z"), +}; + +describe("meteo provider orchestration", () => { + it("normalizes provider capabilities and merges provider snapshots", async () => { + const observedProvider = createMeteoProvider({ + name: "observed-test", + sourceType: "observed", + capabilities: { wind: true }, + async getSnapshot(context) { + return { + wind: { + speedKnots: { + value: 12, + unit: "kn", + provenance: { + provider: "observed-test", + sourceType: "observed", + observedAt: context.timestamp, + }, + }, + }, + sources: [{ + provider: "observed-test", + sourceType: "observed", + latitude: context.latitude, + longitude: context.longitude, + observedAt: context.timestamp, + }], + }; + }, + }); + const fallbackProvider = createMeteoProvider({ + name: "fallback-test", + sourceType: "fallback", + capabilities: { wind: true, weather: true }, + async getSnapshot() { + return { + weather: { + condition: { + value: "partly cloudy", + unit: "text", + provenance: { provider: "fallback-test", sourceType: "fallback" }, + }, + }, + wind: { + speedKnots: { + value: 10, + unit: "kn", + provenance: { provider: "fallback-test", sourceType: "fallback" }, + }, + }, + warnings: [{ code: "meteo.reason.noFreshMetarObservation" }], + }; + }, + }); + const service = createMeteoService({ providers: [observedProvider, fallbackProvider] }); + + const snapshot = await service.getSnapshot(request); + + expect(observedProvider.capabilities.sea).toBe(false); + expect(snapshot).toMatchObject({ + mode: "auto", + position: { latitude: request.latitude, longitude: request.longitude }, + wind: { speedKnots: { value: 12 } }, + weather: { condition: { value: "partly cloudy" } }, + warnings: [{ code: "meteo.reason.noFreshMetarObservation" }], + }); + expect(snapshot.validAt).toEqual(request.timestamp); + expect(snapshot.sources).toHaveLength(1); + }); + + it("runs only preferred providers when requested", async () => { + const service = createMeteoService({ + providers: [ + createMeteoProvider({ + name: "ignored-provider", + sourceType: "observed", + async getSnapshot() { + throw new Error("ignored provider should not run"); + }, + }), + createMeteoProvider({ + name: "selected-provider", + sourceType: "calculated", + capabilities: { astronomy: true }, + async getSnapshot() { + return { + astronomy: { + moonPhase: { + value: "new moon", + unit: "text", + provenance: { provider: "selected-provider", sourceType: "calculated" }, + }, + }, + }; + }, + }), + ], + }); + + await expect(service.getSnapshot({ ...request, preferredProviders: ["selected-provider"] })) + .resolves.toMatchObject({ astronomy: { moonPhase: { value: "new moon" } } }); + }); +}); + +describe("meteo provider merge priority", () => { + it("prefers higher-quality source types even when they run later", async () => { + const fallbackProvider = createMeteoProvider({ + name: "fallback-first", + sourceType: "fallback", + capabilities: { wind: true }, + async getSnapshot() { + return { + wind: { + speedKnots: { + value: 9, + unit: "kn", + provenance: { provider: "fallback-first", sourceType: "fallback" }, + }, + }, + }; + }, + }); + const observedProvider = createMeteoProvider({ + name: "observed-second", + sourceType: "observed", + capabilities: { wind: true }, + async getSnapshot() { + return { + wind: { + speedKnots: { + value: 14, + unit: "kn", + provenance: { provider: "observed-second", sourceType: "observed" }, + }, + }, + }; + }, + }); + const service = createMeteoService({ providers: [fallbackProvider, observedProvider] }); + + await expect(service.getSnapshot(request)).resolves.toMatchObject({ + wind: { + speedKnots: { + value: 14, + provenance: { provider: "observed-second", sourceType: "observed" }, + }, + }, + }); + }); + + it("keeps the earlier value when source types have equal rank", async () => { + const firstObservedProvider = createMeteoProvider({ + name: "first-observed", + sourceType: "observed", + async getSnapshot() { + return { + weather: { + pressureHpa: { + value: 1012, + unit: "hPa", + provenance: { provider: "first-observed", sourceType: "observed" }, + }, + }, + }; + }, + }); + const secondObservedProvider = createMeteoProvider({ + name: "second-observed", + sourceType: "observed", + async getSnapshot() { + return { + weather: { + pressureHpa: { + value: 1015, + unit: "hPa", + provenance: { provider: "second-observed", sourceType: "observed" }, + }, + }, + }; + }, + }); + const service = createMeteoService({ providers: [firstObservedProvider, secondObservedProvider] }); + + await expect(service.getSnapshot(request)).resolves.toMatchObject({ + weather: { + pressureHpa: { + value: 1012, + provenance: { provider: "first-observed", sourceType: "observed" }, + }, + }, + }); + }); +}); diff --git a/tests/app/domain-meteo-remarks.test.ts b/tests/app/domain-meteo-remarks.test.ts new file mode 100644 index 0000000..3b16d0b --- /dev/null +++ b/tests/app/domain-meteo-remarks.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { createMeteoSourceRemark, createMeteoSourceRemarkParts, type MeteoSnapshot, type MeteoValueProvenance } from "../../app/domain/meteo"; + +const ndbcProvenance: MeteoValueProvenance = { + provider: "noaa-ndbc", + providerLabel: "NOAA National Data Buoy Center", + sourceType: "observed", + station: { id: "44013", latitude: 42.346, longitude: -70.651, distanceNm: 3.24 }, + observedAt: new Date("2026-07-18T12:10:00Z"), + validAt: new Date("2026-07-18T12:10:00Z"), +}; + +const astronomyProvenance: MeteoValueProvenance = { + provider: "local-astronomy", + providerLabel: "Local astronomy calculation", + sourceType: "calculated", + calculatedAt: new Date("2026-07-18T12:30:00Z"), + validAt: new Date("2026-07-18T12:30:00Z"), +}; + +const fallbackProvenance: MeteoValueProvenance = { + provider: "open-meteo", + providerLabel: "Open-Meteo", + sourceType: "fallback", + validAt: new Date("2026-07-18T12:00:00Z"), +}; + +describe("meteo source remarks", () => { + it("groups fields that share the same provenance", () => { + const snapshot: MeteoSnapshot = { + requestedAt: new Date("2026-07-18T12:30:00Z"), + validAt: new Date("2026-07-18T12:10:00Z"), + position: { latitude: 42.35, longitude: -70.65 }, + mode: "auto", + weather: { + pressureHpa: { value: 1013.2, unit: "hPa", provenance: ndbcProvenance }, + }, + wind: { + directionDeg: { value: 190, unit: "deg", provenance: ndbcProvenance }, + speedKnots: { value: 12, unit: "kn", provenance: ndbcProvenance }, + }, + astronomy: { + moonPhase: { value: "waxing crescent", unit: "text", provenance: astronomyProvenance }, + }, + sources: [], + warnings: [], + }; + + expect(createMeteoSourceRemark(snapshot)).toBe( + "barometer, wind direction, and wind speed from NOAA National Data Buoy Center station 44013, 3.2 NM away (observed 2026-07-18T12:10:00Z). " + + "moon phase from Local astronomy calculation (calculated 2026-07-18T12:30:00Z).", + ); + }); + + it("marks fallback estimates clearly", () => { + const snapshot: MeteoSnapshot = { + requestedAt: new Date("2026-07-18T12:30:00Z"), + validAt: new Date("2026-07-18T12:00:00Z"), + position: { latitude: 54.1, longitude: 10.2 }, + mode: "auto", + weather: { + cloudCoverPercent: { value: 40, unit: "%", provenance: fallbackProvenance }, + }, + sources: [], + warnings: [], + }; + + expect(createMeteoSourceRemark(snapshot)).toBe( + "cloud cover from Open-Meteo (fallback estimate 2026-07-18T12:00:00Z).", + ); + }); + + it("exposes structured field keys for later translation", () => { + const snapshot: MeteoSnapshot = { + requestedAt: new Date("2026-07-18T12:30:00Z"), + validAt: new Date("2026-07-18T12:10:00Z"), + position: { latitude: 42.35, longitude: -70.65 }, + mode: "auto", + weather: { pressureHpa: { value: 1013.2, unit: "hPa", provenance: ndbcProvenance } }, + wind: { speedKnots: { value: 12, unit: "kn", provenance: ndbcProvenance } }, + sources: [], + warnings: [], + }; + + expect(createMeteoSourceRemarkParts(snapshot)).toMatchObject([{ + fields: ["barometer", "windSpeed"], + provenance: { provider: "noaa-ndbc", sourceType: "observed" }, + }]); + }); + + it("returns a clear message when no source metadata exists", () => { + expect(createMeteoSourceRemark({ + requestedAt: new Date("2026-07-18T12:30:00Z"), + validAt: new Date("2026-07-18T12:30:00Z"), + position: { latitude: 54.1, longitude: 10.2 }, + mode: "auto", + sources: [], + warnings: [], + })).toBe("No meteo source metadata available."); + }); +});