Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
# @suaveplan/tides - Tidal prediction engine: harmonic constituents, equilibrium-tide physics, ADMIRALTY UK Hydrographic Office API client, and station search — exposed via a unified TidalProvider interface.
## Summary
Tidal prediction providers.
<!-- begin:auto-docs -->
## Documentation tree
- [admiralty](./src/admiralty/admiralty.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [admiralty](./src/admiralty/admiralty.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [harmonic](./src/harmonic/harmonic.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [harmonic-constituents](./src/harmonic/harmonic-constituents.md) — Astronomical helper functions, Doodson coefficient tables, and per-constituent angular-speed tables consumed by the har…
- [harmonic](./src/harmonic/harmonic.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [physics](./src/physics/physics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [physics](./src/physics/physics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [stations](./src/stations/stations.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [stations](./src/stations/stations.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [admiralty](./src/admiralty/admiralty.md) — <!-- begin:auto-docs --> _(no submodules)_- [harmonic](./src/harmonic/harmonic.md) — <!-- begin:auto-docs --> _(no submodules)_- [harmonic-constituents](./src/harmonic/harmonic-constituents.md) — Astronomical helper functions, Doodson coefficient tables, and per-constituent angular-speed tables consumed by the har…
- [harmonic](./src/harmonic/harmonic.md) — <!-- begin:auto-docs --> _(no submodules)_- [physics](./src/physics/physics.md) — <!-- begin:auto-docs --> _(no submodules)_- [physics](./src/physics/physics.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [stations](./src/stations/stations.md) — <!-- begin:auto-docs --> _(no submodules)_- [stations](./src/stations/stations.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [admiralty](./src/admiralty/admiralty.md) — <!-- begin:auto-docs --> _(no submodules)_- [harmonic](./src/harmonic/harmonic.md) — <!-- begin:auto-docs --> _(no submodules)_- [harmonic-constituents](./src/harmonic/harmonic-constituents.md) — Astronomical helper functions, Doodson coefficient tables, and per-constituent angular-speed tables consumed by the har…
- [harmonic](./src/harmonic/harmonic.md) — <!-- begin:auto-docs --> _(no submodules)_- [physics](./src/physics/physics.md) — <!-- begin:auto-docs --> _(no submodules)_- [physics](./src/physics/physics.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [stations](./src/stations/stations.md) — <!-- begin:auto-docs --> _(no submodules)_- [stations](./src/stations/stations.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [admiralty](./src/admiralty/admiralty.md) — <!-- begin:auto-docs --> _(no submodules)_- [harmonic](./src/harmonic/harmonic.md) — <!-- begin:auto-docs --> _(no submodules)_- [harmonic-constituents](./src/harmonic/harmonic-constituents.md) — Astronomical helper functions, Doodson coefficient tables, and per-constituent angular-speed tables consumed by the har…
- [harmonic](./src/harmonic/harmonic.md) — <!-- begin:auto-docs --> _(no submodules)_- [physics](./src/physics/physics.md) — <!-- begin:auto-docs --> _(no submodules)_- [physics](./src/physics/physics.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [stations](./src/stations/stations.md) — <!-- begin:auto-docs --> _(no submodules)_- [stations](./src/stations/stations.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [admiralty](./src/admiralty/admiralty.md) — <!-- begin:auto-docs --> _(no submodules)_- [harmonic](./src/harmonic/harmonic.md) — <!-- begin:auto-docs --> _(no submodules)_ - [harmonic-constituents](./src/harmonic/harmonic-constituents.md) — Astronomical helper functions, Doodson coefficient tables, and per-constituent angular-speed tables consumed by the har…
- [harmonic](./src/harmonic/harmonic.md) — <!-- begin:auto-docs --> _(no submodules)_- [physics](./src/physics/physics.md) — <!-- begin:auto-docs --> _(no submodules)_ - [physics](./src/physics/physics.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_ - [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [stations](./src/stations/stations.md) — <!-- begin:auto-docs --> _(no submodules)_ - [stations](./src/stations/stations.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/tides
```

View File

@@ -0,0 +1,47 @@
{
"name": "@suaveplan/tides",
"version": "0.1.0",
"description": "Tidal prediction engine: harmonic constituents, equilibrium-tide physics, ADMIRALTY UK Hydrographic Office API client, and station search — exposed via a unified TidalProvider interface.",
"type": "module",
"sideEffects": false,
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./harmonic/harmonic": {
"import": "./dist/harmonic/harmonic.js",
"types": "./dist/harmonic/harmonic.d.ts"
},
"./provider/provider": {
"import": "./dist/provider/provider.js",
"types": "./dist/provider/provider.d.ts"
}
},
"scripts": {
"build": "vite build",
"lint": "bunx biome check src/",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json"
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/geo": "workspace:^",
"@suaveplan/geo-planet": "workspace:^",
"@suaveplan/types": "workspace:^",
"@suaveplan/universe": "workspace:^",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"peerDependencies": {
"typescript": "^5.8"
}
}

View File

@@ -0,0 +1,73 @@
# admiralty
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Production HTTP client for the UK Hydrographic Office's ADMIRALTY Tidal API (`https://admiraltyapi.azure-api.net/uktidalapi/api/V1`). Wraps the `/Stations` and `/Stations/{id}/TidalEvents` endpoints with typed parsers, in-memory TTL caching, structured error mapping, and a tide-table builder that interpolates between published high/low events using a cosine progression. Designed for offline-tolerant UI flows where station catalogues and per-day extreme lists should be fetched once and reused.
## Features
- `createAdmiraltyProvider(options)` factory returning a fully configured `AdmiraltyProvider`.
- API-key authentication via `Ocp-Apim-Subscription-Key` header.
- Injectable `fetchFn` for testing or custom transport (e.g. retry middleware).
- Per-key in-memory cache with configurable `cacheTtlMs` (default 1 hour) plus dedicated unbounded station cache.
- Structured `TideError` mapping for 401/403 (`AUTH_ERROR`), 429 (`RATE_LIMIT`), other 4xx/5xx (`API_ERROR`), and empty payloads (`NO_DATA`).
- Cosine-progress interpolation between consecutive high/low events for sub-event height predictions.
- Multi-day tide-table builder with day-by-day extreme aggregation.
## Usage: Basic Example
```ts
import { createAdmiraltyProvider } from "@suaveplan/tides";
const provider = createAdmiraltyProvider({ apiKey: process.env.ADMIRALTY_KEY! });
const stations = await provider.getStations();
console.log(`${stations.length} ADMIRALTY stations available`);
```
## Usage: Advanced Example
```ts
import { createAdmiraltyProvider, type TideStation } from "@suaveplan/tides";
const provider = createAdmiraltyProvider({
apiKey: process.env.ADMIRALTY_KEY!,
cacheTtlMs: 30 * 60_000,
fetchFn: (url, init) =>
fetch(url, { ...init, signal: AbortSignal.timeout(10_000) }),
});
const { station, distanceKm } = await provider.getNearestStation({
latitude: 51.5074,
longitude: -0.1278,
});
console.log(`Nearest: ${station.name} (${distanceKm} km)`);
const table = await provider.getTideTable(
station,
new Date("2024-06-15T00:00:00Z"),
new Date("2024-06-17T23:59:59Z"),
60
);
for (const e of table.extremes) {
console.log(e.type, e.time.toISOString(), e.height.toFixed(2));
}
```
## API Reference
- `createAdmiraltyProvider(options: AdmiraltyProviderOptions): AdmiraltyProvider`.
- `AdmiraltyProviderOptions` — `{ apiKey: string; fetchFn?: typeof fetch; cacheTtlMs?: number }`.
- `AdmiraltyProvider`:
- `name: "admiralty"`.
- `getStations(signal?: AbortSignal): Promise<readonly TideStation[]>`.
- `getNearestStation(coord: SphericalCoord, signal?): Promise<NearestStationResult>`.
- `predictHeight(station, time, signal?): Promise<number>` — cosine-interpolated between extremes.
- `getExtremes(station, date, signal?): Promise<readonly TideExtreme[]>` — events whose `DateTime` falls on the UTC date.
- `getTideTable(station, start, end, intervalMinutes?, signal?): Promise<TideTable>`.
## Implementation Notes
Station IDs are namespaced as `admiralty-${rawId}` so they cannot collide with other-provider IDs in a merged catalogue. The cache uses a single `Map<string, CacheEntry<unknown>>` with per-entry `expiresAt` checked on read; expired entries are deleted lazily, so the map never grows beyond active keys. The cosine interpolation `(1 - cos(π·progress))/2` is the classical "tidal half-cosine" approximation — accurate to a few centimetres in the middle of the tidal cycle but degrades near slack water; for high-fidelity prediction prefer harmonic constituents via `createTiconProvider`. AbortSignal is forwarded conditionally (only when defined) to keep the `fetch` options object clean for environments that reject explicit `signal: undefined`.

View File

@@ -0,0 +1,366 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { TideError } from "../types/types.js";
import { AdmiraltyProvider, createAdmiraltyProvider } from "./admiralty.js";
function createMockFetch(
responseBody: unknown,
status = 200
): typeof globalThis.fetch {
return vi.fn().mockResolvedValue({
ok: status === 200,
status,
statusText: status === 200 ? "OK" : "Error",
json: () => Promise.resolve(responseBody),
} as Response);
}
function makeStation(id: string, lat: number, lng: number) {
return {
id,
name: id,
latitude: lat,
longitude: lng,
provider: "admiralty",
};
}
describe("createAdmiraltyProvider", () => {
it("returns an AdmiraltyProvider instance", { timeout: 5000 }, () => {
const provider = createAdmiraltyProvider({ apiKey: "test-key" });
expect(provider).toBeInstanceOf(AdmiraltyProvider);
expect(provider.name).toBe("admiralty");
});
});
describe("AdmiraltyProvider.getStations", () => {
it("fetches and parses stations from the API", {
timeout: 30000,
}, async () => {
const mockFetch = createMockFetch({
features: [
{
properties: {
Id: "001",
Name: "Dover",
Country: "UK",
Latitude: 51.1,
Longitude: 1.3,
},
},
],
});
const provider = createAdmiraltyProvider({
apiKey: "test-key",
fetchFn: mockFetch,
});
const stations = await provider.getStations();
expect(stations).toHaveLength(1);
expect(stations[0]!.id).toBe("admiralty-001");
expect(stations[0]!.name).toBe("Dover");
});
it("caches station results", { timeout: 30000 }, async () => {
const mockFetch = createMockFetch({ features: [] });
const provider = createAdmiraltyProvider({
apiKey: "test-key",
fetchFn: mockFetch,
});
await provider.getStations();
await provider.getStations();
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("expires cached extremes past TTL", { timeout: 30000 }, async () => {
const mockFetch = createMockFetch([
{
DateTime: "2024-01-15T06:00:00Z",
Height: 2.0,
IsHighWater: true,
EventType: "HighWater",
},
]);
const provider = createAdmiraltyProvider({
apiKey: "test-key",
fetchFn: mockFetch,
cacheTtlMs: 1,
});
const station = makeStation("admiralty-001", 0, 0);
const date = new Date("2024-01-15T00:00:00Z");
await provider.getExtremes(station, date);
await new Promise((r) => setTimeout(r, 5));
await provider.getExtremes(station, date);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
describe("AdmiraltyProvider.getNearestStation", () => {
it("finds nearest station to a coordinate", {
timeout: 30000,
}, async () => {
const mockFetch = createMockFetch({
features: [
{
properties: {
Id: "001",
Name: "London",
Country: "UK",
Latitude: 51.5,
Longitude: -0.1,
},
},
{
properties: {
Id: "002",
Name: "Dover",
Country: "UK",
Latitude: 51.1,
Longitude: 1.3,
},
},
],
});
const provider = createAdmiraltyProvider({
apiKey: "test-key",
fetchFn: mockFetch,
});
const result = await provider.getNearestStation({
latitude: 51.4,
longitude: 0,
});
expect(result.station.name).toBe("London");
expect(result.distanceKm).toBeGreaterThan(0);
});
});
describe("AdmiraltyProvider.predictHeight", () => {
it("predicts height by interpolating between extremes", {
timeout: 30000,
}, async () => {
const baseDate = "2024-01-15";
const mockFetch = createMockFetch([
{
DateTime: `${baseDate}T06:00:00Z`,
Height: 2.0,
IsHighWater: true,
EventType: "HighWater",
},
{
DateTime: `${baseDate}T12:00:00Z`,
Height: 1.0,
IsHighWater: false,
EventType: "LowWater",
},
]);
const provider = createAdmiraltyProvider({
apiKey: "test-key",
fetchFn: mockFetch,
});
const station = makeStation("admiralty-001", 51.5, -0.1);
const at9am = new Date(`${baseDate}T09:00:00Z`);
const height = await provider.predictHeight(station, at9am);
expect(height).toBeGreaterThan(1);
expect(height).toBeLessThan(2);
});
it("returns last extreme when after all extremes", {
timeout: 30000,
}, async () => {
const mockFetch = createMockFetch([
{
DateTime: "2024-01-15T06:00:00Z",
Height: 2.0,
IsHighWater: true,
EventType: "HighWater",
},
]);
const provider = createAdmiraltyProvider({
apiKey: "test-key",
fetchFn: mockFetch,
});
const station = makeStation("admiralty-001", 0, 0);
const after = new Date("2024-01-15T08:00:00Z");
const height = await provider.predictHeight(station, after);
expect(height).toBe(2);
});
it("throws when no extremes returned", { timeout: 30000 }, async () => {
const mockFetch = createMockFetch([]);
const provider = createAdmiraltyProvider({
apiKey: "test-key",
fetchFn: mockFetch,
});
const station = makeStation("admiralty-001", 0, 0);
const date = new Date("2024-01-15T00:00:00Z");
await expect(provider.predictHeight(station, date)).rejects.toThrow(
TideError
);
});
});
describe("AdmiraltyProvider.getTideTable", () => {
it("builds a multi-day table with hourly predictions", {
timeout: 30000,
}, async () => {
const mockFetch = createMockFetch([
{
DateTime: "2024-01-15T06:00:00Z",
Height: 2.0,
IsHighWater: true,
EventType: "HighWater",
},
{
DateTime: "2024-01-15T12:00:00Z",
Height: 1.0,
IsHighWater: false,
EventType: "LowWater",
},
]);
const provider = createAdmiraltyProvider({
apiKey: "test-key",
fetchFn: mockFetch,
});
const station = makeStation("admiralty-001", 0, 0);
const start = new Date("2024-01-15T00:00:00Z");
const end = new Date("2024-01-15T23:00:00Z");
const table = await provider.getTideTable(station, start, end);
expect(table.station).toBe(station);
expect(table.predictions.length).toBeGreaterThan(0);
expect(table.extremes.length).toBeGreaterThan(0);
});
});
describe("AdmiraltyProvider abort signal", () => {
it("forwards AbortSignal to fetch", { timeout: 30000 }, async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
json: () => Promise.resolve({ features: [] }),
} as Response);
const provider = createAdmiraltyProvider({
apiKey: "test-key",
fetchFn: mockFetch,
});
const controller = new AbortController();
await provider.getStations(controller.signal);
const lastCall = mockFetch.mock.calls.at(-1);
expect(lastCall?.[1]?.signal).toBe(controller.signal);
});
});
describe("AdmiraltyProvider.getExtremes", () => {
it("parses tidal events into TideExtreme format", {
timeout: 30000,
}, async () => {
const mockFetch = createMockFetch([
{
DateTime: "2024-01-15T06:00:00Z",
Height: 2.5,
IsHighWater: true,
EventType: "HighWater",
},
{
DateTime: "2024-01-15T12:00:00Z",
Height: 1.0,
IsHighWater: false,
EventType: "LowWater",
},
]);
const provider = createAdmiraltyProvider({
apiKey: "test-key",
fetchFn: mockFetch,
});
const station = makeStation("admiralty-001", 0, 0);
const extremes = await provider.getExtremes(
station,
new Date("2024-01-15T00:00:00Z")
);
expect(extremes).toHaveLength(2);
expect(extremes[0]!.type).toBe("high");
expect(extremes[0]!.height).toBe(2.5);
expect(extremes[1]!.type).toBe("low");
expect(extremes[1]!.height).toBe(1.0);
});
});
describe("AdmiraltyProvider.getTideTable", () => {
it("builds a tide table for a date range", { timeout: 30000 }, async () => {
const mockFetch = createMockFetch([
{
DateTime: "2024-01-15T06:00:00Z",
Height: 2.0,
IsHighWater: true,
EventType: "HighWater",
},
{
DateTime: "2024-01-15T12:00:00Z",
Height: 1.0,
IsHighWater: false,
EventType: "LowWater",
},
]);
const provider = createAdmiraltyProvider({
apiKey: "test-key",
fetchFn: mockFetch,
});
const station = makeStation("admiralty-001", 0, 0);
const table = await provider.getTideTable(
station,
new Date("2024-01-15T00:00:00Z"),
new Date("2024-01-15T23:59:59Z"),
360
);
expect(table.station).toBe(station);
expect(table.extremes.length).toBeGreaterThan(0);
expect(table.predictions.length).toBeGreaterThan(0);
});
});
describe("AdmiraltyProvider error handling", () => {
it("throws AUTH_ERROR on 401", { timeout: 30000 }, async () => {
const mockFetch = createMockFetch({}, 401);
const provider = createAdmiraltyProvider({
apiKey: "bad-key",
fetchFn: mockFetch,
});
await expect(provider.getStations()).rejects.toThrow(
"Invalid or expired"
);
});
it("throws RATE_LIMIT on 429", { timeout: 30000 }, async () => {
const mockFetch = createMockFetch({}, 429);
const provider = createAdmiraltyProvider({
apiKey: "test-key",
fetchFn: mockFetch,
});
await expect(provider.getStations()).rejects.toThrow("rate limit");
});
it("throws API_ERROR on other failures", { timeout: 30000 }, async () => {
const mockFetch = createMockFetch({}, 500);
const provider = createAdmiraltyProvider({
apiKey: "test-key",
fetchFn: mockFetch,
});
await expect(provider.getStations()).rejects.toThrow("API error: 500");
});
});

View File

@@ -0,0 +1,251 @@
import type { SphericalCoord } from "@suaveplan/geo";
import type { TideExtreme } from "@suaveplan/types";
import { findNearestStation } from "../stations/stations.js";
import type {
NearestStationResult,
TideStation,
TideTable,
} from "../types/types.js";
import { TideError } from "../types/types.js";
const BASE_URL = "https://admiraltyapi.azure-api.net/uktidalapi/api/V1";
/** Options for creating an ADMIRALTY provider. */
export interface AdmiraltyProviderOptions {
/** ADMIRALTY API subscription key (Ocp-Apim-Subscription-Key header). */
readonly apiKey: string;
/** Optional custom fetch implementation. */
readonly fetchFn?: typeof globalThis.fetch;
/** Cache TTL in milliseconds (default: 1 hour). */
readonly cacheTtlMs?: number;
}
interface AdmiraltyStation {
readonly Id: string;
readonly Name: string;
readonly Country: string;
readonly Latitude: number;
readonly Longitude: number;
}
interface AdmiraltyTidalEvent {
readonly DateTime: string;
readonly Height: number;
readonly IsHighWater: boolean;
readonly EventType: string;
}
interface CacheEntry<T> {
readonly data: T;
readonly expiresAt: number;
}
/**
* Create an ADMIRALTY Tidal API provider.
* Requires a free API key from https://admiraltyapi.azure-api.net/
*/
export function createAdmiraltyProvider(
options: AdmiraltyProviderOptions
): AdmiraltyProvider {
return new AdmiraltyProvider(options);
}
export class AdmiraltyProvider {
private readonly apiKey: string;
private readonly fetchFn: typeof globalThis.fetch;
private readonly cacheTtlMs: number;
private readonly cache = new Map<string, CacheEntry<unknown>>();
constructor(options: AdmiraltyProviderOptions) {
this.apiKey = options.apiKey;
this.fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis);
this.cacheTtlMs = options.cacheTtlMs ?? 3_600_000;
}
get name(): string {
return "admiralty";
}
async getStations(signal?: AbortSignal): Promise<readonly TideStation[]> {
const cached = this.getCached<TideStation[]>("stations");
if (cached) return cached;
const data = await this.apiRequest<{
features: { properties: AdmiraltyStation }[];
}>("/Stations", signal);
const stations: TideStation[] = data.features.map((f) => ({
id: `admiralty-${f.properties.Id}`,
name: f.properties.Name,
latitude: f.properties.Latitude,
longitude: f.properties.Longitude,
provider: "admiralty",
}));
this.setCache("stations", stations);
return stations;
}
async getNearestStation(
coord: SphericalCoord,
signal?: AbortSignal
): Promise<NearestStationResult> {
const stations = await this.getStations(signal);
return findNearestStation(coord.latitude, coord.longitude, stations);
}
async predictHeight(
station: TideStation,
time: Date,
signal?: AbortSignal
): Promise<number> {
const extremes = await this.getExtremes(station, time, signal);
if (extremes.length === 0) {
throw new TideError(
"No tidal events returned for this date",
"NO_DATA"
);
}
const t = time.getTime();
let before: TideExtreme | null = null;
let after: TideExtreme | null = null;
for (const ext of extremes) {
const et = ext.time.getTime();
if (et <= t) before = ext;
if (et > t && !after) after = ext;
}
if (!before) return after!.height;
if (!after) return before.height;
const progress =
(t - before.time.getTime()) /
(after.time.getTime() - before.time.getTime());
const cosProgress = (1 - Math.cos(progress * Math.PI)) / 2;
return (
Math.round(
(before.height + (after.height - before.height) * cosProgress) *
1000
) / 1000
);
}
async getExtremes(
station: TideStation,
date: Date,
signal?: AbortSignal
): Promise<readonly TideExtreme[]> {
const admiraltyId = station.id.replace("admiralty-", "");
const cacheKey = `extremes-${admiraltyId}-${date.toISOString().slice(0, 10)}`;
const cached = this.getCached<TideExtreme[]>(cacheKey);
if (cached) return cached;
const events = await this.apiRequest<AdmiraltyTidalEvent[]>(
`/Stations/${admiraltyId}/TidalEvents`,
signal
);
const dateStr = date.toISOString().slice(0, 10);
const extremes: TideExtreme[] = events
.filter((e) => e.DateTime.startsWith(dateStr))
.map((e) => ({
type: e.IsHighWater ? ("high" as const) : ("low" as const),
height: e.Height,
time: new Date(e.DateTime),
}));
this.setCache(cacheKey, extremes);
return extremes;
}
async getTideTable(
station: TideStation,
start: Date,
end: Date,
intervalMinutes = 60,
signal?: AbortSignal
): Promise<TideTable> {
const allExtremes: TideExtreme[] = [];
const currentDate = new Date(
Date.UTC(
start.getUTCFullYear(),
start.getUTCMonth(),
start.getUTCDate()
)
);
const endTime = end.getTime();
while (currentDate.getTime() <= endTime) {
const dayExtremes = await this.getExtremes(
station,
currentDate,
signal
);
allExtremes.push(...dayExtremes);
currentDate.setUTCDate(currentDate.getUTCDate() + 1);
}
const predictions = [];
const intervalMs = intervalMinutes * 60000;
for (let t = start.getTime(); t <= end.getTime(); t += intervalMs) {
const time = new Date(t);
const height = await this.predictHeight(station, time, signal);
predictions.push({ height, time, station });
}
return { station, predictions, extremes: allExtremes };
}
private getCached<T>(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data as T;
}
private setCache<T>(key: string, data: T): void {
this.cache.set(key, { data, expiresAt: Date.now() + this.cacheTtlMs });
}
private async apiRequest<T>(
path: string,
signal?: AbortSignal
): Promise<T> {
const url = `${BASE_URL}${path}`;
const response = await this.fetchFn(url, {
headers: {
"Ocp-Apim-Subscription-Key": this.apiKey,
},
...(signal !== undefined ? { signal } : {}),
});
if (!response.ok) {
const status = response.status;
if (status === 401 || status === 403) {
throw new TideError(
"Invalid or expired ADMIRALTY API key",
"AUTH_ERROR"
);
}
if (status === 429) {
throw new TideError(
"ADMIRALTY API rate limit exceeded",
"RATE_LIMIT"
);
}
throw new TideError(
`ADMIRALTY API error: ${status} ${response.statusText}`,
"API_ERROR"
);
}
return response.json() as Promise<T>;
}
}

View File

@@ -0,0 +1,5 @@
export type { AdmiraltyProviderOptions } from "./admiralty.js";
export {
AdmiraltyProvider,
createAdmiraltyProvider,
} from "./admiralty.js";

View File

@@ -0,0 +1,54 @@
# harmonic-constituents
## Purpose
Astronomical helper functions, Doodson coefficient tables, and per-constituent angular-speed tables consumed by the harmonic tide-prediction engine. This module is split out of `harmonic.ts` so that the prediction algorithm and its lookup data each stay below the 300-line file budget while remaining colocated under `src/harmonic/`. Every export is a pure, stateless utility — no I/O, no shared state.
## Features
- `julianDate(date)` and J2000-relative century parameter helpers feeding mean-longitude polynomials.
- Mean longitude functions for Moon (`s`), Sun (`h`), lunar perigee (`p`), ascending lunar node (`N`), and solar perigee (`pp`).
- `astronomicalArgument(name, time)` — Doodson-coefficient combination yielding `V₀ + u` for a named constituent.
- `nodeFactor(name, time)` — Schureman-approximation `f` factor for amplitude modulation.
- `DOODSON_NUMBERS` table covering 30+ constituents (M2, S2, N2, K2, K1, O1, P1, Q1, M4, MS4, MN4, M6, …).
- `CONSTITUENT_SPEEDS` angular-speed table in degrees per hour.
## Usage: Basic Example
```ts
import { CONSTITUENT_SPEEDS } from "@suaveplan/tides";
const m2Speed = CONSTITUENT_SPEEDS.M2; // 28.984104 deg/hr
```
## Usage: Advanced Example
```ts
import {
astronomicalArgument,
DOODSON_NUMBERS,
nodeFactor,
} from "@suaveplan/tides";
const time = new Date("2024-06-15T12:00:00Z");
for (const name of Object.keys(DOODSON_NUMBERS)) {
const v0 = astronomicalArgument(name, time);
const f = nodeFactor(name, time);
console.log(name, v0.toFixed(2), f.toFixed(4));
}
```
## API Reference
- `toRad(deg: number): number` — degrees to radians.
- `julianDate(date: Date): number` — Julian Day Number including fractional day.
- `normalize(deg: number): number` — wrap angle into `[0, 360)`.
- `meanLongitudeMoon|Sun|LunarPerigee|LunarNode|SolarPerigee(T: number): number` — mean longitudes evaluated at Julian centuries since J2000.
- `astronomicalArgument(name: string, time: Date): number` — `V₀ + u` in degrees, returns `0` for unknown names.
- `nodeFactor(name: string, time: Date): number` — `f` in `[~0.7, ~1.3]`, returns `1.0` for unrecognised constituents.
- `DOODSON_NUMBERS: Record<string, readonly [number, number, number, number, number, number]>` — six-tuple `(τ, s, h, p, N, pp)` coefficients.
- `CONSTITUENT_SPEEDS: Record<string, number>` — angular speed in degrees per hour.
## Implementation Notes
Mean-longitude polynomials are truncated to the linear term in `T` (Julian centuries since J2000.0). This is adequate for short-range tide prediction but not for high-precision astronomical work — for centennial accuracy, quadratic and cubic terms from Meeus' *Astronomical Algorithms* are required. Node factors are simplified Schureman approximations: full-rigour evaluation requires lunar node `N` and lunar-perigee `p` together, but for engineering work the dominant `cos N`/`cos 2N` terms suffice. The Doodson and speed tables are intentionally exposed as plain `Record<string, …>` to keep registry extension a one-liner for callers shipping additional shallow-water or compound constituents.

View File

@@ -0,0 +1,200 @@
/**
* Astronomical helpers, Doodson coefficients, angular speeds, and node-factor
* tables used by the harmonic tide prediction engine. Split out of
* `harmonic.ts` to satisfy the 300-line per-file limit while keeping the
* physics logic colocated with the prediction algorithm.
*/
/** Degrees to radians. */
export function toRad(deg: number): number {
return (deg * Math.PI) / 180;
}
/** Julian date from a JavaScript Date. */
export function julianDate(date: Date): number {
return date.getTime() / 86400000 + 2440587.5;
}
/** Normalize angle to 0-360 degrees. */
export function normalize(deg: number): number {
return ((deg % 360) + 360) % 360;
}
/** Mean longitude of Moon (degrees). */
export function meanLongitudeMoon(T: number): number {
return normalize(218.3165 + 481267.8813 * T);
}
/** Mean longitude of Sun (degrees). */
export function meanLongitudeSun(T: number): number {
return normalize(280.4664 + 36000.7698 * T);
}
/** Mean longitude of lunar perigee (degrees). */
export function meanLongitudeLunarPerigee(T: number): number {
return normalize(83.3533 + 4069.0137 * T);
}
/** Mean longitude of ascending lunar node (degrees). */
export function meanLongitudeLunarNode(T: number): number {
return normalize(125.0445 - 1934.1363 * T);
}
/** Mean longitude of solar perigee / perihelion (degrees). */
export function meanLongitudeSolarPerigee(T: number): number {
return normalize(282.9373 + 1.7196 * T);
}
/**
* Astronomical argument V₀ + u for a constituent at a given time.
* Uses simplified Schureman approximations for node factors and
* equilibrium arguments.
*/
export function astronomicalArgument(
constituentName: string,
time: Date
): number {
const jd = julianDate(time);
const T = (jd - 2451545.0) / 36525.0;
const s = meanLongitudeMoon(T);
const h = meanLongitudeSun(T);
const p = meanLongitudeLunarPerigee(T);
const N = meanLongitudeLunarNode(T);
const pp = meanLongitudeSolarPerigee(T);
const hourOfDay =
time.getUTCHours() +
time.getUTCMinutes() / 60 +
time.getUTCSeconds() / 3600;
const tau = hourOfDay * 15 + h - s;
const doodson = DOODSON_NUMBERS[constituentName];
if (!doodson) return 0;
const [d1, d2, d3, d4, d5, d6] = doodson;
return normalize(d1 * tau + d2 * s + d3 * h + d4 * p + d5 * N + d6 * pp);
}
/**
* Node factor (f) for amplitude modulation.
* Simplified — returns 1.0 for most constituents.
*/
export function nodeFactor(constituentName: string, time: Date): number {
const jd = julianDate(time);
const T = (jd - 2451545.0) / 36525.0;
const N = toRad(meanLongitudeLunarNode(T));
const cosN = Math.cos(N);
const cos2N = Math.cos(2 * N);
switch (constituentName) {
case "M2":
case "N2":
case "2N2":
case "MU2":
case "NU2":
return Math.sqrt(
(1 - 0.03731 * cosN + 0.00052 * cos2N) ** 2 +
(0.03731 * Math.sin(N) - 0.00052 * Math.sin(2 * N)) ** 2
);
case "K1":
return Math.sqrt(
(1 + 0.1158 * cosN - 0.0029 * cos2N) ** 2 +
(0.1554 * Math.sin(N) - 0.0029 * Math.sin(2 * N)) ** 2
);
case "O1":
case "Q1":
case "2Q1":
case "RHO1":
case "SIGMA1":
return Math.sqrt(
(1 + 0.189 * cosN - 0.0058 * cos2N) ** 2 +
(0.189 * Math.sin(N)) ** 2
);
case "K2":
return Math.sqrt(
(1 + 0.2852 * cosN + 0.0324 * cos2N) ** 2 +
(0.3108 * Math.sin(N) + 0.0324 * Math.sin(2 * N)) ** 2
);
case "S2":
case "T2":
case "R2":
case "P1":
return 1.0;
default:
return 1.0;
}
}
/**
* Doodson numbers for common tidal constituents.
*/
export const DOODSON_NUMBERS: Record<
string,
readonly [number, number, number, number, number, number]
> = {
M2: [2, -2, 2, 0, 0, 0],
S2: [2, 0, 0, 0, 0, 0],
N2: [2, -3, 2, 1, 0, 0],
K2: [2, 0, 2, 0, 0, 0],
"2N2": [2, -4, 2, 2, 0, 0],
MU2: [2, -4, 4, 0, 0, 0],
NU2: [2, -3, 4, -1, 0, 0],
L2: [2, -1, 2, -1, 0, 0],
T2: [2, 0, -1, 0, 0, 1],
R2: [2, 0, 1, 0, 0, -1],
LAM2: [2, -1, 0, 1, 0, 0],
K1: [1, 0, 1, 0, 0, 0],
O1: [1, -2, 1, 0, 0, 0],
P1: [1, 0, -1, 0, 0, 0],
Q1: [1, -3, 1, 1, 0, 0],
J1: [1, 1, 1, -1, 0, 0],
OO1: [1, 2, 1, 0, 0, 0],
"2Q1": [1, -4, 1, 2, 0, 0],
RHO1: [1, -3, 3, -1, 0, 0],
SIGMA1: [1, -4, 3, 0, 0, 0],
MM: [0, 1, 0, -1, 0, 0],
MF: [0, 2, 0, 0, 0, 0],
MSF: [0, 2, -2, 0, 0, 0],
SSA: [0, 0, 2, 0, 0, 0],
SA: [0, 0, 1, 0, 0, 0],
M4: [4, -4, 4, 0, 0, 0],
MS4: [4, -2, 2, 0, 0, 0],
MN4: [4, -5, 4, 1, 0, 0],
M6: [6, -6, 6, 0, 0, 0],
S4: [4, 0, 0, 0, 0, 0],
S6: [6, 0, 0, 0, 0, 0],
};
/** Angular speeds of tidal constituents in degrees per hour. */
export const CONSTITUENT_SPEEDS: Record<string, number> = {
M2: 28.984104,
S2: 30.0,
N2: 28.43973,
K2: 30.082137,
"2N2": 27.895355,
MU2: 27.968208,
NU2: 28.512583,
L2: 29.528479,
T2: 29.958933,
R2: 30.041067,
LAM2: 29.455625,
K1: 15.041069,
O1: 13.943036,
P1: 14.958931,
Q1: 13.398661,
J1: 15.585443,
OO1: 16.139102,
"2Q1": 12.854286,
RHO1: 13.471515,
SIGMA1: 12.92714,
MM: 0.544375,
MF: 1.098033,
MSF: 1.015896,
SSA: 0.082137,
SA: 0.041069,
M4: 57.968208,
MS4: 58.984104,
MN4: 57.423834,
M6: 86.952313,
S4: 60.0,
S6: 90.0,
};

View File

@@ -0,0 +1,69 @@
# harmonic
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the classical harmonic tide-prediction algorithm — superposition of cosine constituents with node-factor amplitude modulation and time-varying equilibrium arguments. The module exposes a stateless `predictHeight` function and a slope-tracking `findExtremes` scan with bisection refinement, both consuming an arbitrary list of `HarmonicConstituent` records (name, amplitude, phase lag, angular speed). It is the engine behind the TICON-style provider and any caller bringing its own constituent table.
## Features
- Pure-function `predictHeight(constituents, time, meanSeaLevel?)` evaluating `h(t) = H₀ + Σ f(t)·Hₙ·cos(ωₙ·t + V₀ₙ(t) - Gₙ)`.
- Slope-sign-change detection in `findExtremes(constituents, start, end, meanSeaLevel?, scanMinutes?)` followed by 10-iteration bisection refinement to ~1-minute accuracy.
- Built-in node-factor (`f`) and equilibrium-argument (`V₀ + u`) tables for 30+ constituents (M2, S2, N2, K2, K1, O1, P1, Q1, M4, MS4, MN4, M6, etc.) — re-exported from `harmonic-constituents`.
- `TideError` thrown on empty constituent list (`NO_CONSTITUENTS`) and inverted ranges (`INVALID_RANGE`).
- Outputs rounded to millimetre precision for stable equality comparisons.
## Usage: Basic Example
```ts
import { predictHeight } from "@suaveplan/tides";
const height = predictHeight(
[{ name: "M2", amplitude: 1.2, phase: 45, speed: 28.984104 }],
new Date("2024-06-15T12:00:00Z"),
3.5
);
```
## Usage: Advanced Example
```ts
import {
CONSTITUENT_SPEEDS,
findExtremes,
type HarmonicConstituent,
} from "@suaveplan/tides";
const constituents: HarmonicConstituent[] = ["M2", "S2", "N2", "K1", "O1"].map(
(name) => ({
name,
amplitude: name === "M2" ? 2.1 : 0.4,
phase: 0,
speed: CONSTITUENT_SPEEDS[name]!,
})
);
const extremes = findExtremes(
constituents,
new Date("2024-06-15T00:00:00Z"),
new Date("2024-06-16T00:00:00Z"),
0,
6
);
for (const e of extremes) {
console.log(e.type, e.time.toISOString(), e.height.toFixed(2));
}
```
## API Reference
- `predictHeight(constituents: readonly HarmonicConstituent[], time: Date, meanSeaLevel?: number): number` — predicted height in metres above datum. Throws `TideError("NO_CONSTITUENTS")` if `constituents` is empty.
- `findExtremes(constituents, start: Date, end: Date, meanSeaLevel?: number, scanMinutes?: number): TideExtreme[]` — ordered high/low events between `start` and `end`. Throws `TideError("INVALID_RANGE")` if `start >= end`.
- `nodeFactor(constituentName: string, time: Date): number` — Schureman-approximation amplitude modulation factor for the named constituent.
- `CONSTITUENT_SPEEDS`, `DOODSON_NUMBERS` re-exported from `./harmonic-constituents`.
## Implementation Notes
The algorithm uses J2000.0 (JD 2451545.0) as the reference epoch for `hoursSinceEpoch`. Equilibrium arguments are computed from mean longitudes of Moon, Sun, lunar/solar perigee, and ascending lunar node via Meeus polynomials truncated to the linear term — sufficient for short-range engineering predictions but not for centennial accuracy. Node factors fall back to `1.0` for unrecognised constituents, matching the classical convention. `findExtremes` uses a midpoint-finite-difference slope at each scan step rather than a true derivative, so `scanMinutes` should stay below the Nyquist limit of the highest-frequency constituent (default 6 minutes is safe through M6).

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TideError } from "../types/types.js";
import { findExtremes, predictHeight } from "./harmonic.js";
import { astronomicalArgument, nodeFactor } from "./harmonic-constituents.js";
describe("predictHeight", () => {
it("predicts with single constituent", { timeout: 5000 }, () => {
const h = predictHeight(
[{ name: "M2", amplitude: 1, phase: 0, speed: 1 }],
new Date("2024-01-01T00:00:00Z"),
0
);
expect(typeof h).toBe("number");
expect(Number.isFinite(h)).toBe(true);
});
it("sums multiple constituents", { timeout: 5000 }, () => {
const h = predictHeight(
[
{ name: "M2", amplitude: 1, phase: 0, speed: 1 },
{ name: "S2", amplitude: 0.5, phase: 0, speed: 2 },
],
new Date("2024-01-01T00:00:00Z"),
0
);
expect(typeof h).toBe("number");
expect(Number.isFinite(h)).toBe(true);
});
});
describe("findExtremes", () => {
it("finds high and low tides", { timeout: 5000 }, () => {
const start = new Date("2024-01-01T00:00:00Z");
const end = new Date("2024-01-02T00:00:00Z");
const extremes = findExtremes(
[{ name: "M2", amplitude: 1, phase: 0, speed: 28.984104 }],
start,
end,
0
);
expect(extremes.length).toBeGreaterThan(0);
});
it("rejects empty constituent list with NO_CONSTITUENTS", {
timeout: 5000,
}, () => {
expect(() =>
predictHeight([], new Date("2024-01-01T00:00:00Z"))
).toThrow(TideError);
});
it("rejects inverted range with INVALID_RANGE", { timeout: 5000 }, () => {
const start = new Date("2024-01-02T00:00:00Z");
const end = new Date("2024-01-01T00:00:00Z");
expect(() =>
findExtremes(
[{ name: "M2", amplitude: 1, phase: 0, speed: 28.984104 }],
start,
end
)
).toThrow(TideError);
});
});
describe("nodeFactor", () => {
it.each([
["K1"],
["O1"],
["Q1"],
["2Q1"],
["RHO1"],
["SIGMA1"],
["K2"],
["S2"],
["T2"],
["R2"],
["P1"],
["UNKNOWN"],
])("returns a finite factor for %s", (name) => {
const f = nodeFactor(name, new Date("2024-06-15T00:00:00Z"));
expect(Number.isFinite(f)).toBe(true);
expect(f).toBeGreaterThan(0);
});
});
describe("astronomicalArgument", () => {
it("returns 0 for an unknown constituent", { timeout: 5000 }, () => {
expect(
astronomicalArgument(
"DOES_NOT_EXIST",
new Date("2024-06-15T00:00:00Z")
)
).toBe(0);
});
});

View File

@@ -0,0 +1,147 @@
import type { HarmonicConstituent, TideExtreme } from "../types/types.js";
export type { HarmonicConstituent, TideExtreme } from "../types/types.js";
import { TideError } from "../types/types.js";
import {
astronomicalArgument,
nodeFactor,
toRad,
} from "./harmonic-constituents.js";
export {
CONSTITUENT_SPEEDS,
DOODSON_NUMBERS,
nodeFactor,
} from "./harmonic-constituents.js";
/**
* Predict tide height at a given time using harmonic constituents.
*
* Formula: h(t) = H₀ + Σ [ f(t) · Hₙ · cos( ωₙ·t + V₀ₙ(t) - Gₙ ) ]
*/
export function predictHeight(
constituents: readonly HarmonicConstituent[],
time: Date,
meanSeaLevel = 0
): number {
if (constituents.length === 0) {
throw new TideError(
"No harmonic constituents provided",
"NO_CONSTITUENTS"
);
}
const j2000Ms = Date.UTC(2000, 0, 1, 12, 0, 0);
const hoursSinceEpoch = (time.getTime() - j2000Ms) / 3600000;
let height = meanSeaLevel;
for (const c of constituents) {
const f = nodeFactor(c.name, time);
const V0 = astronomicalArgument(c.name, time);
const angle = toRad(c.speed * hoursSinceEpoch + V0 - c.phase);
height += f * c.amplitude * Math.cos(angle);
}
return Math.round(height * 1000) / 1000;
}
/** Refine an extreme by bisection to ~1 minute accuracy. */
function refineExtreme(
constituents: readonly HarmonicConstituent[],
start: Date,
end: Date,
meanSeaLevel: number,
type: "high" | "low"
): TideExtreme {
let lo = start.getTime();
let hi = end.getTime();
const iterations = 10;
for (let i = 0; i < iterations; i++) {
const mid = (lo + hi) / 2;
const t1 = mid - 30000;
const t2 = mid + 30000;
const h1 = predictHeight(constituents, new Date(t1), meanSeaLevel);
const h2 = predictHeight(constituents, new Date(t2), meanSeaLevel);
if (type === "high") {
if (h1 > h2) {
hi = mid;
} else {
lo = mid;
}
} else {
if (h1 < h2) {
hi = mid;
} else {
lo = mid;
}
}
}
const extremeTime = new Date((lo + hi) / 2);
const height = predictHeight(constituents, extremeTime, meanSeaLevel);
return { type, height, time: extremeTime };
}
/**
* Find high and low water times within a date range using the prediction engine.
*/
export function findExtremes(
constituents: readonly HarmonicConstituent[],
start: Date,
end: Date,
meanSeaLevel = 0,
scanMinutes = 6
): TideExtreme[] {
if (start >= end) {
throw new TideError("Start must be before end", "INVALID_RANGE");
}
const scanMs = scanMinutes * 60000;
const extremes: TideExtreme[] = [];
let prevTime = start;
let prevHeight = predictHeight(constituents, start, meanSeaLevel);
let prevSlope: number | null = null;
for (let t = start.getTime() + scanMs; t <= end.getTime(); t += scanMs) {
const currentTime = new Date(t);
const currentHeight = predictHeight(
constituents,
currentTime,
meanSeaLevel
);
const slope = currentHeight - prevHeight;
if (prevSlope !== null) {
if (prevSlope > 0 && slope <= 0) {
extremes.push(
refineExtreme(
constituents,
prevTime,
currentTime,
meanSeaLevel,
"high"
)
);
} else if (prevSlope < 0 && slope >= 0) {
extremes.push(
refineExtreme(
constituents,
prevTime,
currentTime,
meanSeaLevel,
"low"
)
);
}
}
prevSlope = slope;
prevTime = currentTime;
prevHeight = currentHeight;
}
return extremes;
}

View File

@@ -0,0 +1,20 @@
export {
CONSTITUENT_SPEEDS,
DOODSON_NUMBERS,
findExtremes,
type HarmonicConstituent,
nodeFactor,
predictHeight,
type TideExtreme,
} from "./harmonic.js";
export {
astronomicalArgument,
julianDate,
meanLongitudeLunarNode,
meanLongitudeLunarPerigee,
meanLongitudeMoon,
meanLongitudeSolarPerigee,
meanLongitudeSun,
normalize,
toRad,
} from "./harmonic-constituents.js";

View File

@@ -0,0 +1,10 @@
export {
findExtremes,
type HarmonicConstituent,
predictHeight,
} from "./harmonic/index.js";
export {
createAdmiraltyProvider,
createPhysicsBasedProvider,
createTiconProvider,
} from "./provider/index.js";

View File

@@ -0,0 +1,4 @@
export {
equilibriumTideHeight,
findEquilibriumExtremes,
} from "./physics.js";

View File

@@ -0,0 +1,57 @@
# physics
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
A simplified equilibrium-tide model that estimates ocean-surface height from latitude, longitude, and UTC time without requiring station data, harmonic constituents, or network access. It superposes a semi-diurnal lunar (M2) and solar (S2) cosine with a latitude- and declination-dependent amplitude factor, giving a useful first-order estimate for any coordinate on Earth. The module is the engine behind `createPhysicsBasedProvider` and is the recommended fallback when no gauge data exists.
## Features
- `equilibriumTideHeight(latitude, longitude, time)` returning a metre-scale height value.
- Latitude-modulated amplitude via `cos(lat) · cos(declination)` so polar points correctly attenuate.
- `findEquilibriumExtremes(latitude, longitude, date, scanMinutes?)` scanning a 24-hour window for local maxima and minima.
- Three-sample local-max/local-min detector with millimetre rounding.
- Pure functions — no closures, no I/O, no allocation beyond the result array.
## Usage: Basic Example
```ts
import { createPhysicsBasedProvider } from "@suaveplan/tides";
const provider = createPhysicsBasedProvider();
const height = await provider.predictHeight(
{ name: "Earth", radius: 6_371_000, rotationPeriod: 86_164 },
{ latitude: 51.5074, longitude: -0.1278 },
new Date("2024-06-15T12:00:00Z")
);
```
## Usage: Advanced Example
```ts
import { createPhysicsBasedProvider } from "@suaveplan/tides";
const provider = createPhysicsBasedProvider();
const earth = { name: "Earth", radius: 6_371_000, rotationPeriod: 86_164 };
const coord = { latitude: 51.5074, longitude: -0.1278 };
const extremes = await provider.getExtremes(
earth,
coord,
new Date("2024-06-15T00:00:00Z")
);
const highs = extremes.filter((e) => e.type === "high");
const lows = extremes.filter((e) => e.type === "low");
console.log(`${highs.length} highs, ${lows.length} lows`);
```
## API Reference
- `equilibriumTideHeight(latitude: number, _longitude: number, time: Date): number` — predicted height in metres. Longitude is unused but preserved in the signature for parity with station-based providers.
- `findEquilibriumExtremes(latitude: number, longitude: number, date: Date, scanMinutes?: number): TideExtreme[]` — high/low water events found within the UTC day containing `date`. Default `scanMinutes` is 15.
## Implementation Notes
The model uses J2000.0 as the reference epoch and combines two cosines (lunar at 28.984104°/h, solar at 30°/h) with a slowly varying solar declination term. It deliberately omits node factors, equilibrium arguments, and shallow-water constituents — for accurate predictions, prefer `createTiconProvider` with a real harmonic catalogue or `createAdmiraltyProvider` for UK waters. The `findEquilibriumExtremes` scanner uses a coarse three-point local-extremum test rather than bisection; when adjacent samples are equal the test rejects the candidate, so very flat sections of the curve will not produce spurious extremes. Output is rounded to millimetre precision.

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { equilibriumTideHeight, findEquilibriumExtremes } from "./physics.js";
describe("equilibriumTideHeight", () => {
it("returns a value for any time", { timeout: 5000 }, () => {
const height = equilibriumTideHeight(
51.5,
-0.1,
new Date("2024-06-15T12:00:00Z")
);
expect(typeof height).toBe("number");
expect(Number.isFinite(height)).toBe(true);
});
it("varies with latitude", { timeout: 5000 }, () => {
const hEquator = equilibriumTideHeight(
0,
0,
new Date("2024-06-15T12:00:00Z")
);
const hPole = equilibriumTideHeight(
90,
0,
new Date("2024-06-15T12:00:00Z")
);
expect(Math.abs(hPole)).toBeLessThan(Math.abs(hEquator));
});
it("varies with time", { timeout: 5000 }, () => {
const h1 = equilibriumTideHeight(
51.5,
-0.1,
new Date("2024-06-15T00:00:00Z")
);
const h2 = equilibriumTideHeight(
51.5,
-0.1,
new Date("2024-06-15T06:00:00Z")
);
expect(h1).not.toBe(h2);
});
});
describe("findEquilibriumExtremes", () => {
it("finds high and low waters in a day", { timeout: 5000 }, () => {
const extremes = findEquilibriumExtremes(
51.5,
-0.1,
new Date("2024-06-15T00:00:00Z")
);
expect(extremes.length).toBeGreaterThan(0);
const highs = extremes.filter((e) => e.type === "high");
const lows = extremes.filter((e) => e.type === "low");
expect(highs.length).toBeGreaterThan(0);
expect(lows.length).toBeGreaterThan(0);
});
it("alternates high and low", { timeout: 5000 }, () => {
const extremes = findEquilibriumExtremes(
51.5,
-0.1,
new Date("2024-06-15T00:00:00Z")
);
for (let i = 1; i < extremes.length; i++) {
expect(extremes[i]!.type).not.toBe(extremes[i - 1]!.type);
}
});
it("highs are higher than adjacent lows", { timeout: 5000 }, () => {
const extremes = findEquilibriumExtremes(
51.5,
-0.1,
new Date("2024-06-15T00:00:00Z")
);
for (let i = 0; i < extremes.length - 1; i++) {
if (
extremes[i]!.type === "high" &&
extremes[i + 1]!.type === "low"
) {
expect(extremes[i]!.height).toBeGreaterThan(
extremes[i + 1]!.height
);
}
}
});
});

View File

@@ -0,0 +1,92 @@
import type { TideExtreme } from "@suaveplan/types";
/**
* Calculate equilibrium tide height using a simplified model.
*
* Combines semi-diurnal lunar (M2) and solar (S2) constituents
* with latitude-dependent amplitude modulation.
*
* @param latitude - Observer latitude in degrees
* @param longitude - Observer longitude in degrees (unused but preserved for API consistency)
* @param time - UTC time for the prediction
* @returns Predicted tide height in metres
*/
export function equilibriumTideHeight(
latitude: number,
_longitude: number,
time: Date
): number {
const hoursSinceEpoch =
(time.getTime() - Date.UTC(2000, 0, 1, 12, 0, 0)) / 3600000;
const lunar =
0.53 *
Math.cos((((hoursSinceEpoch * 28.984104) % 360) * Math.PI) / 180);
const solar =
0.25 * Math.cos((((hoursSinceEpoch * 30.0) % 360) * Math.PI) / 180);
const declination =
((23.45 * Math.PI) / 180) *
Math.sin((hoursSinceEpoch * 0.549 * Math.PI) / 180);
const latRad = (latitude * Math.PI) / 180;
const latFactor = Math.cos(latRad) * Math.cos(declination);
return Math.round((lunar + solar) * latFactor * 1000) / 1000;
}
/**
* Find high/low water extremes using the equilibrium tide model.
*
* Scans at regular intervals and detects local maxima/minima.
*
* @param latitude - Observer latitude in degrees
* @param longitude - Observer longitude in degrees
* @param date - Calendar date (UTC) to scan
* @param scanMinutes - Interval between samples (default: 15)
* @returns Array of tide extremes for the day
*/
export function findEquilibriumExtremes(
latitude: number,
longitude: number,
date: Date,
scanMinutes = 15
): TideExtreme[] {
const start = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
);
const end = new Date(start.getTime() + 86400000);
const scanMs = scanMinutes * 60000;
type Sample = { time: Date; height: number };
const samples: Sample[] = [];
for (let t = start.getTime(); t <= end.getTime(); t += scanMs) {
const time = new Date(t);
samples.push({
time,
height: equilibriumTideHeight(latitude, longitude, time),
});
}
const extremes: TideExtreme[] = [];
for (let i = 1; i < samples.length - 1; i++) {
const prev = samples[i - 1]!.height;
const curr = samples[i]!.height;
const next = samples[i + 1]!.height;
if (curr > prev && curr > next) {
extremes.push({
type: "high",
height: Math.round(curr * 1000) / 1000,
time: samples[i]!.time,
});
} else if (curr < prev && curr < next) {
extremes.push({
type: "low",
height: Math.round(curr * 1000) / 1000,
time: samples[i]!.time,
});
}
}
return extremes;
}

View File

@@ -0,0 +1,5 @@
export {
createAdmiraltyProvider,
createPhysicsBasedProvider,
createTiconProvider,
} from "./provider.js";

View File

@@ -0,0 +1,66 @@
# provider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Adapts the three tide-prediction backends — TICON-style harmonic constituents, the ADMIRALTY HTTP API, and the equilibrium-tide physics model — to the universal `TidalProvider` shape from `@suaveplan/types`. Every factory returns a pair of methods (`predictHeight`, `getExtremes`) that accept a `PlanetConfig`, a `SphericalCoord`, and a `Date`, so callers can swap backends without changing call sites or domain logic. This is the recommended entry point for any consumer that doesn't need the lower-level station-rooted `AdmiraltyProvider` API.
## Features
- `createTiconProvider(constituents, meanSeaLevel?)` — pure, offline, deterministic. Wraps `predictHeight`/`findExtremes` from `@suaveplan/tides/harmonic`.
- `createAdmiraltyProvider(options)` — async, network-bound. Routes via `getNearestStation` so callers can pass any UK coordinate.
- `createPhysicsBasedProvider()` — zero-config, no network, no constituents. Equilibrium-tide model from `@suaveplan/tides` `physics`.
- All three return objects matching `TidalProvider` from `@suaveplan/types`, so they compose with anything in the universe stack expecting that contract.
- AbortSignal is forwarded for the ADMIRALTY adapter; ignored for the deterministic backends.
## Usage: Basic Example
```ts
import { createTiconProvider } from "@suaveplan/tides";
const provider = createTiconProvider([
{ name: "M2", amplitude: 1.0, phase: 0, speed: 28.984104 },
]);
const earth = { name: "Earth", radius: 6_371_000, rotationPeriod: 86_164 };
const height = await provider.predictHeight(
earth,
{ latitude: 51.5074, longitude: -0.1278 },
new Date("2024-06-15T12:00:00Z")
);
```
## Usage: Advanced Example
```ts
import {
createAdmiraltyProvider,
createPhysicsBasedProvider,
createTiconProvider,
} from "@suaveplan/tides";
import type { TidalProvider } from "@suaveplan/types";
function pickProvider(input: {
apiKey?: string;
constituents?: Parameters<typeof createTiconProvider>[0];
}): TidalProvider {
if (input.constituents?.length)
return createTiconProvider(input.constituents);
if (input.apiKey) return createAdmiraltyProvider({ apiKey: input.apiKey });
return createPhysicsBasedProvider();
}
```
## API Reference
- `createTiconProvider(constituents: readonly HarmonicConstituent[], meanSeaLevel?: number): TidalProvider`.
- `createAdmiraltyProvider(options: AdmiraltyProviderOptions): TidalProvider` — internally wraps a station-rooted `AdmiraltyProvider`.
- `createPhysicsBasedProvider(): TidalProvider`.
- Each `TidalProvider` exposes:
- `predictHeight(planet, coord, time, signal?): Promise<number>`.
- `getExtremes(planet, coord, date, signal?): Promise<TideExtreme[]>`.
## Implementation Notes
The `_planet` parameter is currently unused by every backend — it is preserved on the contract so future backends (e.g. Mars or exoplanet equilibrium tide models) can vary behaviour without breaking call sites. The ADMIRALTY adapter performs an extra `getNearestStation` round-trip per call; for batch workloads, consider hoisting that to once-per-coord and operating on the station-rooted `AdmiraltyProvider` API directly to amortise it. The TICON and physics adapters are synchronous internally but return Promises to satisfy the universal contract — they never reject and never await, so `await` is essentially zero-cost. `getExtremes` for the TICON adapter scans the full UTC day containing `date` (`startOfDay → +24h`) regardless of the time-of-day component on the input.

View File

@@ -0,0 +1,199 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import {
createAdmiraltyProvider,
createPhysicsBasedProvider,
createTiconProvider,
} from "./provider.js";
const EARTH: import("@suaveplan/geo-planet").PlanetConfig = {
name: "Earth",
radius: 6_371_000,
rotationPeriod: 86_164,
};
const LONDON: import("@suaveplan/geo").SphericalCoord = {
latitude: 51.5074,
longitude: -0.1278,
};
describe("createTiconProvider", () => {
it("predicts height using harmonic constituents", {
timeout: 30000,
}, async () => {
const provider = createTiconProvider([
{ name: "M2", amplitude: 1, phase: 0, speed: 28.984104 },
]);
const height = await provider.predictHeight(
EARTH,
LONDON,
new Date("2024-06-15T12:00:00Z")
);
expect(typeof height).toBe("number");
expect(Number.isFinite(height)).toBe(true);
});
it("returns extremes for a date", { timeout: 30000 }, async () => {
const provider = createTiconProvider([
{ name: "M2", amplitude: 1, phase: 0, speed: 28.984104 },
]);
const extremes = await provider.getExtremes(
EARTH,
LONDON,
new Date("2024-06-15T00:00:00Z")
);
expect(extremes.length).toBeGreaterThan(0);
expect(extremes[0]!).toHaveProperty("type");
expect(extremes[0]!).toHaveProperty("height");
expect(extremes[0]!).toHaveProperty("time");
});
});
describe("createAdmiraltyProvider", () => {
it("wraps the AdmiraltyProvider with universe interface", {
timeout: 30000,
}, async () => {
const mockFetch = vi.fn().mockImplementation((url: string) => {
if (url.includes("/TidalEvents")) {
return Promise.resolve({
ok: true,
status: 200,
statusText: "OK",
json: () =>
Promise.resolve([
{
DateTime: "2024-06-15T06:00:00Z",
Height: 2.0,
IsHighWater: true,
EventType: "HighWater",
},
{
DateTime: "2024-06-15T12:00:00Z",
Height: 1.0,
IsHighWater: false,
EventType: "LowWater",
},
]),
} as Response);
}
return Promise.resolve({
ok: true,
status: 200,
statusText: "OK",
json: () =>
Promise.resolve({
features: [
{
properties: {
Id: "001",
Name: "London",
Country: "UK",
Latitude: 51.5,
Longitude: -0.1,
},
},
],
}),
} as Response);
});
const provider = createAdmiraltyProvider({
apiKey: "test-key",
fetchFn: mockFetch,
});
const height = await provider.predictHeight(
EARTH,
LONDON,
new Date("2024-06-15T12:00:00Z")
);
expect(typeof height).toBe("number");
});
it("delegates getExtremes through AdmiraltyProvider", {
timeout: 30000,
}, async () => {
const mockFetch = vi.fn().mockImplementation((url: string) => {
if (url.includes("/TidalEvents")) {
return Promise.resolve({
ok: true,
status: 200,
statusText: "OK",
json: () =>
Promise.resolve([
{
DateTime: "2024-06-15T06:00:00Z",
Height: 2.0,
IsHighWater: true,
EventType: "HighWater",
},
{
DateTime: "2024-06-15T12:00:00Z",
Height: 1.0,
IsHighWater: false,
EventType: "LowWater",
},
]),
} as Response);
}
return Promise.resolve({
ok: true,
status: 200,
statusText: "OK",
json: () =>
Promise.resolve({
features: [
{
properties: {
Id: "001",
Name: "London",
Country: "UK",
Latitude: 51.5,
Longitude: -0.1,
},
},
],
}),
} as Response);
});
const provider = createAdmiraltyProvider({
apiKey: "test-key",
fetchFn: mockFetch,
});
const extremes = await provider.getExtremes(
EARTH,
LONDON,
new Date("2024-06-15T00:00:00Z")
);
expect(extremes.length).toBeGreaterThan(0);
expect(extremes[0]!).toHaveProperty("type");
});
});
describe("createPhysicsBasedProvider", () => {
it("predicts height using equilibrium model", {
timeout: 30000,
}, async () => {
const provider = createPhysicsBasedProvider();
const height = await provider.predictHeight(
EARTH,
LONDON,
new Date("2024-06-15T12:00:00Z")
);
expect(typeof height).toBe("number");
expect(Number.isFinite(height)).toBe(true);
});
it("returns extremes for a date", { timeout: 30000 }, async () => {
const provider = createPhysicsBasedProvider();
const extremes = await provider.getExtremes(
EARTH,
LONDON,
new Date("2024-06-15T00:00:00Z")
);
expect(extremes.length).toBeGreaterThan(0);
const types = extremes.map((e) => e.type);
expect(types).toContain("high");
expect(types).toContain("low");
});
});

View File

@@ -0,0 +1,111 @@
import type { SphericalCoord } from "@suaveplan/geo";
import type { PlanetConfig } from "@suaveplan/geo-planet";
import type { TidalProvider, TideExtreme } from "@suaveplan/types";
import type { AdmiraltyProviderOptions } from "../admiralty/admiralty.js";
import { AdmiraltyProvider } from "../admiralty/admiralty.js";
import { findExtremes, predictHeight } from "../harmonic/harmonic.js";
import {
equilibriumTideHeight,
findEquilibriumExtremes,
} from "../physics/physics.js";
import type { HarmonicConstituent } from "../types/types.js";
/**
* Create a harmonic (TICON-style) tidal provider from constituent data.
*
* Uses the classic harmonic method: h(t) = H₀ + Σ f(t)·Hₙ·cos(ωₙ·t + V₀ₙ - Gₙ).
*/
export function createTiconProvider(
constituents: readonly HarmonicConstituent[],
meanSeaLevel = 0
): TidalProvider {
return {
predictHeight(
_planet: PlanetConfig,
_coord: SphericalCoord,
time: Date
): Promise<number> {
return Promise.resolve(
predictHeight(constituents, time, meanSeaLevel)
);
},
getExtremes(
_planet: PlanetConfig,
_coord: SphericalCoord,
date: Date,
_signal?: AbortSignal
): Promise<TideExtreme[]> {
const start = new Date(
Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate()
)
);
const end = new Date(start.getTime() + 86400000);
return Promise.resolve(
findExtremes(constituents, start, end, meanSeaLevel)
);
},
};
}
/**
* Create an ADMIRALTY Tidal API provider.
*
* Wraps the UK Hydrographic Office's public API. Requires a free API key.
*/
export function createAdmiraltyProvider(
options: AdmiraltyProviderOptions
): TidalProvider {
const client = new AdmiraltyProvider(options);
return {
async predictHeight(
_planet: PlanetConfig,
coord: SphericalCoord,
time: Date,
signal?: AbortSignal
): Promise<number> {
const { station } = await client.getNearestStation(coord, signal);
return client.predictHeight(station, time, signal);
},
async getExtremes(
_planet: PlanetConfig,
coord: SphericalCoord,
date: Date,
signal?: AbortSignal
): Promise<TideExtreme[]> {
const { station } = await client.getNearestStation(coord, signal);
return [...(await client.getExtremes(station, date, signal))];
},
};
}
/**
* Create a physics-based (equilibrium tide) provider.
*
* Uses a simplified astronomical model — no station data required.
* Useful for locations without gauge data or as a fallback.
*/
export function createPhysicsBasedProvider(): TidalProvider {
return {
predictHeight(
_planet: PlanetConfig,
coord: SphericalCoord,
time: Date
): Promise<number> {
return Promise.resolve(
equilibriumTideHeight(coord.latitude, coord.longitude, time)
);
},
getExtremes(
_planet: PlanetConfig,
coord: SphericalCoord,
date: Date
): Promise<TideExtreme[]> {
return Promise.resolve(
findEquilibriumExtremes(coord.latitude, coord.longitude, date)
);
},
};
}

View File

@@ -0,0 +1,4 @@
export {
findNearestStation,
findStationsInRadius,
} from "./stations.js";

View File

@@ -0,0 +1,50 @@
# stations
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Geospatial helpers that find tide-gauge stations near a query point on the WGS84-equivalent reference ellipsoid. Wraps `@suaveplan/geo`'s `greatCircleDistance` so that callers do not need to choose an ellipsoid or unit themselves. Used by every provider that ships its own catalogue (ADMIRALTY, custom TICON datasets) and by user-facing UI surfaces that need a "nearest station" picker without instantiating a full provider.
## Features
- `findNearestStation(lat, lon, stations)` returning the single closest entry plus the great-circle distance in kilometres.
- `findStationsInRadius(lat, lon, radiusKm, stations)` returning every station within a radius, pre-sorted ascending by distance.
- Distances rounded to two decimal places (10 m precision) for stable display.
- Strict `TideError` codes for pathological inputs: `NO_STATIONS` and `INVALID_RADIUS`.
- Pure functions — no allocation beyond the result array, no shared state.
## Usage: Basic Example
```ts
import { findNearestStation } from "@suaveplan/tides";
const result = findNearestStation(51.5074, -0.1278, [
{ id: "1", name: "London", latitude: 51.5074, longitude: -0.1278, provider: "ticon" },
{ id: "2", name: "Dover", latitude: 51.1278, longitude: 1.3134, provider: "ticon" },
]);
console.log(result.station.name, result.distanceKm);
```
## Usage: Advanced Example
```ts
import { findStationsInRadius, type TideStation } from "@suaveplan/tides";
const stations: TideStation[] = await loadCatalogue();
const within50km = findStationsInRadius(51.5074, -0.1278, 50, stations);
for (const { station, distanceKm } of within50km) {
console.log(`${station.name}: ${distanceKm} km`);
}
```
## API Reference
- `findNearestStation(latitude: number, longitude: number, stations: readonly TideStation[]): NearestStationResult` — throws `TideError("NO_STATIONS")` when `stations` is empty.
- `findStationsInRadius(latitude: number, longitude: number, radiusKm: number, stations: readonly TideStation[]): NearestStationResult[]` — throws `TideError("INVALID_RADIUS")` when `radiusKm <= 0`. Result is sorted ascending by distance.
- `NearestStationResult` — `{ station: TideStation; distanceKm: number }`.
## Implementation Notes
Internally uses a single ellipsoid built via `defineEllipsoid(6_371_000, 6_356_000)`, matching the values used elsewhere in the geo plugin family. This is a slight approximation versus the strict WGS84 spheroid (6_378_137 / 6_356_752) but is consistent with the rest of the package family and avoids importing the full WGS84 constants table. The radius search performs a linear scan rather than a spatial index — for catalogues of more than a few thousand stations, callers should consider pre-bucketing by tile or using an external R-tree library; tide-gauge networks rarely exceed that size in practice. Distances are reported in kilometres (`"kilometre"` unit on `greatCircleDistance`).

View File

@@ -0,0 +1,122 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TideError } from "../types/types.js";
import { findNearestStation, findStationsInRadius } from "./stations.js";
describe("findNearestStation", () => {
it("finds the nearest station from a list", { timeout: 5000 }, () => {
const stations = [
{
id: "1",
name: "London",
latitude: 51.5074,
longitude: -0.1278,
provider: "test",
},
{
id: "2",
name: "Dover",
latitude: 51.1278,
longitude: 1.3134,
provider: "test",
},
{
id: "3",
name: "Bristol",
latitude: 51.4545,
longitude: -2.5879,
provider: "test",
},
];
const result = findNearestStation(51.5, 0, stations);
expect(result.station.id).toBe("1");
expect(result.distanceKm).toBeGreaterThan(0);
});
it("throws TideError when no stations provided", { timeout: 5000 }, () => {
expect(() => findNearestStation(0, 0, [])).toThrow(TideError);
});
it("handles single station", { timeout: 5000 }, () => {
const stations = [
{
id: "1",
name: "Only",
latitude: 0,
longitude: 0,
provider: "test",
},
];
const result = findNearestStation(10, 10, stations);
expect(result.station.id).toBe("1");
expect(result.distanceKm).toBeGreaterThan(0);
});
});
describe("findStationsInRadius", () => {
it("finds stations within radius", { timeout: 5000 }, () => {
const stations = [
{
id: "1",
name: "Close",
latitude: 51.5,
longitude: -0.1,
provider: "test",
},
{
id: "2",
name: "Far",
latitude: 55.0,
longitude: -1.6,
provider: "test",
},
];
const results = findStationsInRadius(51.5, 0, 100, stations);
expect(results).toHaveLength(1);
expect(results[0]!.station.id).toBe("1");
});
it("returns empty array when no stations in radius", {
timeout: 5000,
}, () => {
const stations = [
{
id: "1",
name: "Far",
latitude: 55,
longitude: -1,
provider: "test",
},
];
const results = findStationsInRadius(51, 0, 10, stations);
expect(results).toHaveLength(0);
});
it("sorts results by distance", { timeout: 5000 }, () => {
const stations = [
{
id: "1",
name: "A",
latitude: 51.51,
longitude: -0.11,
provider: "test",
},
{
id: "2",
name: "B",
latitude: 51.55,
longitude: -0.2,
provider: "test",
},
];
const results = findStationsInRadius(51.5, -0.1, 100, stations);
expect(results).toHaveLength(2);
expect(results[0]!.distanceKm).toBeLessThanOrEqual(
results[1]!.distanceKm
);
});
it("throws for non-positive radius", { timeout: 5000 }, () => {
expect(() => findStationsInRadius(0, 0, 0, [])).toThrow(TideError);
expect(() => findStationsInRadius(0, 0, -1, [])).toThrow(TideError);
});
});

View File

@@ -0,0 +1,74 @@
import { defineEllipsoid, greatCircleDistance } from "@suaveplan/geo";
import type { NearestStationResult, TideStation } from "../types/types.js";
import { TideError } from "../types/types.js";
const ELLIPSOID = defineEllipsoid(6_371_000, 6_356_000);
/**
* Find the nearest tide station to a given coordinate.
*/
export function findNearestStation(
latitude: number,
longitude: number,
stations: readonly TideStation[]
): NearestStationResult {
if (stations.length === 0) {
throw new TideError("No stations available", "NO_STATIONS");
}
let bestStation = stations[0] as TideStation;
let bestDistance = Number.POSITIVE_INFINITY;
for (const station of stations) {
const dist = greatCircleDistance(
{ latitude, longitude },
{ latitude: station.latitude, longitude: station.longitude },
ELLIPSOID,
"kilometre"
);
if (dist < bestDistance) {
bestDistance = dist;
bestStation = station;
}
}
return {
station: bestStation,
distanceKm: Math.round(bestDistance * 100) / 100,
};
}
/**
* Find all stations within a given radius.
*/
export function findStationsInRadius(
latitude: number,
longitude: number,
radiusKm: number,
stations: readonly TideStation[]
): NearestStationResult[] {
if (radiusKm <= 0) {
throw new TideError("Radius must be positive", "INVALID_RADIUS");
}
const results: NearestStationResult[] = [];
for (const station of stations) {
const dist = greatCircleDistance(
{ latitude, longitude },
{ latitude: station.latitude, longitude: station.longitude },
ELLIPSOID,
"kilometre"
);
if (dist <= radiusKm) {
results.push({
station,
distanceKm: Math.round(dist * 100) / 100,
});
}
}
return results.sort((a, b) => a.distanceKm - b.distanceKm);
}

View File

@@ -0,0 +1,11 @@
export type {
HarmonicConstituent,
NearestStationResult,
TidalProviderLike,
TideExtreme,
TidePrediction,
TideStation,
TidesClientOptions,
TideTable,
} from "./types.js";
export { TideError } from "./types.js";

View File

@@ -0,0 +1,68 @@
# types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The canonical TypeScript surface shared by every other module in `@suaveplan/tides` — harmonic constituent records, station metadata, prediction and extreme records, table containers, provider contracts, and the package-local `TideError` class. Re-exports `TideExtreme` from `@suaveplan/types` so downstream callers never need a second import path. This module owns no runtime logic beyond the `TideError` class.
## Features
- `HarmonicConstituent` interface — name, amplitude (m), phase lag (deg), angular speed (deg/hr).
- `TideStation` interface — id, name, lat/lon, provider, optional offline `constituents` and `meanSeaLevel`.
- `TidePrediction` and `TideTable` containers — height + time + originating station, plus aggregated extremes.
- `NearestStationResult` — station + distance in km.
- `TidalProviderLike` — minimal structural contract mirroring `@suaveplan/universe`'s `TidalProvider` so client code can mock or wrap providers without heavy dependencies.
- `TidesClientOptions` — ordered provider list for first-match-wins routing.
- `TideError extends Error` — typed code-bearing error class used by every other module.
## Usage: Basic Example
```ts
import {
type HarmonicConstituent,
type TideStation,
TideError,
} from "@suaveplan/tides";
const m2: HarmonicConstituent = {
name: "M2",
amplitude: 1.2,
phase: 0,
speed: 28.984104,
};
```
## Usage: Advanced Example
```ts
import {
type TidalProviderLike,
type TidesClientOptions,
TideError,
} from "@suaveplan/tides";
function createClient(options: TidesClientOptions): TidalProviderLike {
if (options.providers.length === 0) {
throw new TideError("No providers configured", "NO_PROVIDERS");
}
const first = options.providers[0]!;
return first;
}
```
## API Reference
- `HarmonicConstituent` — `{ name, amplitude, phase, speed }` all readonly.
- `TideStation` — `{ id, name, latitude, longitude, provider, constituents?, meanSeaLevel? }` all readonly.
- `TidePrediction` — `{ height, time, station }` all readonly.
- `TideTable` — `{ station, predictions, extremes }` all readonly.
- `NearestStationResult` — `{ station, distanceKm }`.
- `TidalProviderLike` — async methods `getStations`, `getNearestStation`, `predictHeight`, `getExtremes`, `getTideTable`.
- `TidesClientOptions` — `{ providers: readonly TidalProviderLike[] }`.
- `TideError` — `new TideError(message, code)`; `error.code` exposes the stable code string.
## Implementation Notes
`TideExtreme` lives in `@suaveplan/types` to keep the shape stable across the `geo`/`universe`/`tides` triangle without a circular dependency. `TidalProviderLike` deliberately diverges from `@suaveplan/universe`'s `TidalProvider` (which takes a `PlanetConfig` first argument) — this local shape is the _station-rooted_ contract used by `AdmiraltyProvider` and the `TidesClientOptions` flow, while `provider.ts` adapts station providers to the `PlanetConfig`-shaped universal contract. `TideError` is intentionally a hand-rolled class rather than a `SuaveplanError` subclass so the package can stay zero-runtime-deps for downstream tree-shaking; promoting it to the framework error registry is a future migration if the surface area grows.

View File

@@ -0,0 +1,101 @@
import type { TideExtreme } from "@suaveplan/types";
export type { TideExtreme } from "@suaveplan/types";
/**
* A single tidal harmonic constituent with its parameters.
*/
export interface HarmonicConstituent {
/** Standard name (e.g. 'M2', 'S2', 'K1') */
readonly name: string;
/** Amplitude in meters */
readonly amplitude: number;
/** Phase lag in degrees (Greenwich epoch) */
readonly phase: number;
/** Angular speed in degrees per hour */
readonly speed: number;
}
/** Metadata about a tide gauge station. */
export interface TideStation {
/** Unique station identifier */
readonly id: string;
/** Human-readable station name */
readonly name: string;
/** Station latitude */
readonly latitude: number;
/** Station longitude */
readonly longitude: number;
/** Provider that supplies this station's data */
readonly provider: string;
/** Harmonic constituents (available for offline providers) */
readonly constituents?: readonly HarmonicConstituent[];
/** Mean sea level offset in meters (datum offset H₀) */
readonly meanSeaLevel?: number;
}
/** A single tide height prediction at a specific time. */
export interface TidePrediction {
/** Predicted water height in meters above chart datum */
readonly height: number;
/** UTC timestamp of the prediction */
readonly time: Date;
/** Station used for the prediction */
readonly station: TideStation;
}
/** A table of predictions over a time range. */
export interface TideTable {
/** Station used */
readonly station: TideStation;
/** Ordered predictions at regular intervals */
readonly predictions: readonly TidePrediction[];
/** High and low water events within the range */
readonly extremes: readonly TideExtreme[];
}
/** Result of a nearest-station search. */
export interface NearestStationResult {
/** The station found */
readonly station: TideStation;
/** Distance from the query point in kilometers */
readonly distanceKm: number;
}
/** Options for creating a tides client. */
export interface TidesClientOptions {
/** Ordered list of providers to try (first match wins). */
readonly providers: readonly TidalProviderLike[];
}
/** Minimal tidal provider shape for the client (mirrors universe TidalProvider). */
export interface TidalProviderLike {
readonly name: string;
getStations(): Promise<readonly TideStation[]>;
getNearestStation(
latitude: number,
longitude: number
): Promise<NearestStationResult>;
predictHeight(station: TideStation, time: Date): Promise<number>;
getExtremes(
station: TideStation,
date: Date
): Promise<readonly TideExtreme[]>;
getTideTable(
station: TideStation,
start: Date,
end: Date,
intervalMinutes?: number
): Promise<TideTable>;
}
/** Custom error class for tidal operations. */
export class TideError extends Error {
public readonly code: string;
constructor(message: string, code: string) {
super(message);
this.name = "TideError";
this.code = code;
}
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,25 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
build: {
lib: {
entry: {
index: "./src/index.ts",
"harmonic/harmonic": "./src/harmonic/harmonic.ts",
"provider/provider": "./src/provider/provider.ts",
},
formats: ["es"],
},
outDir: "dist",
sourcemap: true,
rollupOptions: {
external: [/^@suaveplan\//, "zod", /^@opentelemetry\//],
output: {
preserveModules: true,
preserveModulesRoot: "src",
},
},
},
plugins: [dts({ tsconfigPath: "./tsconfig.lib.json" })],
});

View File

@@ -0,0 +1,10 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({
coverageThresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
});