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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
node_modules
dist
package-lock.json
tmp
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ This database includes harmonic constituents for tide prediction from various so

## Sources

- ✅ [**NOAA**](https://tidesandcurrents.noaa.gov): National Oceanic and Atmospheric Administration
- ✅ [**NOAA**](data/noaa/README.md): National Oceanic and Atmospheric Administration
~3379 stations, mostly in the United States and its territories. Updated monthly via [NOAA's API](https://api.tidesandcurrents.noaa.gov/mdapi/prod/).

- 🔜 [**TICON-4**](https://www.seanoe.org/data/00980/109129/): TIdal CONstants based on GESLA-4 sea-level records
- [**TICON-4**](data/ticon/README.md): TIdal CONstants based on GESLA-4 sea-level records
4,838 global stations - ([#16](https://github.com/neaps/tide-database/pull/16))

If you know of other public sources of harmonic constituents, please [open an issue](https://github.com/neaps/tide-database/issues/new) to discuss adding them.
Expand Down
2 changes: 1 addition & 1 deletion docs/noaa.md → data/noaa/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## NOAA Tide Station Data Overview

This script fetches tide station metadata from NOAA CO-OPS and converts it into a local, normalized dataset. It classifies stations by prediction method, stores harmonic constituents or prediction offsets as appropriate, and records available tidal datums for reference.
This database fetches tide station metadata from NOAA CO-OPS and converts it into a local, normalized dataset. It classifies stations by prediction method, stores harmonic constituents or prediction offsets as appropriate, and records available tidal datums for reference.

The goal is to mirror how NOAA operationally produces tide predictions, not just what data exists in their metadata.

Expand Down
25 changes: 25 additions & 0 deletions data/ticon/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# TICON-4 Tide Station Data

[TICON-4](https://www.seanoe.org/data/00980/109129/) is a global dataset of tidal harmonic constituents derived from the **GESLA-4** (Global Extreme Sea Level Analysis v.4) sea-level gauge compilation. It provides tidal characteristics for approximately **4,838 tide stations** worldwide, with emphasis on global coverage outside the United States (which is covered by NOAA's tide database).

**Key Details:**
- **Source:** [TICON-4 @ SEANOE](https://www.seanoe.org/data/00980/109129/)
- **Manual:** [TICON Documentation](https://www.seanoe.org/data/00980/109129/data/122852.pdf)
- **License:** CC-BY-4.0 (Creative Commons Attribution 4.0)
- **Coverage:** Global tide stations with harmonic constituent analysis from GESLA-4 observations

Each station in this dataset contains harmonic constituents (amplitude and phase for tidal frequency components such as M2, K1, O1, etc.) extracted from historical sea-level records.

![](https://www.seanoe.org/data/00980/109129/illustration.jpg)

## Synthetic Tidal Datums

TICON-4 does not provide empirically derived tidal datums. Instead, this dataset includes **synthetic tidal datums** computed from 19-year harmonic predictions using the harmonic constituents, not from observed water level data. This approach generates theoretical datums that represent long-term average tidal characteristics without the influence of weather events, non-tidal water level changes, or observational gaps.

These datums should eventually be replaced with water-level-derived datums when available. See [#40](https://github.com/neaps/tide-database/issues/40).

## References

- [TICON-4 Dataset](https://www.seanoe.org/data/00980/109129/)
- [TICON Manual](https://www.seanoe.org/data/00980/109129/data/122852.pdf)
- [GESLA-4 Project](https://gesla787883612.wordpress.com)
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"format": "prettier --write ."
},
"devDependencies": {
"@neaps/tide-predictor": "^0.2.1",
"@neaps/tide-predictor": "^0.4.1",
"@types/geokdbush": "^1.1.5",
"@types/make-fetch-happen": "^10.0.4",
"@types/node": "^25.0.3",
Expand Down
230 changes: 230 additions & 0 deletions tools/datum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import tidePredictor, {
type TidePredictionOptions,
type HarmonicConstituent,
} from "@neaps/tide-predictor";

export interface EpochSpec {
start?: Date;
end?: Date;
}

export type Datums = Record<string, number>;

export interface TidalDatumsResult {
epochStart: Date;
epochEnd: Date;
lengthYears: number;

/** seconds between samples in the synthetic series */
timeFidelity: number;
/** tidal-day length used (hours) */
tidalDayHours: number;

datums: Datums;
}

export interface DatumsOptions extends TidePredictionOptions {
/**
* Time step in hours for the synthetic series.
* Converted to `timeFidelity` in seconds for neaps.
* Default: 1 hour.
*/
stepHours?: number;

/**
* Length of a "tidal day" in hours.
* Typical: 24.8333 (24h 50m).
* Default: 24.8333333.
*/
tidalDayHours?: number;
}

const YEAR_MS = 365.2425 * 24 * 60 * 60 * 1000;
const NINETEEN_YEARS = 19 * YEAR_MS;

/**
* Resolve an EpochSpec to explicit start/end Dates.
*/
export function resolveEpoch({
end = new Date(),
start = new Date(end.getTime() - NINETEEN_YEARS),
}: EpochSpec): {
start: Date;
end: Date;
lengthYears: number;
} {
let lengthYears = (end.getTime() - start.getTime()) / YEAR_MS;
if (lengthYears > 19) {
start = new Date(end.getTime() - NINETEEN_YEARS);
lengthYears = 19;
}
return { start, end, lengthYears };
}

/**
* Core helper: given a regular timeline of {time, level}, compute datums
*/
function computeDatumsFromTimeline(
times: Date[],
heights: number[],
tidalDayHours: number,
): Datums {
if (!times.length || times.length !== heights.length) {
throw new Error("times and heights must be non-empty and of equal length");
}

const allHighs: number[] = [];
const allLows: number[] = [];
const higherHighs: number[] = [];
const lowerLows: number[] = [];

const tidalDayMs = tidalDayHours * 60 * 60 * 1000;

if (times.length === 0) {
throw new Error("times array is empty");
}
const firstTime = times[0];
const lastTime = times[times.length - 1];
if (!firstTime || !lastTime) {
throw new Error("times array is empty");
}

let dayStartTime = firstTime.getTime();
let idx = 0;
let daysWithHighs = 0;
let daysWithLows = 0;

while (dayStartTime < lastTime.getTime()) {
const dayEndTime = dayStartTime + tidalDayMs;

const idxStart = idx;
while (idx < times.length && times[idx]!.getTime() < dayEndTime) {
idx++;
}
const idxEnd = idx;

if (idxEnd - idxStart >= 3) {
const highs: number[] = [];
const lows: number[] = [];

for (let i = idxStart + 1; i < idxEnd - 1; i++) {
const hPrev = heights[i - 1];
const hCurr = heights[i];
const hNext = heights[i + 1];

if (
hCurr !== undefined &&
hPrev !== undefined &&
hNext !== undefined &&
hCurr >= hPrev &&
hCurr >= hNext &&
(hCurr > hPrev || hCurr > hNext)
) {
highs.push(hCurr);
} else if (
hCurr !== undefined &&
hPrev !== undefined &&
hNext !== undefined &&
hCurr <= hPrev &&
hCurr <= hNext &&
(hCurr < hPrev || hCurr < hNext)
) {
lows.push(hCurr);
}
}

if (highs.length > 0) {
daysWithHighs++;
allHighs.push(...highs);
highs.sort((a, b) => a - b);
// higher high
const hhVal = highs[highs.length - 1];
if (hhVal !== undefined) {
higherHighs.push(hhVal);
}
}

if (lows.length > 0) {
daysWithLows++;
allLows.push(...lows);
lows.sort((a, b) => a - b);
// lower low
const llVal = lows[0];
if (llVal !== undefined) {
lowerLows.push(llVal);
}
}
}

dayStartTime += tidalDayMs;

// ensure idx keeps up
while (idx < times.length && times[idx]!.getTime() < dayStartTime) {
idx++;
}
}

const mhw = mean(allHighs);
const mlw = mean(allLows);

return {
MHHW: toFixed(mean(higherHighs), 3),
MHW: toFixed(mhw, 3),
MSL: toFixed(mean(heights), 3),
MTL: toFixed((mhw + mlw) / 2, 3),
MLW: toFixed(mlw, 3),
MLLW: toFixed(mean(lowerLows), 3),
LAT: toFixed(Math.min(...heights), 3),
};
}

/**
* Use @neaps/tide-predictor to synthesize a multi-year tidal timeline
* for a given set of constituents, and compute tidal datums from it.
*/
export function computeDatums(
constituents: HarmonicConstituent[],
epochSpec: EpochSpec,
{
stepHours = 1,
tidalDayHours = 24.8333333,
...tidePredictorOptions
}: DatumsOptions = {},
): TidalDatumsResult {
const { start, end, lengthYears } = resolveEpoch(epochSpec);

const timeFidelity = stepHours * 60 * 60;

// Build predictor from @neaps/tide-predictor
const predictor = tidePredictor(constituents, tidePredictorOptions);

// Ask it for a synthetic timeline over the epoch
const timeline = predictor.getExtremesPrediction({
start,
end,
timeFidelity,
});

const times = timeline.map((pt) => pt.time);
const heights = timeline.map((pt) => pt.level);

return {
epochStart: start,
epochEnd: end,
lengthYears,
timeFidelity,
tidalDayHours,
datums: computeDatumsFromTimeline(times, heights, tidalDayHours),
};
}

export function toFixed(num: number, digits: number) {
if (typeof num !== "number") return num;

const factor = Math.pow(10, digits);
return Math.round(num * factor) / factor;
}

export function mean(arr: number[]): number {
return arr.length ? arr.reduce((s, v) => s + v, 0) / arr.length : NaN;
}
12 changes: 12 additions & 0 deletions tools/import-ticon
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/bin/bash

set -e

mkdir -p tmp

[[ -d tmp/TICON-4 ]] || {
curl -L -o tmp/TICON-4.zip https://github.com/user-attachments/files/24195063/TICON-4.zip
unzip -d tmp tmp/TICON-4.zip
}

node tools/import-ticon.ts
Loading