Initial Commit
This commit is contained in:
33
packages/geospatial/geo/tides/README.md
Normal file
33
packages/geospatial/geo/tides/README.md
Normal 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
|
||||
```
|
||||
|
||||
47
packages/geospatial/geo/tides/package.json
Normal file
47
packages/geospatial/geo/tides/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
73
packages/geospatial/geo/tides/src/admiralty/admiralty.md
Normal file
73
packages/geospatial/geo/tides/src/admiralty/admiralty.md
Normal 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`.
|
||||
366
packages/geospatial/geo/tides/src/admiralty/admiralty.test.ts
Normal file
366
packages/geospatial/geo/tides/src/admiralty/admiralty.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
251
packages/geospatial/geo/tides/src/admiralty/admiralty.ts
Normal file
251
packages/geospatial/geo/tides/src/admiralty/admiralty.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
5
packages/geospatial/geo/tides/src/admiralty/index.ts
Normal file
5
packages/geospatial/geo/tides/src/admiralty/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type { AdmiraltyProviderOptions } from "./admiralty.js";
|
||||
export {
|
||||
AdmiraltyProvider,
|
||||
createAdmiraltyProvider,
|
||||
} from "./admiralty.js";
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
};
|
||||
69
packages/geospatial/geo/tides/src/harmonic/harmonic.md
Normal file
69
packages/geospatial/geo/tides/src/harmonic/harmonic.md
Normal 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).
|
||||
95
packages/geospatial/geo/tides/src/harmonic/harmonic.test.ts
Normal file
95
packages/geospatial/geo/tides/src/harmonic/harmonic.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
147
packages/geospatial/geo/tides/src/harmonic/harmonic.ts
Normal file
147
packages/geospatial/geo/tides/src/harmonic/harmonic.ts
Normal 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;
|
||||
}
|
||||
20
packages/geospatial/geo/tides/src/harmonic/index.ts
Normal file
20
packages/geospatial/geo/tides/src/harmonic/index.ts
Normal 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";
|
||||
10
packages/geospatial/geo/tides/src/index.ts
Normal file
10
packages/geospatial/geo/tides/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
findExtremes,
|
||||
type HarmonicConstituent,
|
||||
predictHeight,
|
||||
} from "./harmonic/index.js";
|
||||
export {
|
||||
createAdmiraltyProvider,
|
||||
createPhysicsBasedProvider,
|
||||
createTiconProvider,
|
||||
} from "./provider/index.js";
|
||||
4
packages/geospatial/geo/tides/src/physics/index.ts
Normal file
4
packages/geospatial/geo/tides/src/physics/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
equilibriumTideHeight,
|
||||
findEquilibriumExtremes,
|
||||
} from "./physics.js";
|
||||
57
packages/geospatial/geo/tides/src/physics/physics.md
Normal file
57
packages/geospatial/geo/tides/src/physics/physics.md
Normal 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.
|
||||
87
packages/geospatial/geo/tides/src/physics/physics.test.ts
Normal file
87
packages/geospatial/geo/tides/src/physics/physics.test.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
92
packages/geospatial/geo/tides/src/physics/physics.ts
Normal file
92
packages/geospatial/geo/tides/src/physics/physics.ts
Normal 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;
|
||||
}
|
||||
5
packages/geospatial/geo/tides/src/provider/index.ts
Normal file
5
packages/geospatial/geo/tides/src/provider/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createAdmiraltyProvider,
|
||||
createPhysicsBasedProvider,
|
||||
createTiconProvider,
|
||||
} from "./provider.js";
|
||||
66
packages/geospatial/geo/tides/src/provider/provider.md
Normal file
66
packages/geospatial/geo/tides/src/provider/provider.md
Normal 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.
|
||||
199
packages/geospatial/geo/tides/src/provider/provider.test.ts
Normal file
199
packages/geospatial/geo/tides/src/provider/provider.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
111
packages/geospatial/geo/tides/src/provider/provider.ts
Normal file
111
packages/geospatial/geo/tides/src/provider/provider.ts
Normal 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)
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
4
packages/geospatial/geo/tides/src/stations/index.ts
Normal file
4
packages/geospatial/geo/tides/src/stations/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
findNearestStation,
|
||||
findStationsInRadius,
|
||||
} from "./stations.js";
|
||||
50
packages/geospatial/geo/tides/src/stations/stations.md
Normal file
50
packages/geospatial/geo/tides/src/stations/stations.md
Normal 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`).
|
||||
122
packages/geospatial/geo/tides/src/stations/stations.test.ts
Normal file
122
packages/geospatial/geo/tides/src/stations/stations.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
74
packages/geospatial/geo/tides/src/stations/stations.ts
Normal file
74
packages/geospatial/geo/tides/src/stations/stations.ts
Normal 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);
|
||||
}
|
||||
11
packages/geospatial/geo/tides/src/types/index.ts
Normal file
11
packages/geospatial/geo/tides/src/types/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type {
|
||||
HarmonicConstituent,
|
||||
NearestStationResult,
|
||||
TidalProviderLike,
|
||||
TideExtreme,
|
||||
TidePrediction,
|
||||
TideStation,
|
||||
TidesClientOptions,
|
||||
TideTable,
|
||||
} from "./types.js";
|
||||
export { TideError } from "./types.js";
|
||||
68
packages/geospatial/geo/tides/src/types/types.md
Normal file
68
packages/geospatial/geo/tides/src/types/types.md
Normal 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.
|
||||
101
packages/geospatial/geo/tides/src/types/types.ts
Normal file
101
packages/geospatial/geo/tides/src/types/types.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
12
packages/geospatial/geo/tides/tsconfig.lib.json
Normal file
12
packages/geospatial/geo/tides/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
25
packages/geospatial/geo/tides/vite.config.ts
Normal file
25
packages/geospatial/geo/tides/vite.config.ts
Normal 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" })],
|
||||
});
|
||||
10
packages/geospatial/geo/tides/vitest.config.ts
Normal file
10
packages/geospatial/geo/tides/vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({
|
||||
coverageThresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user