Skip to content
Open
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
2 changes: 1 addition & 1 deletion benchmarks/noaa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ for (const id of stations) {
end,
})
.extremes.map((e) => ({
time: e.time.getTime(),
time: e.time.epochMilliseconds,
level: e.level,
type: e.high ? "H" : "L",
}));
Expand Down
62 changes: 62 additions & 0 deletions examples/temporal-example.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/usr/bin/env node
/**
* Example demonstrating Temporal.Instant support in Neaps
* Run with: node examples/temporal-example.mjs
*/

import { Temporal } from "@js-temporal/polyfill";
import { getExtremesPrediction } from "neaps";

// Example 1: Using JavaScript Date (backward compatible)
console.log("=== Using JavaScript Date ===");
const predictionWithDate = getExtremesPrediction({
lat: 26.772,
lon: -80.05,
start: new Date("2025-12-18T00:00:00Z"),
end: new Date("2025-12-19T00:00:00Z"),
datum: "MLLW",
});

console.log("Extremes found:", predictionWithDate.extremes.length);
console.log("First extreme:", {
time: predictionWithDate.extremes[0].time.toString(),
level: predictionWithDate.extremes[0].level.toFixed(2),
type: predictionWithDate.extremes[0].high ? "High" : "Low",
});

// Example 2: Using Temporal.Instant (native Temporal support)
console.log("\n=== Using Temporal.Instant ===");
const startInstant = Temporal.Instant.from("2025-12-18T00:00:00Z");
const endInstant = Temporal.Instant.from("2025-12-19T00:00:00Z");

const predictionWithTemporal = getExtremesPrediction({
lat: 26.772,
lon: -80.05,
start: startInstant,
end: endInstant,
datum: "MLLW",
});

console.log("Extremes found:", predictionWithTemporal.extremes.length);
console.log("First extreme:", {
time: predictionWithTemporal.extremes[0].time.toString(),
level: predictionWithTemporal.extremes[0].level.toFixed(2),
type: predictionWithTemporal.extremes[0].high ? "High" : "Low",
});

// Example 3: Converting returned Temporal.Instant to Date
console.log("\n=== Converting Temporal.Instant back to Date ===");
const instant = predictionWithTemporal.extremes[0].time;
const date = new Date(instant.epochMilliseconds);
console.log("As Date object:", date.toISOString());
console.log("As Date string:", date.toString());

// Example 4: Using Temporal for manipulation
console.log("\n=== Temporal API benefits ===");
const timeOfExtreme = predictionWithTemporal.extremes[0].time;
const oneHourLater = timeOfExtreme.add({ hours: 1 });
const oneHourEarlier = timeOfExtreme.subtract({ hours: 1 });

console.log("One hour earlier:", oneHourEarlier.toString());
console.log("Extreme time:", timeOfExtreme.toString());
console.log("One hour later:", oneHourLater.toString());
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@neaps/tide-database": "*",
"@types/make-fetch-happen": "^10.0.4",
"@types/node": "^25.0.2",
"@vitest/coverage-v8": "^4.0.15",
Expand Down
1 change: 1 addition & 0 deletions packages/neaps/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"prepack": "npm run build"
},
"dependencies": {
"@js-temporal/polyfill": "^0.5",
"@neaps/tide-database": "0.3",
"@neaps/tide-predictor": "^0.4.0",
"geolib": "^3.3.4"
Expand Down
37 changes: 34 additions & 3 deletions packages/neaps/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { Temporal } from "@js-temporal/polyfill";
import { getDistance } from "geolib";
import { stations, type Station } from "@neaps/tide-database";
import tidePredictor, { type TimeSpan, type ExtremesInput } from "@neaps/tide-predictor";
import tidePredictor, {
type TimeSpan,
type ExtremesInput,
Extreme,
TimelinePoint,
} from "@neaps/tide-predictor";
import type { GeolibInputCoordinates } from "geolib/es/types";

type Units = "meters" | "feet";
Expand All @@ -14,7 +20,7 @@ type PredictionOptions = {

export type ExtremesOptions = ExtremesInput & PredictionOptions;
export type TimelineOptions = TimeSpan & PredictionOptions;
export type WaterLevelOptions = { time: Date } & PredictionOptions;
export type WaterLevelOptions = { time: Date | Temporal.Instant } & PredictionOptions;

const feetPerMeter = 3.2808399;
const defaultUnits: Units = "meters";
Expand Down Expand Up @@ -89,7 +95,32 @@ export function findStation(query: string) {
return useStation(found);
}

export function useStation(station: Station, distance?: number) {
export type StationPrediction = {
datum: string | undefined;
units: Units;
station: Station;
distance?: number;
};

export type StationExtremesPrediction = StationPrediction & {
extremes: Extreme[];
};

export type StationTimelinePrediction = StationPrediction & {
timeline: TimelinePoint[];
};

export type StationWaterLevelPrediction = StationPrediction & TimelinePoint;

export type StationPredictor = Station & {
distance?: number;
defaultDatum?: string;
getExtremesPrediction: (options: ExtremesOptions) => StationExtremesPrediction;
getTimelinePrediction: (options: TimelineOptions) => StationTimelinePrediction;
getWaterLevelAtTime: (options: WaterLevelOptions) => StationWaterLevelPrediction;
};

export function useStation(station: Station, distance?: number): StationPredictor {
// If subordinate station, use the reference station for datums and constituents
let reference = station;
if (station.type === "subordinate") {
Expand Down
21 changes: 15 additions & 6 deletions packages/neaps/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe("timezone independence", () => {
expect(result.length).toBe(baseline.length);
result.forEach((extreme, index) => {
const base = baseline[index];
expect(extreme.time.valueOf()).toBe(base.time.valueOf());
expect(extreme.time.epochMilliseconds).toBe(base.time.epochMilliseconds);
expect(extreme.high).toBe(base.high);
expect(extreme.low).toBe(base.low);
expect(extreme.label).toBe(base.label);
Expand Down Expand Up @@ -71,7 +71,9 @@ describe("getExtremesPrediction", () => {

const { extremes } = prediction;
expect(extremes.length).toBe(4);
expect(extremes[0].time).toEqual(new Date("2025-12-18T05:30:00.000Z"));
expect(extremes[0].time.epochMilliseconds).toEqual(
new Date("2025-12-18T05:30:00.000Z").getTime(),
);
expect(extremes[0].level).toBeCloseTo(0.02, 2);
expect(extremes[0].high).toBe(false);
expect(extremes[0].low).toBe(true);
Expand Down Expand Up @@ -126,7 +128,9 @@ describe("getWaterLevelAtTime", () => {

expect(prediction.station.id).toEqual("noaa/8722588");
expect(prediction.datum).toBe("MSL");
expect(prediction.time).toEqual(new Date("2025-12-19T05:30:00.000Z"));
expect(prediction.time.epochMilliseconds).toEqual(
new Date("2025-12-19T05:30:00.000Z").getTime(),
);
expect(typeof prediction.level).toBe("number");
});

Expand Down Expand Up @@ -173,7 +177,9 @@ describe("for a specific station", () => {
});

expect(predictions.length).toBe(4);
expect(predictions[0].time).toEqual(new Date("2025-12-17T11:23:00.000Z"));
expect(predictions[0].time.epochMilliseconds).toEqual(
new Date("2025-12-17T11:23:00.000Z").getTime(),
);
expect(predictions[0].level).toBeCloseTo(0.9, 1);
expect(predictions[0].high).toBe(true);
expect(predictions[0].low).toBe(false);
Expand Down Expand Up @@ -221,7 +227,10 @@ describe("for a specific station", () => {

noaa.forEach((expected, index) => {
const actual = prediction.extremes[index];
expect(actual.time).toBeWithin(new Date(expected.t).valueOf(), 5 * 60 * 1000 /* min */);
expect(actual.time.epochMilliseconds).toBeWithin(
new Date(expected.t).getTime(),
5 * 60 * 1000 /* min */,
);
expect(actual.level).toBeWithin(expected.v, 0.04 /* m */);
});
});
Expand Down Expand Up @@ -265,7 +274,7 @@ describe("for a specific station", () => {
const prediction = station.getWaterLevelAtTime({
time: new Date("2025-12-19T00:30:00Z"),
});
expect(prediction.time).toEqual(new Date("2025-12-19T00:30:00Z"));
expect(prediction.time.epochMilliseconds).toEqual(new Date("2025-12-19T00:30:00Z").getTime());
expect(prediction.datum).toBe("MLLW");
expect(typeof prediction.level).toBe("number");
});
Expand Down
4 changes: 3 additions & 1 deletion packages/tide-predictor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@
"files": [
"dist"
],
"devDependencies": {},
"scripts": {
"build": "tsdown",
"prepack": "npm run build"
},
"dependencies": {
"@js-temporal/polyfill": "^0.5"
}
}
42 changes: 28 additions & 14 deletions packages/tide-predictor/src/astronomy/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Temporal } from "@js-temporal/polyfill";
import { d2r, r2d } from "./constants.js";
import coefficients from "./coefficients.js";

Expand All @@ -24,6 +25,14 @@ export interface AstroData {
P: AstroValue;
}

// Convert Date or Temporal.Instant to Temporal.Instant
const toInstant = (time: Date | Temporal.Instant): Temporal.Instant => {
if (time instanceof Temporal.Instant) {
return time;
}
return Temporal.Instant.fromEpochMilliseconds(time.getTime());
};

// Evaluates a polynomial at argument
const polynomial = (coefficients: number[], argument: number): number => {
const result: number[] = [];
Expand All @@ -43,20 +52,24 @@ const derivativePolynomial = (coefficients: number[], argument: number): number
};

// Meeus formula 11.1
const T = (t: Date): number => {
const T = (t: Date | Temporal.Instant): number => {
return (JD(t) - 2451545.0) / 36525;
};

// Meeus formula 7.1
const JD = (t: Date): number => {
let Y = t.getUTCFullYear();
let M = t.getUTCMonth() + 1;
const JD = (t: Date | Temporal.Instant): number => {
const instant = toInstant(t);
// Extract UTC components directly from epoch milliseconds to avoid expensive ZonedDateTime conversion
const ms = Number(instant.epochMilliseconds);
const date = new Date(ms);
let Y = date.getUTCFullYear();
let M = date.getUTCMonth() + 1;
const D =
t.getUTCDate() +
t.getUTCHours() / 24.0 +
t.getUTCMinutes() / (24.0 * 60.0) +
t.getUTCSeconds() / (24.0 * 60.0 * 60.0) +
t.getUTCMilliseconds() / (24.0 * 60.0 * 60.0 * 1e6);
date.getUTCDate() +
date.getUTCHours() / 24.0 +
date.getUTCMinutes() / (24.0 * 60.0) +
date.getUTCSeconds() / (24.0 * 60.0 * 60.0) +
date.getUTCMilliseconds() / (24.0 * 60.0 * 60.0 * 1e6);
if (M <= 2) {
Y = Y - 1;
M = M + 12;
Expand Down Expand Up @@ -122,7 +135,8 @@ const modulus = (a: number, b: number): number => {
return ((a % b) + b) % b;
};

const astro = (time: Date): AstroData => {
const astro = (time: Date | Temporal.Instant): AstroData => {
const instant = toInstant(time);
// This gets cast to `AstroData` later, but we build it up step by step here
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any = {};
Expand All @@ -143,8 +157,8 @@ const astro = (time: Date): AstroData => {
const dTdHour = 1 / (24 * 365.25 * 100);
for (const name in polynomials) {
result[name] = {
value: modulus(polynomial(polynomials[name], T(time)), 360.0),
speed: derivativePolynomial(polynomials[name], T(time)) * dTdHour,
value: modulus(polynomial(polynomials[name], T(instant)), 360.0),
speed: derivativePolynomial(polynomials[name], T(instant)) * dTdHour,
};
}

Expand All @@ -170,7 +184,7 @@ const astro = (time: Date): AstroData => {
// set for equilibrium arguments #is given by T+h-s, s, h, p, N, pp, 90.
// This is in line with convention.
const hour = {
value: (JD(time) - Math.floor(JD(time))) * 360.0,
value: (JD(instant) - Math.floor(JD(instant))) * 360.0,
speed: 15.0,
};

Expand All @@ -191,4 +205,4 @@ const astro = (time: Date): AstroData => {
};

export default astro;
export { polynomial, derivativePolynomial, T, JD, _I, _xi, _nu, _nup, _nupp };
export { toInstant, polynomial, derivativePolynomial, T, JD, _I, _xi, _nu, _nup, _nupp };
46 changes: 28 additions & 18 deletions packages/tide-predictor/src/harmonics/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Temporal } from "@js-temporal/polyfill";
import prediction from "./prediction.js";
import constituentModels from "../constituents/index.js";
import { d2r } from "../astronomy/constants.js";
Expand All @@ -15,28 +16,34 @@ export interface PredictionOptions {
}

export interface Harmonics {
setTimeSpan: (startTime: Date | number, endTime: Date | number) => Harmonics;
setTimeSpan: (
startTime: Date | Temporal.Instant | number,
endTime: Date | Temporal.Instant | number,
) => Harmonics;
prediction: (options?: PredictionOptions) => Prediction;
}

const getDate = (time: Date | number): Date => {
if (time instanceof Date) {
const getInstant = (time: Date | Temporal.Instant | number): Temporal.Instant => {
if (time instanceof Temporal.Instant) {
return time;
}
if (time instanceof Date) {
return Temporal.Instant.fromEpochMilliseconds(time.getTime());
}
if (typeof time === "number") {
return new Date(time * 1000);
return Temporal.Instant.fromEpochMilliseconds(time * 1000);
}
throw new Error("Invalid date format, should be a Date object, or timestamp");
throw new Error("Invalid date format, should be a Date, Temporal.Instant, or timestamp");
};

const getTimeline = (start: Date, end: Date, seconds: number = 10 * 60) => {
const items: Date[] = [];
const endTime = end.getTime() / 1000;
let lastTime = start.getTime() / 1000;
const getTimeline = (start: Temporal.Instant, end: Temporal.Instant, seconds: number = 10 * 60) => {
const items: Temporal.Instant[] = [];
const endEpochSeconds = end.epochMilliseconds / 1000;
let lastTime = start.epochMilliseconds / 1000;
const startTime = lastTime;
const hours: number[] = [];
while (lastTime <= endTime) {
items.push(new Date(lastTime * 1000));
while (lastTime <= endEpochSeconds) {
items.push(Temporal.Instant.fromEpochMilliseconds(lastTime * 1000));
hours.push((lastTime - startTime) / (60 * 60));
lastTime += seconds;
}
Expand Down Expand Up @@ -72,15 +79,18 @@ const harmonicsFactory = ({ harmonicConstituents, offset }: HarmonicsOptions): H
});
}

let start = new Date();
let end = new Date();
let start = Temporal.Now.instant();
let end = Temporal.Now.instant();

const harmonics: Harmonics = {} as Harmonics;

harmonics.setTimeSpan = (startTime: Date | number, endTime: Date | number): Harmonics => {
start = getDate(startTime);
end = getDate(endTime);
if (start.getTime() >= end.getTime()) {
harmonics.setTimeSpan = (
startTime: Date | Temporal.Instant | number,
endTime: Date | Temporal.Instant | number,
): Harmonics => {
start = getInstant(startTime);
end = getInstant(endTime);
if (Temporal.Instant.compare(start, end) >= 0) {
throw new Error("Start time must be before end time");
}
return harmonics;
Expand All @@ -99,4 +109,4 @@ const harmonicsFactory = ({ harmonicConstituents, offset }: HarmonicsOptions): H
};

export default harmonicsFactory;
export { getDate, getTimeline };
export { getInstant, getTimeline };
Loading