Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions app/domain/meteo/cache.ts
Original file line number Diff line number Diff line change
@@ -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<TValue> = {
expiresAt: number;
value: Promise<TValue>;
};

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<string, CacheEntry<MeteoProviderStation[]>>();
const snapshotCache = new Map<string, CacheEntry<MeteoProviderSnapshot>>();

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<TValue>(
cache: Map<string, CacheEntry<TValue>>,
key: string,
now: () => number,
ttlMs: number,
load: () => Promise<TValue>,
) {
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,
});
}
80 changes: 80 additions & 0 deletions app/domain/meteo/default-service.ts
Original file line number Diff line number Diff line change
@@ -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<FreeMeteoProviderName, () => 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 };
61 changes: 61 additions & 0 deletions app/domain/meteo/index.ts
Original file line number Diff line number Diff line change
@@ -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";
103 changes: 103 additions & 0 deletions app/domain/meteo/local-astronomy.ts
Original file line number Diff line number Diff line change
@@ -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";
}
Loading
Loading