feat: Gezeiten über BSH-OGC-API mit Stations-Suche

Amtliche BSH-Wasserstandsvorhersage ersetzt Open-Meteo als Primärquelle;
nächster Pegel per Haversine, Open-Meteo nur außerhalb 75 km Reichweite.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-12 11:00:41 +02:00
parent 0b46154696
commit 7d6c908f65
20 changed files with 680 additions and 32 deletions
@@ -0,0 +1 @@
{"type": "Feature", "id": "norderney_riffgat", "geometry": {"type": "Point", "coordinates": [7.157778, 53.696389]}, "properties": {"gauge_label": "Norderney, Riffgat", "latitude": 53.696389, "longitude": 7.157778, "area": "Jade und Ostfriesland", "forecast_timestamp": "2026-06-12 08:09:54+02:00", "high_water_low_water": [{"event_timestamp": "2026-06-12 09:20:00+02:00", "event": "HW", "tidal_prediction_value": "606", "forecast_value": 616, "forecast_uncertainty": 10.0, "forecast_deviation": "-0,1 m", "forecast_automated_event_warning": "Wasserstandsvorhersage", "forecast_event_forecast_timestamp": "2026-06-12 08:09:54+02:00"}, {"event_timestamp": "2026-06-12 15:39:00+02:00", "event": "NW", "tidal_prediction_value": "377", "forecast_value": 403, "forecast_uncertainty": 10.0, "forecast_deviation": "+0,2 m", "forecast_automated_event_warning": "Wasserstandsvorhersage", "forecast_event_forecast_timestamp": "2026-06-12 08:09:54+02:00", "mos_forecast_r0_value": 415, "mos_forecast_r0_deviation": "+0,3 m", "mos_forecast_r1_value": 409, "mos_forecast_r1_deviation": "+0,3 m", "mos_forecast_r2_value": 412, "mos_forecast_r2_deviation": "+0,3 m", "mos_forecast_r3_value": 414, "mos_forecast_r3_deviation": "+0,3 m", "mos_forecast_r4_value": 411, "mos_forecast_r4_deviation": "+0,3 m", "mos_forecast_r5_value": 400, "mos_forecast_r5_deviation": "+0,2 m", "mos_forecast_event_forecast_timestamp": "2026-06-12 10:30:00+02:00"}, {"event_timestamp": "2026-06-12 21:41:00+02:00", "event": "HW", "tidal_prediction_value": "629", "forecast_value": 666, "forecast_uncertainty": 10.0, "forecast_deviation": "+0,4 m", "forecast_automated_event_warning": "Wasserstandsvorhersage", "forecast_event_forecast_timestamp": "2026-06-12 08:09:54+02:00", "mos_forecast_r0_value": 653, "mos_forecast_r0_deviation": "+0,3 m", "mos_forecast_r1_value": 653, "mos_forecast_r1_deviation": "+0,3 m", "mos_forecast_r2_value": 658, "mos_forecast_r2_deviation": "+0,3 m", "mos_forecast_r3_value": 657, "mos_forecast_r3_deviation": "+0,3 m", "mos_forecast_r4_value": 653, "mos_forecast_r4_deviation": "+0,3 m", "mos_forecast_r5_value": 651, "mos_forecast_r5_deviation": "+0,2 m", "mos_forecast_event_forecast_timestamp": "2026-06-12 10:30:00+02:00"}, {"event_timestamp": "2026-06-13 04:14:00+02:00", "event": "NW", "tidal_prediction_value": "362", "forecast_value": 393, "forecast_uncertainty": 10.0, "forecast_deviation": "+0,1 m", "forecast_automated_event_warning": "Wasserstandsvorhersage", "forecast_event_forecast_timestamp": "2026-06-12 08:09:54+02:00", "mos_forecast_r0_value": 403, "mos_forecast_r0_deviation": "+0,2 m", "mos_forecast_r1_value": 395, "mos_forecast_r1_deviation": "+0,1 m", "mos_forecast_r2_value": 404, "mos_forecast_r2_deviation": "+0,2 m", "mos_forecast_r3_value": 400, "mos_forecast_r3_deviation": "+0,2 m", "mos_forecast_r4_value": 394, "mos_forecast_r4_deviation": "+0,1 m", "mos_forecast_r5_value": 388, "mos_forecast_r5_deviation": "+/-0,0 m", "mos_forecast_event_forecast_timestamp": "2026-06-12 10:30:00+02:00"}, {"event_timestamp": "2026-06-13 10:21:00+02:00", "event": "HW", "tidal_prediction_value": "617", "mos_forecast_r0_value": 655, "mos_forecast_r0_deviation": "+0,3 m", "mos_forecast_r1_value": 649, "mos_forecast_r1_deviation": "+0,2 m", "mos_forecast_r2_value": 656, "mos_forecast_r2_deviation": "+0,3 m", "mos_forecast_r3_value": 657, "mos_forecast_r3_deviation": "+0,3 m", "mos_forecast_r4_value": 649, "mos_forecast_r4_deviation": "+0,2 m", "mos_forecast_r5_value": 652, "mos_forecast_r5_deviation": "+0,3 m", "mos_forecast_event_forecast_timestamp": "2026-06-12 10:30:00+02:00"}, {"event_timestamp": "2026-06-13 16:47:00+02:00", "event": "NW", "tidal_prediction_value": "366", "mos_forecast_r0_value": 421, "mos_forecast_r0_deviation": "+0,4 m", "mos_forecast_r1_value": 416, "mos_forecast_r1_deviation": "+0,3 m", "mos_forecast_r2_value": 424, "mos_forecast_r2_deviation": "+0,4 m", "mos_forecast_r3_value": 410, "mos_forecast_r3_deviation": "+0,3 m", "mos_forecast_r4_value": 436, "mos_forecast_r4_deviation": "+0,5 m", "mos_forecast_r5_value": 405, "mos_forecast_r5_deviation": "+0,2 m", "mos_forecast_event_forecast_timestamp": "2026-06-12 10:30:00+02:00"}], "copyright": {"de": "@Bundesamt für Seeschifffahrt und Hydrographie (BSH). Das BSH übernimmt für die angegebenen Informationen keine Gewähr. Amtliche Wasserstandsvorhersage des Bundes gemäß §1 SeeAufG.", "en": "@Federal Maritime and Hydrographic Agency (BSH). The BSH accepts no liability for the information provided here. Official water level forecast of the federal government according to §1 SeeAufG."}}}
@@ -0,0 +1,42 @@
[
{
"id": "bensersiel",
"name": "Bensersiel",
"lat": 53.674722,
"lon": 7.575,
"area": "Jade und Ostfriesland",
"hasHwnw": true
},
{
"id": "emden_grosse_seeschleuse",
"name": "Emden, Ems, Große Seeschleuse",
"lat": 53.336667,
"lon": 7.186389,
"area": "Ems",
"hasHwnw": true
},
{
"id": "kiel-holtenau",
"name": "Kiel-Holtenau",
"lat": 54.3720866822911,
"lon": 10.1570496121807,
"area": "Kieler Bucht",
"hasHwnw": false
},
{
"id": "leyhoern_leybucht",
"name": "Leyhörn, Leybucht",
"lat": 53.549167,
"lon": 7.036111,
"area": "Jade und Ostfriesland",
"hasHwnw": true
},
{
"id": "norderney_riffgat",
"name": "Norderney, Riffgat",
"lat": 53.696389,
"lon": 7.157778,
"area": "Jade und Ostfriesland",
"hasHwnw": true
}
]
+1 -1
View File
@@ -3,7 +3,7 @@ import { requireUser } from '../middleware/auth.js'
import {
fetchTidesForCoordinates,
fetchTidesForPlace
} from '../utils/openMeteoTides.js'
} from '../utils/tideProvider.js'
const router = Router()
+75
View File
@@ -0,0 +1,75 @@
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import {
findNearestBshStation,
haversineKm,
parseBshFeatureToExtrema,
parseBshHwnwForecast,
setBshStationCacheForTests,
type BshStation
} from './bshTides.js'
const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), '../fixtures')
function loadJson<T>(name: string): T {
return JSON.parse(readFileSync(join(fixturesDir, name), 'utf8')) as T
}
const stationIndex = loadJson<BshStation[]>('bsh-station-index.json')
describe('haversineKm', () => {
it('returns zero for identical points', () => {
expect(haversineKm(53.62, 7.15, 53.62, 7.15)).toBe(0)
})
})
describe('findNearestBshStation', () => {
it('picks Norderney Riffgat for Norddeich coordinates', () => {
const nearest = findNearestBshStation(53.624526, 7.155263, stationIndex)
expect(nearest?.station.id).toBe('norderney_riffgat')
expect(nearest?.distanceKm).toBeGreaterThan(5)
expect(nearest?.distanceKm).toBeLessThan(12)
})
it('picks Kiel-Holtenau for Kiel coordinates', () => {
const nearest = findNearestBshStation(54.32, 10.14, stationIndex)
expect(nearest?.station.id).toBe('kiel-holtenau')
expect(nearest?.distanceKm).toBeLessThan(10)
})
})
describe('parseBshHwnwForecast', () => {
it('maps HW/NW events to extrema with Europe/Berlin dates', () => {
const feature = loadJson<{ properties: Record<string, unknown> }>('bsh-norderney_riffgat.json')
const extrema = parseBshHwnwForecast(feature)
expect(extrema.length).toBeGreaterThan(0)
const high = extrema.find((e) => e.isHigh)
const low = extrema.find((e) => !e.isHigh)
expect(high?.date).toMatch(/^\d{4}-\d{2}-\d{2}$/)
expect(low?.date).toMatch(/^\d{4}-\d{2}-\d{2}$/)
expect(high?.time).toContain('T')
expect(high?.height).toBeGreaterThan(0)
})
})
describe('parseBshFeatureToExtrema', () => {
it('uses hwnw_forecast when available', () => {
const feature = loadJson('bsh-norderney_riffgat.json')
const extrema = parseBshFeatureToExtrema(feature)
expect(extrema.some((e) => e.isHigh)).toBe(true)
expect(extrema.some((e) => !e.isHigh)).toBe(true)
})
})
describe('setBshStationCacheForTests', () => {
it('allows injecting station cache', () => {
setBshStationCacheForTests(stationIndex)
expect(findNearestBshStation(53.624526, 7.155263, stationIndex)?.station.id).toBe(
'norderney_riffgat'
)
setBshStationCacheForTests(null)
})
})
+315
View File
@@ -0,0 +1,315 @@
import type { TideExtreme, TideLookupResult } from './openMeteoTides.js'
export const MAX_BSH_DISTANCE_KM = 75
export const BSH_TIMEZONE = 'Europe/Berlin'
const API_BASE =
'https://gdi.bsh.de/ldproxy/rest/services/WaterLevelForecast/collections/waterlevelforecastdata/items'
const LIST_LIMIT = 1000
const MAX_PAGES = 20
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
const FETCH_TIMEOUT_MS = 15_000
export interface BshStation {
id: string
name: string
lat: number
lon: number
area?: string
}
interface OgcFeatureCollection {
features?: OgcFeature[]
links?: Array<{ rel?: string; href?: string }>
}
interface OgcFeature {
type?: string
id?: string
geometry?: { coordinates?: [number, number] }
properties?: Record<string, unknown>
}
interface HwnwEvent {
event?: string
event_timestamp?: string
forecast_value?: number | string | null
tidal_prediction_value?: number | string | null
}
interface CurvePoint {
timestamp?: string
automated_curve_forecast?: number | string | null
}
let stationCache: { stations: BshStation[]; loadedAt: number } | null = null
async function fetchJson<T>(url: string): Promise<T> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
try {
const res = await fetch(url, { signal: controller.signal, redirect: 'follow' })
const data = await res.json()
if (!res.ok) {
throw new Error(`BSH HTTP ${res.status}`)
}
return data as T
} finally {
clearTimeout(timeout)
}
}
function parseNum(value: unknown): number | null {
if (value == null || value === '') return null
if (typeof value === 'number') return value
const n = Number(value)
return Number.isNaN(n) ? null : n
}
function stationFromFeature(feature: OgcFeature): BshStation | null {
const id = feature.id
const props = feature.properties
if (!id || !props) return null
const name = String(props.gauge_label ?? '').trim()
if (!name) return null
const geom = feature.geometry?.coordinates
const lat = parseNum(props.latitude) ?? (geom ? geom[1] : null)
const lon = parseNum(props.longitude) ?? (geom ? geom[0] : null)
if (lat == null || lon == null) return null
return {
id,
name,
lat,
lon,
area: props.area ? String(props.area) : undefined
}
}
export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371
const p = Math.PI / 180
const dLat = (lat2 - lat1) * p
const dLon = (lon2 - lon1) * p
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * p) * Math.cos(lat2 * p) * Math.sin(dLon / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(a))
}
export function findNearestBshStation(
lat: number,
lon: number,
stations: BshStation[]
): { station: BshStation; distanceKm: number } | null {
if (stations.length === 0) return null
let best: { station: BshStation; distanceKm: number } | null = null
for (const station of stations) {
const distanceKm = haversineKm(lat, lon, station.lat, station.lon)
if (!best || distanceKm < best.distanceKm) {
best = { station, distanceKm }
}
}
return best
}
export async function loadBshStationIndex(): Promise<BshStation[]> {
if (stationCache && Date.now() - stationCache.loadedAt < CACHE_TTL_MS) {
return stationCache.stations
}
const stations: BshStation[] = []
let nextUrl: string | null = `${API_BASE}?f=json&limit=${LIST_LIMIT}`
for (let page = 0; page < MAX_PAGES && nextUrl; page += 1) {
const currentUrl = nextUrl
const payload: OgcFeatureCollection = await fetchJson<OgcFeatureCollection>(currentUrl)
const features = payload.features ?? []
for (const feature of features) {
const station = stationFromFeature(feature)
if (station) stations.push(station)
}
nextUrl = null
const links = payload.links ?? []
for (let i = 0; i < links.length; i += 1) {
const link = links[i]
if (link.rel === 'next' && link.href) {
nextUrl = link.href
break
}
}
}
if (stations.length === 0) {
throw new Error('bsh_empty_station_list')
}
stationCache = { stations, loadedAt: Date.now() }
return stations
}
/** Test helper: inject a pre-built station list and skip live index fetch. */
export function setBshStationCacheForTests(stations: BshStation[] | null): void {
stationCache = stations ? { stations, loadedAt: Date.now() } : null
}
function localDateFromIso(iso: string, timeZone: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return ''
return new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).format(date)
}
function bshTimestampToIso(timestamp: string): string {
const normalized = timestamp.trim().replace(' ', 'T')
const date = new Date(normalized)
if (Number.isNaN(date.getTime())) return ''
return date.toISOString()
}
function heightMetresFromCm(value: unknown): number {
const cm = parseNum(value)
if (cm == null) return 0
return Number((cm / 100).toFixed(2))
}
export function parseBshHwnwForecast(
feature: OgcFeature,
timeZone = BSH_TIMEZONE
): TideExtreme[] {
const props = feature.properties ?? {}
const hwnw = props.high_water_low_water
if (!Array.isArray(hwnw) || hwnw.length === 0) return []
const extrema: TideExtreme[] = []
for (const raw of hwnw as HwnwEvent[]) {
const event = String(raw.event ?? '').toUpperCase()
const timestamp = String(raw.event_timestamp ?? '').trim()
if (!timestamp || (event !== 'HW' && event !== 'NW')) continue
const iso = bshTimestampToIso(timestamp)
if (!iso) continue
const value = raw.forecast_value ?? raw.tidal_prediction_value
extrema.push({
time: iso,
date: localDateFromIso(iso, timeZone),
height: heightMetresFromCm(value),
isHigh: event === 'HW'
})
}
return extrema
}
function parseBshCurveForecast(
feature: OgcFeature,
timeZone = BSH_TIMEZONE
): TideExtreme[] {
const curve = feature.properties?.curve
if (!Array.isArray(curve) || curve.length < 3) return []
const points = (curve as CurvePoint[])
.map((p) => ({
timestamp: String(p.timestamp ?? '').trim(),
level: parseNum(p.automated_curve_forecast)
}))
.filter((p) => p.timestamp && p.level != null) as Array<{
timestamp: string
level: number
}>
const extrema: TideExtreme[] = []
for (let i = 1; i < points.length - 1; i += 1) {
const prev = points[i - 1].level
const curr = points[i].level
const next = points[i + 1].level
const isHigh = curr >= prev && curr >= next && (curr > prev || curr > next)
const isLow = curr <= prev && curr <= next && (curr < prev || curr < next)
if (!isHigh && !isLow) continue
const iso = bshTimestampToIso(points[i].timestamp)
if (!iso) continue
extrema.push({
time: iso,
date: localDateFromIso(iso, timeZone),
height: Number((curr / 100).toFixed(2)),
isHigh
})
}
return extrema
}
export function parseBshFeatureToExtrema(feature: OgcFeature): TideExtreme[] {
const hwnw = parseBshHwnwForecast(feature)
if (hwnw.length > 0) return hwnw
return parseBshCurveForecast(feature)
}
async function fetchBshStationFeature(stationId: string): Promise<OgcFeature> {
const feature = await fetchJson<OgcFeature>(`${API_BASE}/${stationId}?f=json`)
if (feature.type !== 'Feature' || !feature.properties) {
throw new Error('bsh_invalid_station')
}
return feature
}
export interface BshTideLookupResult extends TideLookupResult {
distanceKm: number
}
export async function fetchBshTidesForCoordinates(
lat: number,
lon: number
): Promise<BshTideLookupResult> {
const stations = await loadBshStationIndex()
const nearest = findNearestBshStation(lat, lon, stations)
if (!nearest) {
throw new Error('no_bsh_station')
}
if (nearest.distanceKm > MAX_BSH_DISTANCE_KM) {
const err = new Error('bsh_station_too_far') as Error & { distanceKm?: number }
err.distanceKm = nearest.distanceKm
throw err
}
const feature = await fetchBshStationFeature(nearest.station.id)
const extrema = parseBshFeatureToExtrema(feature)
if (extrema.length === 0) {
throw new Error('no_tide_data')
}
const copyright = feature.properties?.copyright
let sourceNote = 'BSH Wasserstandsvorhersage (© BSH, CC BY 4.0)'
if (copyright && typeof copyright === 'object' && copyright !== null) {
const cr = copyright as Record<string, string>
sourceNote = cr.de || cr.en || sourceNote
}
return {
distanceKm: Number(nearest.distanceKm.toFixed(1)),
location: {
name: nearest.station.name,
lat: nearest.station.lat,
lon: nearest.station.lon,
source: 'bsh_station',
stationId: nearest.station.id
},
tides: {
data: {
timezone: BSH_TIMEZONE,
datum: 'gauge',
source: sourceNote,
extrema
}
}
}
}
+5 -2
View File
@@ -10,17 +10,20 @@ export interface TideExtreme {
isHigh: boolean
}
export type TideLocationSource = 'coordinates' | 'geocoded' | 'bsh_station'
export interface TideLookupResult {
location: {
name?: string
lat: number
lon: number
source: 'coordinates' | 'geocoded'
source: TideLocationSource
stationId?: string
}
tides: {
data: {
timezone: string
datum: 'MSL'
datum: 'MSL' | 'gauge'
source: string
extrema: TideExtreme[]
}
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import * as bshTides from './bshTides.js'
import * as openMeteoTides from './openMeteoTides.js'
import { fetchTidesForCoordinates } from './tideProvider.js'
describe('fetchTidesForCoordinates', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('returns BSH data when station is within range', async () => {
vi.spyOn(bshTides, 'fetchBshTidesForCoordinates').mockResolvedValue({
distanceKm: 8,
location: {
name: 'Norderney, Riffgat',
lat: 53.696389,
lon: 7.157778,
source: 'bsh_station',
stationId: 'norderney_riffgat'
},
tides: {
data: {
timezone: 'Europe/Berlin',
datum: 'gauge',
source: 'BSH',
extrema: [
{
time: '2026-06-12T07:20:00.000Z',
date: '2026-06-12',
height: 6.16,
isHigh: true
}
]
}
}
})
const result = await fetchTidesForCoordinates(53.62, 7.15)
expect(result.distanceKm).toBe(8)
expect(result.location.source).toBe('bsh_station')
expect(result.fallback).toBeUndefined()
})
it('falls back to Open-Meteo when BSH station is too far', async () => {
vi.spyOn(bshTides, 'fetchBshTidesForCoordinates').mockRejectedValue(
Object.assign(new Error('bsh_station_too_far'), { distanceKm: 120 })
)
vi.spyOn(openMeteoTides, 'fetchTidesForCoordinates').mockResolvedValue({
location: { lat: 62, lon: 5, source: 'coordinates' },
tides: {
data: {
timezone: 'Europe/Oslo',
datum: 'MSL',
source: 'Open-Meteo Marine',
extrema: [
{
time: '2026-06-12T10:00:00.000Z',
date: '2026-06-12',
height: 1.2,
isHigh: true
}
]
}
}
})
const result = await fetchTidesForCoordinates(62, 5)
expect(result.fallback).toBe('open_meteo')
expect(result.tides.data.source).toContain('Fallback')
})
})
+58
View File
@@ -0,0 +1,58 @@
import { fetchBshTidesForCoordinates, MAX_BSH_DISTANCE_KM } from './bshTides.js'
import {
fetchTidesForCoordinates as fetchOpenMeteoTidesForCoordinates,
fetchTidesForPlace as fetchOpenMeteoTidesForPlace,
geocodePlace,
type TideLookupResult
} from './openMeteoTides.js'
export type TideProviderResult = TideLookupResult & {
distanceKm?: number
fallback?: 'open_meteo'
}
export async function fetchTidesForCoordinates(
lat: number,
lon: number,
options?: { name?: string; source?: 'coordinates' | 'geocoded' }
): Promise<TideProviderResult> {
try {
const bsh = await fetchBshTidesForCoordinates(lat, lon)
return bsh
} catch (error: unknown) {
const message = error instanceof Error ? error.message : ''
const tooFar = message === 'bsh_station_too_far'
const noStation = message === 'no_bsh_station' || message === 'bsh_empty_station_list'
const noData = message === 'no_tide_data'
if (!tooFar && !noStation && !noData) {
console.warn('BSH tide lookup failed, trying Open-Meteo fallback:', error)
}
const fallback = await fetchOpenMeteoTidesForCoordinates(lat, lon, options)
return {
...fallback,
fallback: 'open_meteo',
tides: {
data: {
...fallback.tides.data,
source: `${fallback.tides.data.source} (Fallback — keine BSH-Station innerhalb ${MAX_BSH_DISTANCE_KM} km)`
}
}
}
}
}
export async function fetchTidesForPlace(query: string): Promise<TideProviderResult> {
const place = await geocodePlace(query)
if (!place) {
const err = new Error('place_not_found') as Error & { status?: number }
err.status = 404
throw err
}
return fetchTidesForCoordinates(place.latitude, place.longitude, {
name: place.name,
source: 'geocoded'
})
}