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
6 changes: 6 additions & 0 deletions .changeset/free-pumas-occur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@neaps/tide-predictor": minor
"neaps": minor
---

Moved `useStation` into @neaps/tide-predictor so it can be used without the heavy dependency of @neaps/tide-database.
131 changes: 15 additions & 116 deletions packages/neaps/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,17 @@ import {
stations,
near,
nearest,
type Station,
type NearOptions,
type NearestOptions,
} from "@neaps/tide-database";
import tidePredictor, { type TimeSpan, type ExtremesInput } from "@neaps/tide-predictor";

type Units = "meters" | "feet";
type PredictionOptions = {
/** Datum to return predictions in. Defaults to 'MLLW' if available for the nearest station. */
datum?: string;

/** Units for returned water levels. Defaults to 'meters'. */
units?: Units;
};

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

const feetPerMeter = 3.2808399;
const defaultUnits: Units = "meters";
import {
useStation,
type Station,
type StationPredictor,
type StationExtremesOptions,
type StationTimelineOptions,
type StationWaterLevelOptions,
} from "@neaps/tide-predictor";

/**
* Get extremes prediction using the nearest station to the given position.
Expand All @@ -39,21 +29,21 @@ const defaultUnits: Units = "meters";
* datum: 'MLLW', // optional, defaults to MLLW if available
* })
*/
export function getExtremesPrediction(options: NearestOptions & ExtremesOptions) {
export function getExtremesPrediction(options: NearestOptions & StationExtremesOptions) {
return nearestStation(options).getExtremesPrediction(options);
}

/**
* Get timeline prediction using the nearest station to the given position.
*/
export function getTimelinePrediction(options: NearestOptions & TimelineOptions) {
export function getTimelinePrediction(options: NearestOptions & StationTimelineOptions) {
return nearestStation(options).getTimelinePrediction(options);
}

/**
* Get water level at a specific time using the nearest station to the given position.
*/
export function getWaterLevelAtTime(options: NearestOptions & WaterLevelOptions) {
export function getWaterLevelAtTime(options: NearestOptions & StationWaterLevelOptions) {
return nearestStation(options).getWaterLevelAtTime(options);
}

Expand All @@ -63,21 +53,21 @@ export function getWaterLevelAtTime(options: NearestOptions & WaterLevelOptions)
export function nearestStation(options: NearestOptions) {
const data = nearest(options);
if (!data) throw new Error(`No stations found with options: ${JSON.stringify(options)}`);
return useStation(...data);
return useStation(...data, findStation);
}

/**
* Find stations near the given position.
* @param limit Maximum number of stations to return (default: 10)
*/
export function stationsNear(options: NearOptions) {
return near(options).map(([station, distance]) => useStation(station, distance));
return near(options).map(([station, distance]) => useStation(station, distance, findStation));
}

/**
* Find a specific station by its ID or source ID.
*/
export function findStation(query: string) {
export function findStation(query: string): StationPredictor {
const searches = [(s: Station) => s.id === query, (s: Station) => s.source.id === query];

let found: Station | undefined = undefined;
Expand All @@ -89,96 +79,5 @@ export function findStation(query: string) {

if (!found) throw new Error(`Station not found: ${query}`);

return useStation(found);
}

export function useStation(station: Station, distance?: number) {
// If subordinate station, use the reference station for datums and constituents
let reference = station;
if (station.type === "subordinate" && station.offsets?.reference) {
reference = findStation(station.offsets?.reference);
}
const { datums, harmonic_constituents } = reference;

// Use MLLW as the default datum if available
const defaultDatum = "MLLW" in datums ? "MLLW" : undefined;

function getPredictor({ datum = defaultDatum }: PredictionOptions = {}) {
let offset = 0;

if (datum) {
const datumOffset = datums?.[datum];
const mslOffset = datums?.["MSL"];

if (typeof datumOffset !== "number") {
throw new Error(
`Station ${station.id} missing ${datum} datum. Available datums: ${Object.keys(datums).join(", ")}`,
);
}

if (typeof mslOffset !== "number") {
throw new Error(
`Station ${station.id} missing MSL datum, so predictions can't be given in ${datum}.`,
);
}

offset = mslOffset - datumOffset;
}

return tidePredictor(harmonic_constituents, { offset });
}

return {
...station,
distance,
datums,
harmonic_constituents,
defaultDatum,
getExtremesPrediction({
datum = defaultDatum,
units = defaultUnits,
...options
}: ExtremesOptions) {
const extremes = getPredictor({ datum })
.getExtremesPrediction({ ...options, offsets: station.offsets })
.map((e) => toPreferredUnits(e, units));

return { datum, units, station, distance, extremes };
},

getTimelinePrediction({
datum = defaultDatum,
units = defaultUnits,
...options
}: TimelineOptions) {
if (station.type === "subordinate") {
throw new Error(`Timeline predictions are not supported for subordinate stations.`);
}
const timeline = getPredictor({ datum })
.getTimelinePrediction(options)
.map((e) => toPreferredUnits(e, units));

return { datum, units, station, distance, timeline };
},

getWaterLevelAtTime({ time, datum = defaultDatum, units = defaultUnits }: WaterLevelOptions) {
if (station.type === "subordinate") {
throw new Error(`Water level predictions are not supported for subordinate stations.`);
}

const prediction = toPreferredUnits(
getPredictor({ datum }).getWaterLevelAtTime({ time }),
units,
);

return { datum, units, station, distance, ...prediction };
},
};
}

function toPreferredUnits<T extends { level: number }>(prediction: T, units: Units): T {
let { level } = prediction;
if (units === "feet") level *= feetPerMeter;
else if (units !== "meters") throw new Error(`Unsupported units: ${units}`);
return { ...prediction, level };
return useStation(found, undefined, findStation);
}
5 changes: 2 additions & 3 deletions packages/neaps/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
getExtremesPrediction,
nearestStation,
findStation,
useStation,
getTimelinePrediction,
getWaterLevelAtTime,
stationsNear,
Expand Down Expand Up @@ -357,7 +356,7 @@ describe("datum", () => {
);
if (!station) expect.fail("No station without MSL datum found");
expect(() => {
useStation(station).getExtremesPrediction({
findStation(station.id).getExtremesPrediction({
start: new Date("2025-12-17T00:00:00Z"),
end: new Date("2025-12-18T00:00:00Z"),
datum: Object.keys(station.datums)[0],
Expand All @@ -371,7 +370,7 @@ describe("datum", () => {
(s) => s.type === "reference" && Object.entries(s.datums).length === 0,
);
if (!station) expect.fail("No station without datums found");
const extremes = useStation(station).getExtremesPrediction({
const extremes = findStation(station.id).getExtremesPrediction({
start: new Date("2025-12-17T00:00:00Z"),
end: new Date("2025-12-18T00:00:00Z"),
});
Expand Down
1 change: 1 addition & 0 deletions packages/tide-predictor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,4 @@ tidePredictionFactory.constituents = constituents;

export default tidePredictionFactory;
export type { HarmonicConstituent, TimelinePoint, Extreme };
export * from "./station.js";
Loading