├── .eslintrc.cjs ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── dist ├── commonjs │ ├── index.js │ └── package.json └── web │ ├── tide-predictor.js │ └── tide-predictor.min.js ├── examples └── browser │ └── index.html ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── astronomy │ ├── coefficients.js │ ├── constants.js │ └── index.js ├── constituents │ ├── compound-constituent.js │ ├── constituent.js │ └── index.js ├── harmonics │ ├── index.js │ └── prediction.js ├── index.js └── node-corrections │ └── index.js └── test ├── _mocks ├── constituents.js ├── secondary-station.js └── water-levels.js ├── astronomy ├── coefficients.js └── index.js ├── constituents ├── compound-constituent.js ├── constituent.js └── index.js ├── harmonics ├── index.js └── prediction.js ├── index.js ├── lib └── close-to.js ├── noaa.js └── node-corrections └── index.js /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true 6 | }, 7 | extends: 'eslint:recommended', 8 | parserOptions: { 9 | ecmaVersion: 'latest', 10 | sourceType: 'module' 11 | }, 12 | rules: { 13 | indent: ['error', 2], 14 | 'linebreak-style': ['error', 'unix'], 15 | quotes: ['error', 'single'], 16 | semi: ['error', 'never'] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | - push 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | 11 | - name: Install modules 12 | run: npm install 13 | 14 | - name: Install codecov 15 | run: npm install -g codecov 16 | 17 | - name: Test 18 | run: npm run ci 19 | 20 | - name: Test build 21 | run: npm run build 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .local 3 | coverage 4 | junit.xml 5 | .test-cache 6 | .DS_Store 7 | .nyc_output -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "none" 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Fixed 11 | 12 | - The browser example was not working due to API changes. 13 | - The Readme file was using the wrong package name 14 | 15 | ## [0.1.1] - 2022-07-16 16 | 17 | ### Fix 18 | 19 | - A minor version bump for NPM 20 | 21 | ## [0.1.0] - 2022-07-16 22 | 23 | ### Added 24 | 25 | - Removing Babel 26 | - Using C8 and Mocha for native ES import testing 27 | - Remove Grunt in favor of some simpler scripts in package.json 28 | - Build a CJS module as part of the build process 29 | 30 | ## [0.0.3] - 2020-12-07 31 | 32 | ### Fixed 33 | 34 | - Updated URLs for NOAA's API for the testing service 35 | - Security update to developer dependencies 36 | 37 | ## [0.0.2] - 2019-11-14 38 | 39 | ### Added 40 | 41 | - Tidal extreme calculations are expensive, so you can now use the `timeFidelity` option to define how exact you want the high/low tide dates to be. 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Kevin Miller 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![example workflow](https://github.com/neaps/tide-predictor/actions/workflows/test.yml/badge.svg) [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fneaps%2Ftide-predictor.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fneaps%2Ftide-predictor?ref=badge_shield) [![codecov](https://codecov.io/gh/neaps/tide-predictor/branch/main/graph/badge.svg?token=KEJK5NQR5H)](https://codecov.io/gh/neaps/tide-predictor) 2 | 3 | # Tide predictor 4 | 5 | A Javascript tide harmonic calculator. 6 | 7 | 8 | 9 | ## 🚨Warning🚨 10 | 11 | **Do not use calculations from this project for navigation, or depend on them in any situation where inaccuracies could result in harm to a person or property.** 12 | 13 | Tide predictions are only as good as the harmonics data available, and these can be inconsistent and vary widely based on the accuracy of the source data and local conditions. 14 | 15 | The tide predictions do not factor events such as storm surge, wind waves, uplift, tsunamis, or sadly, climate change. 😢 16 | 17 | # Installation 18 | 19 | ``` 20 | #npm 21 | npm install @neaps/tide-predictor 22 | 23 | # yarn 24 | yarn add @neaps/tide-predictor 25 | 26 | ``` 27 | 28 | ## Importing 29 | 30 | You can import the module using Ecmascript, or CommonJS. Note that the CommonJS export is transpiled, so deep debugging the module that way will be difficult. 31 | 32 | ```js 33 | import TidePredictor from '@neaps/tide-predictor' 34 | const TidePredictor = require('@neaps/tide-predictor') 35 | ``` 36 | 37 | There are also packaged and minified versions for the browser in `dist/web`. 38 | 39 | # Usage 40 | 41 | Neaps requires that you [provide your own tidal harmonics information](#constituent-object) to generate a prediction. 42 | 43 | Because many constituent datum come with multiple phases (in the case of NOAA's data, they are `phase_local` and `phase_GMT`), there is a `phaseKey` option for choosing which to use. 44 | 45 | Note that, for now, Neaps **will not** do any timezone corrections. This means you need to pass date objects that align with whatever timezone the constituents are in. 46 | 47 | ```javascript 48 | import TidePredictor from '@neaps/tide-predictor' 49 | 50 | const constituents = [ 51 | { 52 | phase_GMT: 98.7, 53 | phase_local: 313.7, 54 | amplitude: 2.687, 55 | name: 'M2', 56 | speed: 28.984104 57 | } 58 | //....there are usually many, read the docs 59 | ] 60 | 61 | const highLowTides = TidePredictor(constituents, { 62 | phaseKey: 'phase_GMT' 63 | }).getExtremesPrediction({ 64 | start: new Date('2019-01-01'), 65 | end: new Date('2019-01-10') 66 | }) 67 | ``` 68 | 69 | ## Tide prediction object 70 | 71 | Calling `tidePredictor` will generate a new tide prediction object. It accepts the following arguments: 72 | 73 | - `constituents` - An array of [constituent objects](#constituent-object) 74 | - `options` - An object with one of: 75 | - `phaseKey` - The name of the parameter within constituents that is considered the "phase" 76 | - `offset` - A value to add to **all** values predicted. This is useful if you want to, for example, offset tides by mean high water, etc. 77 | 78 | ### Tide prediction methods 79 | 80 | The returned tide prediction object has various methods. All of these return regular JavaScript objects. 81 | 82 | #### High and low tide - `getExtremesPrediction` 83 | 84 | Returns the predicted high and low tides between a start and end date. 85 | 86 | ```javascript 87 | const startDate = new Date() 88 | const endDate = new Date(startDate + 3 * 24 * 60 * 60 * 1000) 89 | const tides = TidePredictor(constituents).getExtremesPrediction({ 90 | start: startDate, 91 | end: endDate, 92 | labels: { 93 | //optional human-readable labels 94 | high: 'High tide', 95 | low: 'Low tide' 96 | } 97 | }) 98 | ``` 99 | 100 | If you want predictions for a subservient station, first set the reference station in the prediction, and pass the [subservient station offests](#subservient-station) to the `getExtremesPrediction` method: 101 | 102 | ```javascript 103 | const tides = TidePredictor(constituents).getExtremesPrediction({ 104 | start: startDate, 105 | end: endDate, 106 | offset: { 107 | height_offset: { 108 | high: 1, 109 | low: 2 110 | }, 111 | time_offset: { 112 | high: 1, 113 | low: 2 114 | } 115 | } 116 | }) 117 | ``` 118 | 119 | ##### Options 120 | 121 | The `getExtremesPrediction` accepts a single object with options: 122 | 123 | - `start` - **Required ** - The date & time to start looking for high and low tides 124 | - `end` - **Required ** - The date & time to stop looking for high and low tides 125 | - `timeFidelity` - Number of seconds accurate the time should be, defaults to 10 minutes. 126 | - `labels` - An object to define the human-readable labels for the tides 127 | - `high` - The human-readable label for high tides 128 | - `low` - The human-readable label for low tides 129 | - `offset` - The offset values if these predictions are for a [subservient station](#subservient-station) 130 | 131 | ##### Return values 132 | 133 | High and low tides are returned as arrays of objects: 134 | 135 | - `time` - A Javascript Date object of the time 136 | - `level` - The water level 137 | - `high` - **true** if this is a high tide, **false** if not 138 | - `low` - **true** if this is a low tide, **false** if not 139 | - `label` - The human-readable label (by default, 'High' or 'Low') 140 | 141 | #### Water level at time - `getWaterLevelAtTime` 142 | 143 | Gives you the predicted water level at a specific time. 144 | 145 | ```javascript 146 | const waterLevel = TidePredictor(constituents).getWaterLevelAtTime({ 147 | time: new Date() 148 | }) 149 | ``` 150 | 151 | ##### Options 152 | 153 | The `getWaterLevelAtTime` accepts a single object of options: 154 | 155 | - `time` - A Javascript date object of the time for the prediction 156 | 157 | ##### Return values 158 | 159 | A single object is returned with: 160 | 161 | - `time` - A Javascript date object 162 | - `level` - The predicted water level 163 | 164 | ## Data definitions 165 | 166 | ### Constituent definition 167 | 168 | Tidal constituents should be an array of objects with at least: 169 | 170 | - `name` - **string** - The NOAA constituent name, all upper-case. 171 | - `amplitude` - **float** - The constituent amplitude 172 | - `[phase]` - **float** - The phase of the constituent. Because several services provide different phase values, you can choose which one to use when building your tide prediction. 173 | 174 | ``` 175 | [ 176 | { 177 | name: '[constituent name]', 178 | amplitude: 1.3, 179 | phase: 1.33 180 | }, 181 | { 182 | name: '[constituent name 2]', 183 | amplitude: 1.3, 184 | phase: 1.33 185 | } 186 | ] 187 | ``` 188 | 189 | ### Subservient station definitions 190 | 191 | Some stations do not have defined harmonic data, but do have published offets and a reference station. These include the offsets in time or amplitude of the high and low tides. Subservient station definitions are objects that include: 192 | 193 | - `height_offset` - **object** - An object of height offets, in the same units as the reference station. 194 | - `high` - **float** - The offset to be added to high tide (can be negative) 195 | - `low` - **float** - The offset to be added to low tide (can be negative) 196 | - `time_offset` - **object** - An object of time offets, in number of minutes 197 | - `high` - **float** - The number of minutes to add to high tide times (can be negative) 198 | - `low` - **float** - The number of minutes to add to low tide times (can be negative) 199 | 200 | ``` 201 | { 202 | height_offset: { 203 | high: 1, 204 | low: 2 205 | }, 206 | time_offset: { 207 | high: 1, 208 | low: 2 209 | } 210 | } 211 | ``` 212 | 213 | # Shout out 214 | 215 | All the really hard math is based on the excellent [Xtide](https://flaterco.com/xtide) and [pytides](https://github.com/sam-cox/pytides). 216 | -------------------------------------------------------------------------------- /dist/commonjs/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const d2r = Math.PI / 180.0; 4 | const r2d = 180.0 / Math.PI; 5 | 6 | // Convert a sexagesimal angle into decimal degrees 7 | const sexagesimalToDecimal = (degrees, arcmins, arcsecs, mas, muas) => { 8 | arcmins = typeof arcmins !== 'undefined' ? arcmins : 0; 9 | arcsecs = typeof arcsecs !== 'undefined' ? arcsecs : 0; 10 | mas = typeof mas !== 'undefined' ? mas : 0; 11 | muas = typeof muas !== 'undefined' ? muas : 0; 12 | 13 | return ( 14 | degrees + 15 | arcmins / 60.0 + 16 | arcsecs / (60.0 * 60.0) + 17 | mas / (60.0 * 60.0 * 1e3) + 18 | muas / (60.0 * 60.0 * 1e6) 19 | ) 20 | }; 21 | 22 | const coefficients = { 23 | // Meeus formula 21.3 24 | terrestrialObliquity: [ 25 | sexagesimalToDecimal(23, 26, 21.448), 26 | -sexagesimalToDecimal(0, 0, 4680.93), 27 | -sexagesimalToDecimal(0, 0, 1.55), 28 | sexagesimalToDecimal(0, 0, 1999.25), 29 | -sexagesimalToDecimal(0, 0, 51.38), 30 | -sexagesimalToDecimal(0, 0, 249.67), 31 | -sexagesimalToDecimal(0, 0, 39.05), 32 | sexagesimalToDecimal(0, 0, 7.12), 33 | sexagesimalToDecimal(0, 0, 27.87), 34 | sexagesimalToDecimal(0, 0, 5.79), 35 | sexagesimalToDecimal(0, 0, 2.45), 36 | ].map((number, index) => { 37 | return number * Math.pow(1e-2, index) 38 | }), 39 | 40 | solarPerigee: [ 41 | 280.46645 - 357.5291, 42 | 36000.76932 - 35999.0503, 43 | 0.0003032 + 0.0001559, 44 | 0.00000048, 45 | ], 46 | 47 | solarLongitude: [280.46645, 36000.76983, 0.0003032], 48 | 49 | lunarInclination: [5.145], 50 | 51 | lunarLongitude: [ 52 | 218.3164591, 53 | 481267.88134236, 54 | -0.0013268, 55 | 1 / 538841.0 - 1 / 65194000.0, 56 | ], 57 | 58 | lunarNode: [ 59 | 125.044555, 60 | -1934.1361849, 61 | 0.0020762, 62 | 1 / 467410.0, 63 | -1 / 60616000.0, 64 | ], 65 | 66 | lunarPerigee: [ 67 | 83.353243, 68 | 4069.0137111, 69 | -0.0103238, 70 | -1 / 80053.0, 71 | 1 / 18999000.0, 72 | ], 73 | }; 74 | 75 | // Evaluates a polynomial at argument 76 | const polynomial = (coefficients, argument) => { 77 | const result = []; 78 | coefficients.forEach((coefficient, index) => { 79 | result.push(coefficient * Math.pow(argument, index)); 80 | }); 81 | return result.reduce((a, b) => { 82 | return a + b 83 | }) 84 | }; 85 | 86 | // Evaluates a derivative polynomial at argument 87 | const derivativePolynomial = (coefficients, argument) => { 88 | const result = []; 89 | coefficients.forEach((coefficient, index) => { 90 | result.push(coefficient * index * Math.pow(argument, index - 1)); 91 | }); 92 | return result.reduce((a, b) => { 93 | return a + b 94 | }) 95 | }; 96 | 97 | // Meeus formula 11.1 98 | const T = (t) => { 99 | return (JD(t) - 2451545.0) / 36525 100 | }; 101 | 102 | // Meeus formula 7.1 103 | const JD = (t) => { 104 | let Y = t.getFullYear(); 105 | let M = t.getMonth() + 1; 106 | const D = 107 | t.getDate() + 108 | t.getHours() / 24.0 + 109 | t.getMinutes() / (24.0 * 60.0) + 110 | t.getSeconds() / (24.0 * 60.0 * 60.0) + 111 | t.getMilliseconds() / (24.0 * 60.0 * 60.0 * 1e6); 112 | if (M <= 2) { 113 | Y = Y - 1; 114 | M = M + 12; 115 | } 116 | const A = Math.floor(Y / 100.0); 117 | const B = 2 - A + Math.floor(A / 4.0); 118 | return ( 119 | Math.floor(365.25 * (Y + 4716)) + 120 | Math.floor(30.6001 * (M + 1)) + 121 | D + 122 | B - 123 | 1524.5 124 | ) 125 | }; 126 | 127 | /** 128 | * @todo - What's with the array returned from the arccos? 129 | * @param {*} N 130 | * @param {*} i 131 | * @param {*} omega 132 | */ 133 | const _I = (N, i, omega) => { 134 | N = d2r * N; 135 | i = d2r * i; 136 | omega = d2r * omega; 137 | const cosI = 138 | Math.cos(i) * Math.cos(omega) - Math.sin(i) * Math.sin(omega) * Math.cos(N); 139 | return r2d * Math.acos(cosI) 140 | }; 141 | 142 | const _xi = (N, i, omega) => { 143 | N = d2r * N; 144 | i = d2r * i; 145 | omega = d2r * omega; 146 | let e1 = 147 | (Math.cos(0.5 * (omega - i)) / Math.cos(0.5 * (omega + i))) * 148 | Math.tan(0.5 * N); 149 | let e2 = 150 | (Math.sin(0.5 * (omega - i)) / Math.sin(0.5 * (omega + i))) * 151 | Math.tan(0.5 * N); 152 | e1 = Math.atan(e1); 153 | e2 = Math.atan(e2); 154 | e1 = e1 - 0.5 * N; 155 | e2 = e2 - 0.5 * N; 156 | return -(e1 + e2) * r2d 157 | }; 158 | 159 | const _nu = (N, i, omega) => { 160 | N = d2r * N; 161 | i = d2r * i; 162 | omega = d2r * omega; 163 | let e1 = 164 | (Math.cos(0.5 * (omega - i)) / Math.cos(0.5 * (omega + i))) * 165 | Math.tan(0.5 * N); 166 | let e2 = 167 | (Math.sin(0.5 * (omega - i)) / Math.sin(0.5 * (omega + i))) * 168 | Math.tan(0.5 * N); 169 | e1 = Math.atan(e1); 170 | e2 = Math.atan(e2); 171 | e1 = e1 - 0.5 * N; 172 | e2 = e2 - 0.5 * N; 173 | return (e1 - e2) * r2d 174 | }; 175 | 176 | // Schureman equation 224 177 | const _nup = (N, i, omega) => { 178 | const I = d2r * _I(N, i, omega); 179 | const nu = d2r * _nu(N, i, omega); 180 | return ( 181 | r2d * 182 | Math.atan( 183 | (Math.sin(2 * I) * Math.sin(nu)) / 184 | (Math.sin(2 * I) * Math.cos(nu) + 0.3347) 185 | ) 186 | ) 187 | }; 188 | 189 | // Schureman equation 232 190 | const _nupp = (N, i, omega) => { 191 | const I = d2r * _I(N, i, omega); 192 | const nu = d2r * _nu(N, i, omega); 193 | const tan2nupp = 194 | (Math.sin(I) ** 2 * Math.sin(2 * nu)) / 195 | (Math.sin(I) ** 2 * Math.cos(2 * nu) + 0.0727); 196 | return r2d * 0.5 * Math.atan(tan2nupp) 197 | }; 198 | 199 | const modulus$1 = (a, b) => { 200 | return ((a % b) + b) % b 201 | }; 202 | 203 | const astro = (time) => { 204 | const result = {}; 205 | const polynomials = { 206 | s: coefficients.lunarLongitude, 207 | h: coefficients.solarLongitude, 208 | p: coefficients.lunarPerigee, 209 | N: coefficients.lunarNode, 210 | pp: coefficients.solarPerigee, 211 | 90: [90.0], 212 | omega: coefficients.terrestrialObliquity, 213 | i: coefficients.lunarInclination 214 | }; 215 | 216 | // Polynomials are in T, that is Julian Centuries; we want our speeds to be 217 | // in the more convenient unit of degrees per hour. 218 | const dTdHour = 1 / (24 * 365.25 * 100); 219 | Object.keys(polynomials).forEach((name) => { 220 | result[name] = { 221 | value: modulus$1(polynomial(polynomials[name], T(time)), 360.0), 222 | speed: derivativePolynomial(polynomials[name], T(time)) * dTdHour 223 | }; 224 | }); 225 | 226 | // Some other parameters defined by Schureman which are dependent on the 227 | // parameters N, i, omega for use in node factor calculations. We don't need 228 | // their speeds. 229 | const functions = { 230 | I: _I, 231 | xi: _xi, 232 | nu: _nu, 233 | nup: _nup, 234 | nupp: _nupp 235 | }; 236 | Object.keys(functions).forEach((name) => { 237 | const functionCall = functions[name]; 238 | result[name] = { 239 | value: modulus$1( 240 | functionCall(result.N.value, result.i.value, result.omega.value), 241 | 360.0 242 | ), 243 | speed: null 244 | }; 245 | }); 246 | 247 | // We don't work directly with the T (hours) parameter, instead our spanning 248 | // set for equilibrium arguments #is given by T+h-s, s, h, p, N, pp, 90. 249 | // This is in line with convention. 250 | const hour = { 251 | value: (JD(time) - Math.floor(JD(time))) * 360.0, 252 | speed: 15.0 253 | }; 254 | 255 | result['T+h-s'] = { 256 | value: hour.value + result.h.value - result.s.value, 257 | speed: hour.speed + result.h.speed - result.s.speed 258 | }; 259 | 260 | // It is convenient to calculate Schureman's P here since several node 261 | // factors need it, although it could be argued that these 262 | // (along with I, xi, nu etc) belong somewhere else. 263 | result.P = { 264 | value: result.p.value - (result.xi.value % 360.0), 265 | speed: null 266 | }; 267 | 268 | return result 269 | }; 270 | 271 | const modulus = (a, b) => { 272 | return ((a % b) + b) % b 273 | }; 274 | 275 | const addExtremesOffsets = (extreme, offsets) => { 276 | if (typeof offsets === 'undefined' || !offsets) { 277 | return extreme 278 | } 279 | if (extreme.high && offsets.height_offset && offsets.height_offset.high) { 280 | extreme.level *= offsets.height_offset.high; 281 | } 282 | if (extreme.low && offsets.height_offset && offsets.height_offset.low) { 283 | extreme.level *= offsets.height_offset.low; 284 | } 285 | if (extreme.high && offsets.time_offset && offsets.time_offset.high) { 286 | extreme.time = new Date( 287 | extreme.time.getTime() + offsets.time_offset.high * 60 * 1000 288 | ); 289 | } 290 | if (extreme.low && offsets.time_offset && offsets.time_offset.low) { 291 | extreme.time = new Date( 292 | extreme.time.getTime() + offsets.time_offset.low * 60 * 1000 293 | ); 294 | } 295 | return extreme 296 | }; 297 | 298 | const getExtremeLabel = (label, highLowLabels) => { 299 | if ( 300 | typeof highLowLabels !== 'undefined' && 301 | typeof highLowLabels[label] !== 'undefined' 302 | ) { 303 | return highLowLabels[label] 304 | } 305 | const labels = { 306 | high: 'High', 307 | low: 'Low' 308 | }; 309 | return labels[label] 310 | }; 311 | 312 | const predictionFactory = ({ timeline, constituents, start }) => { 313 | const getLevel = (hour, modelBaseSpeed, modelU, modelF, modelBaseValue) => { 314 | const amplitudes = []; 315 | let result = 0; 316 | 317 | constituents.forEach((constituent) => { 318 | const amplitude = constituent.amplitude; 319 | const phase = constituent._phase; 320 | const f = modelF[constituent.name]; 321 | const speed = modelBaseSpeed[constituent.name]; 322 | const u = modelU[constituent.name]; 323 | const V0 = modelBaseValue[constituent.name]; 324 | amplitudes.push(amplitude * f * Math.cos(speed * hour + (V0 + u) - phase)); 325 | }); 326 | // sum up each row 327 | amplitudes.forEach((item) => { 328 | result += item; 329 | }); 330 | return result 331 | }; 332 | 333 | const prediction = {}; 334 | 335 | prediction.getExtremesPrediction = (options) => { 336 | const { labels, offsets } = typeof options !== 'undefined' ? options : {}; 337 | const results = []; 338 | const { baseSpeed, u, f, baseValue } = prepare(); 339 | let goingUp = false; 340 | let goingDown = false; 341 | let lastLevel = getLevel(0, baseSpeed, u[0], f[0], baseValue); 342 | timeline.items.forEach((time, index) => { 343 | const hour = timeline.hours[index]; 344 | const level = getLevel(hour, baseSpeed, u[index], f[index], baseValue); 345 | // Compare this level to the last one, if we 346 | // are changing angle, then the last one was high or low 347 | if (level > lastLevel && goingDown) { 348 | results.push( 349 | addExtremesOffsets( 350 | { 351 | time: timeline.items[index - 1], 352 | level: lastLevel, 353 | high: false, 354 | low: true, 355 | label: getExtremeLabel('low', labels) 356 | }, 357 | offsets 358 | ) 359 | ); 360 | } 361 | if (level < lastLevel && goingUp) { 362 | results.push( 363 | addExtremesOffsets( 364 | { 365 | time: timeline.items[index - 1], 366 | level: lastLevel, 367 | high: true, 368 | low: false, 369 | label: getExtremeLabel('high', labels) 370 | }, 371 | offsets 372 | ) 373 | ); 374 | } 375 | if (level > lastLevel) { 376 | goingUp = true; 377 | goingDown = false; 378 | } 379 | if (level < lastLevel) { 380 | goingUp = false; 381 | goingDown = true; 382 | } 383 | lastLevel = level; 384 | }); 385 | return results 386 | }; 387 | 388 | prediction.getTimelinePrediction = () => { 389 | const results = []; 390 | const { baseSpeed, u, f, baseValue } = prepare(); 391 | timeline.items.forEach((time, index) => { 392 | const hour = timeline.hours[index]; 393 | const prediction = { 394 | time, 395 | hour, 396 | level: getLevel(hour, baseSpeed, u[index], f[index], baseValue) 397 | }; 398 | 399 | results.push(prediction); 400 | }); 401 | return results 402 | }; 403 | 404 | const prepare = () => { 405 | const baseAstro = astro(start); 406 | 407 | const baseValue = {}; 408 | const baseSpeed = {}; 409 | const u = []; 410 | const f = []; 411 | constituents.forEach((constituent) => { 412 | const value = constituent._model.value(baseAstro); 413 | const speed = constituent._model.speed(baseAstro); 414 | baseValue[constituent.name] = d2r * value; 415 | baseSpeed[constituent.name] = d2r * speed; 416 | }); 417 | timeline.items.forEach((time) => { 418 | const uItem = {}; 419 | const fItem = {}; 420 | const itemAstro = astro(time); 421 | constituents.forEach((constituent) => { 422 | const constituentU = modulus(constituent._model.u(itemAstro), 360); 423 | 424 | uItem[constituent.name] = d2r * constituentU; 425 | fItem[constituent.name] = modulus(constituent._model.f(itemAstro), 360); 426 | }); 427 | u.push(uItem); 428 | f.push(fItem); 429 | }); 430 | 431 | return { 432 | baseValue, 433 | baseSpeed, 434 | u, 435 | f 436 | } 437 | }; 438 | 439 | return Object.freeze(prediction) 440 | }; 441 | 442 | const corrections = { 443 | fUnity() { 444 | return 1 445 | }, 446 | 447 | // Schureman equations 73, 65 448 | fMm(a) { 449 | const omega = d2r * a.omega.value; 450 | const i = d2r * a.i.value; 451 | const I = d2r * a.I.value; 452 | const mean = 453 | (2 / 3.0 - Math.pow(Math.sin(omega), 2)) * 454 | (1 - (3 / 2.0) * Math.pow(Math.sin(i), 2)); 455 | return (2 / 3.0 - Math.pow(Math.sin(I), 2)) / mean 456 | }, 457 | 458 | // Schureman equations 74, 66 459 | fMf(a) { 460 | const omega = d2r * a.omega.value; 461 | const i = d2r * a.i.value; 462 | const I = d2r * a.I.value; 463 | const mean = Math.pow(Math.sin(omega), 2) * Math.pow(Math.cos(0.5 * i), 4); 464 | return Math.pow(Math.sin(I), 2) / mean 465 | }, 466 | 467 | // Schureman equations 75, 67 468 | fO1(a) { 469 | const omega = d2r * a.omega.value; 470 | const i = d2r * a.i.value; 471 | const I = d2r * a.I.value; 472 | const mean = 473 | Math.sin(omega) * 474 | Math.pow(Math.cos(0.5 * omega), 2) * 475 | Math.pow(Math.cos(0.5 * i), 4); 476 | return (Math.sin(I) * Math.pow(Math.cos(0.5 * I), 2)) / mean 477 | }, 478 | 479 | // Schureman equations 76, 68 480 | fJ1(a) { 481 | const omega = d2r * a.omega.value; 482 | const i = d2r * a.i.value; 483 | const I = d2r * a.I.value; 484 | const mean = 485 | Math.sin(2 * omega) * (1 - (3 / 2.0) * Math.pow(Math.sin(i), 2)); 486 | return Math.sin(2 * I) / mean 487 | }, 488 | 489 | // Schureman equations 77, 69 490 | fOO1(a) { 491 | const omega = d2r * a.omega.value; 492 | const i = d2r * a.i.value; 493 | const I = d2r * a.I.value; 494 | const mean = 495 | Math.sin(omega) * 496 | Math.pow(Math.sin(0.5 * omega), 2) * 497 | Math.pow(Math.cos(0.5 * i), 4); 498 | return (Math.sin(I) * Math.pow(Math.sin(0.5 * I), 2)) / mean 499 | }, 500 | 501 | // Schureman equations 78, 70 502 | fM2(a) { 503 | const omega = d2r * a.omega.value; 504 | const i = d2r * a.i.value; 505 | const I = d2r * a.I.value; 506 | const mean = 507 | Math.pow(Math.cos(0.5 * omega), 4) * Math.pow(Math.cos(0.5 * i), 4); 508 | return Math.pow(Math.cos(0.5 * I), 4) / mean 509 | }, 510 | 511 | // Schureman equations 227, 226, 68 512 | // Should probably eventually include the derivations of the magic numbers (0.5023 etc). 513 | fK1(a) { 514 | const omega = d2r * a.omega.value; 515 | const i = d2r * a.i.value; 516 | const I = d2r * a.I.value; 517 | const nu = d2r * a.nu.value; 518 | const sin2IcosnuMean = 519 | Math.sin(2 * omega) * (1 - (3 / 2.0) * Math.pow(Math.sin(i), 2)); 520 | const mean = 0.5023 * sin2IcosnuMean + 0.1681; 521 | return ( 522 | Math.pow( 523 | 0.2523 * Math.pow(Math.sin(2 * I), 2) + 524 | 0.1689 * Math.sin(2 * I) * Math.cos(nu) + 525 | 0.0283, 526 | 0.5 527 | ) / mean 528 | ) 529 | }, 530 | 531 | // Schureman equations 215, 213, 204 532 | // It can be (and has been) confirmed that the exponent for R_a reads 1/2 via Schureman Table 7 533 | fL2(a) { 534 | const P = d2r * a.P.value; 535 | const I = d2r * a.I.value; 536 | const rAInv = Math.pow( 537 | 1 - 538 | 12 * Math.pow(Math.tan(0.5 * I), 2) * Math.cos(2 * P) + 539 | 36 * Math.pow(Math.tan(0.5 * I), 4), 540 | 0.5 541 | ); 542 | return corrections.fM2(a) * rAInv 543 | }, 544 | 545 | // Schureman equations 235, 234, 71 546 | // Again, magic numbers 547 | fK2(a) { 548 | const omega = d2r * a.omega.value; 549 | const i = d2r * a.i.value; 550 | const I = d2r * a.I.value; 551 | const nu = d2r * a.nu.value; 552 | const sinsqIcos2nuMean = 553 | Math.sin(omega) ** 2 * (1 - (3 / 2.0) * Math.sin(i) ** 2); 554 | const mean = 0.5023 * sinsqIcos2nuMean + 0.0365; 555 | return ( 556 | Math.pow( 557 | 0.2523 * Math.pow(Math.sin(I), 4) + 558 | 0.0367 * Math.pow(Math.sin(I), 2) * Math.cos(2 * nu) + 559 | 0.0013, 560 | 0.5 561 | ) / mean 562 | ) 563 | }, 564 | // Schureman equations 206, 207, 195 565 | fM1(a) { 566 | const P = d2r * a.P.value; 567 | const I = d2r * a.I.value; 568 | const qAInv = Math.pow( 569 | 0.25 + 570 | 1.5 * 571 | Math.cos(I) * 572 | Math.cos(2 * P) * 573 | Math.pow(Math.cos(0.5 * I), -0.5) + 574 | 2.25 * Math.pow(Math.cos(I), 2) * Math.pow(Math.cos(0.5 * I), -4), 575 | 0.5 576 | ); 577 | return corrections.fO1(a) * qAInv 578 | }, 579 | 580 | // See e.g. Schureman equation 149 581 | fModd(a, n) { 582 | return Math.pow(corrections.fM2(a), n / 2.0) 583 | }, 584 | 585 | // Node factors u, see Table 2 of Schureman. 586 | 587 | uZero() { 588 | return 0.0 589 | }, 590 | 591 | uMf(a) { 592 | return -2.0 * a.xi.value 593 | }, 594 | 595 | uO1(a) { 596 | return 2.0 * a.xi.value - a.nu.value 597 | }, 598 | 599 | uJ1(a) { 600 | return -a.nu.value 601 | }, 602 | 603 | uOO1(a) { 604 | return -2.0 * a.xi.value - a.nu.value 605 | }, 606 | 607 | uM2(a) { 608 | return 2.0 * a.xi.value - 2.0 * a.nu.value 609 | }, 610 | 611 | uK1(a) { 612 | return -a.nup.value 613 | }, 614 | 615 | // Schureman 214 616 | uL2(a) { 617 | const I = d2r * a.I.value; 618 | const P = d2r * a.P.value; 619 | const R = 620 | r2d * 621 | Math.atan( 622 | Math.sin(2 * P) / 623 | ((1 / 6.0) * Math.pow(Math.tan(0.5 * I), -2) - Math.cos(2 * P)) 624 | ); 625 | return 2.0 * a.xi.value - 2.0 * a.nu.value - R 626 | }, 627 | 628 | uK2(a) { 629 | return -2.0 * a.nupp.value 630 | }, 631 | 632 | // Schureman 202 633 | uM1(a) { 634 | const I = d2r * a.I.value; 635 | const P = d2r * a.P.value; 636 | const Q = 637 | r2d * 638 | Math.atan(((5 * Math.cos(I) - 1) / (7 * Math.cos(I) + 1)) * Math.tan(P)); 639 | return a.xi.value - a.nu.value + Q 640 | }, 641 | 642 | uModd(a, n) { 643 | return (n / 2.0) * corrections.uM2(a) 644 | } 645 | }; 646 | 647 | /** 648 | * Computes the dot notation of two arrays 649 | * @param {*} a 650 | * @param {*} b 651 | */ 652 | const dotArray = (a, b) => { 653 | const results = []; 654 | a.forEach((value, index) => { 655 | results.push(value * b[index]); 656 | }); 657 | return results.reduce((total, value) => { 658 | return total + value 659 | }) 660 | }; 661 | 662 | const astronimicDoodsonNumber = (astro) => { 663 | return [ 664 | astro['T+h-s'], 665 | astro.s, 666 | astro.h, 667 | astro.p, 668 | astro.N, 669 | astro.pp, 670 | astro['90'] 671 | ] 672 | }; 673 | 674 | const astronomicSpeed = (astro) => { 675 | const results = []; 676 | astronimicDoodsonNumber(astro).forEach((number) => { 677 | results.push(number.speed); 678 | }); 679 | return results 680 | }; 681 | 682 | const astronomicValues = (astro) => { 683 | const results = []; 684 | astronimicDoodsonNumber(astro).forEach((number) => { 685 | results.push(number.value); 686 | }); 687 | return results 688 | }; 689 | 690 | const constituentFactory = (name, coefficients, u, f) => { 691 | if (!coefficients) { 692 | throw new Error('Coefficient must be defined for a constituent') 693 | } 694 | 695 | const constituent = { 696 | name, 697 | coefficients, 698 | 699 | value: (astro) => { 700 | return dotArray(coefficients, astronomicValues(astro)) 701 | }, 702 | 703 | speed(astro) { 704 | return dotArray(coefficients, astronomicSpeed(astro)) 705 | }, 706 | 707 | u: typeof u !== 'undefined' ? u : corrections.uZero, 708 | 709 | f: typeof f !== 'undefined' ? f : corrections.fUnity 710 | }; 711 | 712 | return Object.freeze(constituent) 713 | }; 714 | 715 | const compoundConstituentFactory = (name, members) => { 716 | const coefficients = []; 717 | members.forEach(({ constituent, factor }) => { 718 | constituent.coefficients.forEach((coefficient, index) => { 719 | if (typeof coefficients[index] === 'undefined') { 720 | coefficients[index] = 0; 721 | } 722 | coefficients[index] += coefficient * factor; 723 | }); 724 | }); 725 | 726 | const compoundConstituent = { 727 | name, 728 | coefficients, 729 | 730 | speed: (astro) => { 731 | let speed = 0; 732 | members.forEach(({ constituent, factor }) => { 733 | speed += constituent.speed(astro) * factor; 734 | }); 735 | return speed 736 | }, 737 | 738 | value: (astro) => { 739 | let value = 0; 740 | members.forEach(({ constituent, factor }) => { 741 | value += constituent.value(astro) * factor; 742 | }); 743 | return value 744 | }, 745 | 746 | u: (astro) => { 747 | let u = 0; 748 | members.forEach(({ constituent, factor }) => { 749 | u += constituent.u(astro) * factor; 750 | }); 751 | return u 752 | }, 753 | 754 | f: (astro) => { 755 | const f = []; 756 | members.forEach(({ constituent, factor }) => { 757 | f.push(Math.pow(constituent.f(astro), Math.abs(factor))); 758 | }); 759 | return f.reduce((previous, value) => { 760 | return previous * value 761 | }) 762 | } 763 | }; 764 | 765 | return Object.freeze(compoundConstituent) 766 | }; 767 | 768 | const constituents = {}; 769 | // Long Term 770 | constituents.Z0 = constituentFactory('Z0', [0, 0, 0, 0, 0, 0, 0], corrections.uZero, corrections.fUnity); 771 | constituents.SA = constituentFactory('Sa', [0, 0, 1, 0, 0, 0, 0], corrections.uZero, corrections.fUnity); 772 | constituents.SSA = constituentFactory( 773 | 'Ssa', 774 | [0, 0, 2, 0, 0, 0, 0], 775 | corrections.uZero, 776 | corrections.fUnity 777 | ); 778 | constituents.MM = constituentFactory('MM', [0, 1, 0, -1, 0, 0, 0], corrections.uZero, corrections.fMm); 779 | constituents.MF = constituentFactory('MF', [0, 2, 0, 0, 0, 0, 0], corrections.uMf, corrections.fMf); 780 | // Diurnals 781 | constituents.Q1 = constituentFactory('Q1', [1, -2, 0, 1, 0, 0, 1], corrections.uO1, corrections.fO1); 782 | constituents.O1 = constituentFactory('O1', [1, -1, 0, 0, 0, 0, 1], corrections.uO1, corrections.fO1); 783 | constituents.K1 = constituentFactory('K1', [1, 1, 0, 0, 0, 0, -1], corrections.uK1, corrections.fK1); 784 | constituents.J1 = constituentFactory('J1', [1, 2, 0, -1, 0, 0, -1], corrections.uJ1, corrections.fJ1); 785 | constituents.M1 = constituentFactory('M1', [1, 0, 0, 0, 0, 0, 1], corrections.uM1, corrections.fM1); 786 | constituents.P1 = constituentFactory('P1', [1, 1, -2, 0, 0, 0, 1], corrections.uZero, corrections.fUnity); 787 | constituents.S1 = constituentFactory('S1', [1, 1, -1, 0, 0, 0, 0], corrections.uZero, corrections.fUnity); 788 | constituents.OO1 = constituentFactory('OO1', [1, 3, 0, 0, 0, 0, -1], corrections.uOO1, corrections.fOO1); 789 | // Semi diurnals 790 | constituents['2N2'] = constituentFactory('2N2', [2, -2, 0, 2, 0, 0, 0], corrections.uM2, corrections.fM2); 791 | constituents.N2 = constituentFactory('N2', [2, -1, 0, 1, 0, 0, 0], corrections.uM2, corrections.fM2); 792 | constituents.NU2 = constituentFactory('NU2', [2, -1, 2, -1, 0, 0, 0], corrections.uM2, corrections.fM2); 793 | constituents.M2 = constituentFactory('M2', [2, 0, 0, 0, 0, 0, 0], corrections.uM2, corrections.fM2); 794 | constituents.LAM2 = constituentFactory('LAM2', [2, 1, -2, 1, 0, 0, 2], corrections.uM2, corrections.fM2); 795 | constituents.L2 = constituentFactory('L2', [2, 1, 0, -1, 0, 0, 2], corrections.uL2, corrections.fL2); 796 | constituents.T2 = constituentFactory('T2', [2, 2, -3, 0, 0, 1, 0], corrections.uZero, corrections.fUnity); 797 | constituents.S2 = constituentFactory('S2', [2, 2, -2, 0, 0, 0, 0], corrections.uZero, corrections.fUnity); 798 | constituents.R2 = constituentFactory( 799 | 'R2', 800 | [2, 2, -1, 0, 0, -1, 2], 801 | corrections.uZero, 802 | corrections.fUnity 803 | ); 804 | constituents.K2 = constituentFactory('K2', [2, 2, 0, 0, 0, 0, 0], corrections.uK2, corrections.fK2); 805 | // Third diurnal 806 | constituents.M3 = constituentFactory( 807 | 'M3', 808 | [3, 0, 0, 0, 0, 0, 0], 809 | (a) => { 810 | return corrections.uModd(a, 3) 811 | }, 812 | (a) => { 813 | return corrections.fModd(a, 3) 814 | } 815 | ); 816 | // Compound 817 | constituents.MSF = compoundConstituentFactory('MSF', [ 818 | { constituent: constituents.S2, factor: 1 }, 819 | { constituent: constituents.M2, factor: -1 } 820 | ]); 821 | 822 | // Diurnal 823 | constituents['2Q1'] = compoundConstituentFactory('2Q1', [ 824 | { constituent: constituents.N2, factor: 1 }, 825 | { constituent: constituents.J1, factor: -1 } 826 | ]); 827 | constituents.RHO = compoundConstituentFactory('RHO', [ 828 | { constituent: constituents.NU2, factor: 1 }, 829 | { constituent: constituents.K1, factor: -1 } 830 | ]); 831 | 832 | // Semi-Diurnal 833 | 834 | constituents.MU2 = compoundConstituentFactory('MU2', [ 835 | { constituent: constituents.M2, factor: 2 }, 836 | { constituent: constituents.S2, factor: -1 } 837 | ]); 838 | constituents['2SM2'] = compoundConstituentFactory('2SM2', [ 839 | { constituent: constituents.S2, factor: 2 }, 840 | { constituent: constituents.M2, factor: -1 } 841 | ]); 842 | 843 | // Third-Diurnal 844 | constituents['2MK3'] = compoundConstituentFactory('2MK3', [ 845 | { constituent: constituents.M2, factor: 1 }, 846 | { constituent: constituents.O1, factor: 1 } 847 | ]); 848 | constituents.MK3 = compoundConstituentFactory('MK3', [ 849 | { constituent: constituents.M2, factor: 1 }, 850 | { constituent: constituents.K1, factor: 1 } 851 | ]); 852 | 853 | // Quarter-Diurnal 854 | constituents.MN4 = compoundConstituentFactory('MN4', [ 855 | { constituent: constituents.M2, factor: 1 }, 856 | { constituent: constituents.N2, factor: 1 } 857 | ]); 858 | constituents.M4 = compoundConstituentFactory('M4', [ 859 | { constituent: constituents.M2, factor: 2 } 860 | ]); 861 | constituents.MS4 = compoundConstituentFactory('MS4', [ 862 | { constituent: constituents.M2, factor: 1 }, 863 | { constituent: constituents.S2, factor: 1 } 864 | ]); 865 | constituents.S4 = compoundConstituentFactory('S4', [ 866 | { constituent: constituents.S2, factor: 2 } 867 | ]); 868 | 869 | // Sixth-Diurnal 870 | constituents.M6 = compoundConstituentFactory('M6', [ 871 | { constituent: constituents.M2, factor: 3 } 872 | ]); 873 | constituents.S6 = compoundConstituentFactory('S6', [ 874 | { constituent: constituents.S2, factor: 3 } 875 | ]); 876 | 877 | // Eighth-Diurnals 878 | constituents.M8 = compoundConstituentFactory('M8', [ 879 | { constituent: constituents.M2, factor: 4 } 880 | ]); 881 | 882 | const getDate = (time) => { 883 | if (time instanceof Date) { 884 | return time 885 | } 886 | if (typeof time === 'number') { 887 | return new Date(time * 1000) 888 | } 889 | throw new Error('Invalid date format, should be a Date object, or timestamp') 890 | }; 891 | 892 | const getTimeline = (start, end, seconds) => { 893 | seconds = typeof seconds !== 'undefined' ? seconds : 10 * 60; 894 | const items = []; 895 | const endTime = end.getTime() / 1000; 896 | let lastTime = start.getTime() / 1000; 897 | const startTime = lastTime; 898 | const hours = []; 899 | while (lastTime <= endTime) { 900 | items.push(new Date(lastTime * 1000)); 901 | hours.push((lastTime - startTime) / (60 * 60)); 902 | lastTime += seconds; 903 | } 904 | 905 | return { 906 | items, 907 | hours 908 | } 909 | }; 910 | 911 | const harmonicsFactory = ({ harmonicConstituents, phaseKey, offset }) => { 912 | if (!Array.isArray(harmonicConstituents)) { 913 | throw new Error('Harmonic constituents are not an array') 914 | } 915 | const constituents$1 = []; 916 | harmonicConstituents.forEach((constituent) => { 917 | if (typeof constituent.name === 'undefined') { 918 | throw new Error('Harmonic constituents must have a name property') 919 | } 920 | if (typeof constituents[constituent.name] !== 'undefined') { 921 | constituent._model = constituents[constituent.name]; 922 | constituent._phase = d2r * constituent[phaseKey]; 923 | constituents$1.push(constituent); 924 | } 925 | }); 926 | 927 | if (offset !== false) { 928 | constituents$1.push({ 929 | name: 'Z0', 930 | _model: constituents.Z0, 931 | _phase: 0, 932 | amplitude: offset 933 | }); 934 | } 935 | 936 | let start = new Date(); 937 | let end = new Date(); 938 | 939 | const harmonics = {}; 940 | 941 | harmonics.setTimeSpan = (startTime, endTime) => { 942 | start = getDate(startTime); 943 | end = getDate(endTime); 944 | if (start.getTime() >= end.getTime()) { 945 | throw new Error('Start time must be before end time') 946 | } 947 | return harmonics 948 | }; 949 | 950 | harmonics.prediction = (options) => { 951 | options = 952 | typeof options !== 'undefined' ? options : { timeFidelity: 10 * 60 }; 953 | return predictionFactory({ 954 | timeline: getTimeline(start, end, options.timeFidelity), 955 | constituents: constituents$1, 956 | start 957 | }) 958 | }; 959 | 960 | return Object.freeze(harmonics) 961 | }; 962 | 963 | const tidePredictionFactory = (constituents, options) => { 964 | const harmonicsOptions = { 965 | harmonicConstituents: constituents, 966 | phaseKey: 'phase_GMT', 967 | offset: false 968 | }; 969 | 970 | if (typeof options !== 'undefined') { 971 | Object.keys(harmonicsOptions).forEach((key) => { 972 | if (typeof options[key] !== 'undefined') { 973 | harmonicsOptions[key] = options[key]; 974 | } 975 | }); 976 | } 977 | 978 | const tidePrediction = { 979 | getTimelinePrediction: ({ start, end }) => { 980 | return harmonicsFactory(harmonicsOptions) 981 | .setTimeSpan(start, end) 982 | .prediction() 983 | .getTimelinePrediction() 984 | }, 985 | 986 | getExtremesPrediction: ({ start, end, labels, offsets, timeFidelity }) => { 987 | return harmonicsFactory(harmonicsOptions) 988 | .setTimeSpan(start, end) 989 | .prediction({ timeFidelity }) 990 | .getExtremesPrediction(labels, offsets) 991 | }, 992 | 993 | getWaterLevelAtTime: ({ time }) => { 994 | const endDate = new Date(time.getTime() + 10 * 60 * 1000); 995 | return harmonicsFactory(harmonicsOptions) 996 | .setTimeSpan(time, endDate) 997 | .prediction() 998 | .getTimelinePrediction()[0] 999 | } 1000 | }; 1001 | 1002 | return tidePrediction 1003 | }; 1004 | 1005 | module.exports = tidePredictionFactory; 1006 | -------------------------------------------------------------------------------- /dist/commonjs/package.json: -------------------------------------------------------------------------------- 1 | {"type":"commonjs"} 2 | -------------------------------------------------------------------------------- /dist/web/tide-predictor.js: -------------------------------------------------------------------------------- 1 | (function (global, factory) { 2 | typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : 3 | typeof define === 'function' && define.amd ? define(factory) : 4 | (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.tidePredictor = factory()); 5 | })(this, (function () { 'use strict'; 6 | 7 | const d2r = Math.PI / 180.0; 8 | const r2d = 180.0 / Math.PI; 9 | 10 | // Convert a sexagesimal angle into decimal degrees 11 | const sexagesimalToDecimal = (degrees, arcmins, arcsecs, mas, muas) => { 12 | arcmins = typeof arcmins !== 'undefined' ? arcmins : 0; 13 | arcsecs = typeof arcsecs !== 'undefined' ? arcsecs : 0; 14 | mas = typeof mas !== 'undefined' ? mas : 0; 15 | muas = typeof muas !== 'undefined' ? muas : 0; 16 | 17 | return ( 18 | degrees + 19 | arcmins / 60.0 + 20 | arcsecs / (60.0 * 60.0) + 21 | mas / (60.0 * 60.0 * 1e3) + 22 | muas / (60.0 * 60.0 * 1e6) 23 | ) 24 | }; 25 | 26 | const coefficients = { 27 | // Meeus formula 21.3 28 | terrestrialObliquity: [ 29 | sexagesimalToDecimal(23, 26, 21.448), 30 | -sexagesimalToDecimal(0, 0, 4680.93), 31 | -sexagesimalToDecimal(0, 0, 1.55), 32 | sexagesimalToDecimal(0, 0, 1999.25), 33 | -sexagesimalToDecimal(0, 0, 51.38), 34 | -sexagesimalToDecimal(0, 0, 249.67), 35 | -sexagesimalToDecimal(0, 0, 39.05), 36 | sexagesimalToDecimal(0, 0, 7.12), 37 | sexagesimalToDecimal(0, 0, 27.87), 38 | sexagesimalToDecimal(0, 0, 5.79), 39 | sexagesimalToDecimal(0, 0, 2.45), 40 | ].map((number, index) => { 41 | return number * Math.pow(1e-2, index) 42 | }), 43 | 44 | solarPerigee: [ 45 | 280.46645 - 357.5291, 46 | 36000.76932 - 35999.0503, 47 | 0.0003032 + 0.0001559, 48 | 0.00000048, 49 | ], 50 | 51 | solarLongitude: [280.46645, 36000.76983, 0.0003032], 52 | 53 | lunarInclination: [5.145], 54 | 55 | lunarLongitude: [ 56 | 218.3164591, 57 | 481267.88134236, 58 | -0.0013268, 59 | 1 / 538841.0 - 1 / 65194000.0, 60 | ], 61 | 62 | lunarNode: [ 63 | 125.044555, 64 | -1934.1361849, 65 | 0.0020762, 66 | 1 / 467410.0, 67 | -1 / 60616000.0, 68 | ], 69 | 70 | lunarPerigee: [ 71 | 83.353243, 72 | 4069.0137111, 73 | -0.0103238, 74 | -1 / 80053.0, 75 | 1 / 18999000.0, 76 | ], 77 | }; 78 | 79 | // Evaluates a polynomial at argument 80 | const polynomial = (coefficients, argument) => { 81 | const result = []; 82 | coefficients.forEach((coefficient, index) => { 83 | result.push(coefficient * Math.pow(argument, index)); 84 | }); 85 | return result.reduce((a, b) => { 86 | return a + b 87 | }) 88 | }; 89 | 90 | // Evaluates a derivative polynomial at argument 91 | const derivativePolynomial = (coefficients, argument) => { 92 | const result = []; 93 | coefficients.forEach((coefficient, index) => { 94 | result.push(coefficient * index * Math.pow(argument, index - 1)); 95 | }); 96 | return result.reduce((a, b) => { 97 | return a + b 98 | }) 99 | }; 100 | 101 | // Meeus formula 11.1 102 | const T = (t) => { 103 | return (JD(t) - 2451545.0) / 36525 104 | }; 105 | 106 | // Meeus formula 7.1 107 | const JD = (t) => { 108 | let Y = t.getFullYear(); 109 | let M = t.getMonth() + 1; 110 | const D = 111 | t.getDate() + 112 | t.getHours() / 24.0 + 113 | t.getMinutes() / (24.0 * 60.0) + 114 | t.getSeconds() / (24.0 * 60.0 * 60.0) + 115 | t.getMilliseconds() / (24.0 * 60.0 * 60.0 * 1e6); 116 | if (M <= 2) { 117 | Y = Y - 1; 118 | M = M + 12; 119 | } 120 | const A = Math.floor(Y / 100.0); 121 | const B = 2 - A + Math.floor(A / 4.0); 122 | return ( 123 | Math.floor(365.25 * (Y + 4716)) + 124 | Math.floor(30.6001 * (M + 1)) + 125 | D + 126 | B - 127 | 1524.5 128 | ) 129 | }; 130 | 131 | /** 132 | * @todo - What's with the array returned from the arccos? 133 | * @param {*} N 134 | * @param {*} i 135 | * @param {*} omega 136 | */ 137 | const _I = (N, i, omega) => { 138 | N = d2r * N; 139 | i = d2r * i; 140 | omega = d2r * omega; 141 | const cosI = 142 | Math.cos(i) * Math.cos(omega) - Math.sin(i) * Math.sin(omega) * Math.cos(N); 143 | return r2d * Math.acos(cosI) 144 | }; 145 | 146 | const _xi = (N, i, omega) => { 147 | N = d2r * N; 148 | i = d2r * i; 149 | omega = d2r * omega; 150 | let e1 = 151 | (Math.cos(0.5 * (omega - i)) / Math.cos(0.5 * (omega + i))) * 152 | Math.tan(0.5 * N); 153 | let e2 = 154 | (Math.sin(0.5 * (omega - i)) / Math.sin(0.5 * (omega + i))) * 155 | Math.tan(0.5 * N); 156 | e1 = Math.atan(e1); 157 | e2 = Math.atan(e2); 158 | e1 = e1 - 0.5 * N; 159 | e2 = e2 - 0.5 * N; 160 | return -(e1 + e2) * r2d 161 | }; 162 | 163 | const _nu = (N, i, omega) => { 164 | N = d2r * N; 165 | i = d2r * i; 166 | omega = d2r * omega; 167 | let e1 = 168 | (Math.cos(0.5 * (omega - i)) / Math.cos(0.5 * (omega + i))) * 169 | Math.tan(0.5 * N); 170 | let e2 = 171 | (Math.sin(0.5 * (omega - i)) / Math.sin(0.5 * (omega + i))) * 172 | Math.tan(0.5 * N); 173 | e1 = Math.atan(e1); 174 | e2 = Math.atan(e2); 175 | e1 = e1 - 0.5 * N; 176 | e2 = e2 - 0.5 * N; 177 | return (e1 - e2) * r2d 178 | }; 179 | 180 | // Schureman equation 224 181 | const _nup = (N, i, omega) => { 182 | const I = d2r * _I(N, i, omega); 183 | const nu = d2r * _nu(N, i, omega); 184 | return ( 185 | r2d * 186 | Math.atan( 187 | (Math.sin(2 * I) * Math.sin(nu)) / 188 | (Math.sin(2 * I) * Math.cos(nu) + 0.3347) 189 | ) 190 | ) 191 | }; 192 | 193 | // Schureman equation 232 194 | const _nupp = (N, i, omega) => { 195 | const I = d2r * _I(N, i, omega); 196 | const nu = d2r * _nu(N, i, omega); 197 | const tan2nupp = 198 | (Math.sin(I) ** 2 * Math.sin(2 * nu)) / 199 | (Math.sin(I) ** 2 * Math.cos(2 * nu) + 0.0727); 200 | return r2d * 0.5 * Math.atan(tan2nupp) 201 | }; 202 | 203 | const modulus$1 = (a, b) => { 204 | return ((a % b) + b) % b 205 | }; 206 | 207 | const astro = (time) => { 208 | const result = {}; 209 | const polynomials = { 210 | s: coefficients.lunarLongitude, 211 | h: coefficients.solarLongitude, 212 | p: coefficients.lunarPerigee, 213 | N: coefficients.lunarNode, 214 | pp: coefficients.solarPerigee, 215 | 90: [90.0], 216 | omega: coefficients.terrestrialObliquity, 217 | i: coefficients.lunarInclination 218 | }; 219 | 220 | // Polynomials are in T, that is Julian Centuries; we want our speeds to be 221 | // in the more convenient unit of degrees per hour. 222 | const dTdHour = 1 / (24 * 365.25 * 100); 223 | Object.keys(polynomials).forEach((name) => { 224 | result[name] = { 225 | value: modulus$1(polynomial(polynomials[name], T(time)), 360.0), 226 | speed: derivativePolynomial(polynomials[name], T(time)) * dTdHour 227 | }; 228 | }); 229 | 230 | // Some other parameters defined by Schureman which are dependent on the 231 | // parameters N, i, omega for use in node factor calculations. We don't need 232 | // their speeds. 233 | const functions = { 234 | I: _I, 235 | xi: _xi, 236 | nu: _nu, 237 | nup: _nup, 238 | nupp: _nupp 239 | }; 240 | Object.keys(functions).forEach((name) => { 241 | const functionCall = functions[name]; 242 | result[name] = { 243 | value: modulus$1( 244 | functionCall(result.N.value, result.i.value, result.omega.value), 245 | 360.0 246 | ), 247 | speed: null 248 | }; 249 | }); 250 | 251 | // We don't work directly with the T (hours) parameter, instead our spanning 252 | // set for equilibrium arguments #is given by T+h-s, s, h, p, N, pp, 90. 253 | // This is in line with convention. 254 | const hour = { 255 | value: (JD(time) - Math.floor(JD(time))) * 360.0, 256 | speed: 15.0 257 | }; 258 | 259 | result['T+h-s'] = { 260 | value: hour.value + result.h.value - result.s.value, 261 | speed: hour.speed + result.h.speed - result.s.speed 262 | }; 263 | 264 | // It is convenient to calculate Schureman's P here since several node 265 | // factors need it, although it could be argued that these 266 | // (along with I, xi, nu etc) belong somewhere else. 267 | result.P = { 268 | value: result.p.value - (result.xi.value % 360.0), 269 | speed: null 270 | }; 271 | 272 | return result 273 | }; 274 | 275 | const modulus = (a, b) => { 276 | return ((a % b) + b) % b 277 | }; 278 | 279 | const addExtremesOffsets = (extreme, offsets) => { 280 | if (typeof offsets === 'undefined' || !offsets) { 281 | return extreme 282 | } 283 | if (extreme.high && offsets.height_offset && offsets.height_offset.high) { 284 | extreme.level *= offsets.height_offset.high; 285 | } 286 | if (extreme.low && offsets.height_offset && offsets.height_offset.low) { 287 | extreme.level *= offsets.height_offset.low; 288 | } 289 | if (extreme.high && offsets.time_offset && offsets.time_offset.high) { 290 | extreme.time = new Date( 291 | extreme.time.getTime() + offsets.time_offset.high * 60 * 1000 292 | ); 293 | } 294 | if (extreme.low && offsets.time_offset && offsets.time_offset.low) { 295 | extreme.time = new Date( 296 | extreme.time.getTime() + offsets.time_offset.low * 60 * 1000 297 | ); 298 | } 299 | return extreme 300 | }; 301 | 302 | const getExtremeLabel = (label, highLowLabels) => { 303 | if ( 304 | typeof highLowLabels !== 'undefined' && 305 | typeof highLowLabels[label] !== 'undefined' 306 | ) { 307 | return highLowLabels[label] 308 | } 309 | const labels = { 310 | high: 'High', 311 | low: 'Low' 312 | }; 313 | return labels[label] 314 | }; 315 | 316 | const predictionFactory = ({ timeline, constituents, start }) => { 317 | const getLevel = (hour, modelBaseSpeed, modelU, modelF, modelBaseValue) => { 318 | const amplitudes = []; 319 | let result = 0; 320 | 321 | constituents.forEach((constituent) => { 322 | const amplitude = constituent.amplitude; 323 | const phase = constituent._phase; 324 | const f = modelF[constituent.name]; 325 | const speed = modelBaseSpeed[constituent.name]; 326 | const u = modelU[constituent.name]; 327 | const V0 = modelBaseValue[constituent.name]; 328 | amplitudes.push(amplitude * f * Math.cos(speed * hour + (V0 + u) - phase)); 329 | }); 330 | // sum up each row 331 | amplitudes.forEach((item) => { 332 | result += item; 333 | }); 334 | return result 335 | }; 336 | 337 | const prediction = {}; 338 | 339 | prediction.getExtremesPrediction = (options) => { 340 | const { labels, offsets } = typeof options !== 'undefined' ? options : {}; 341 | const results = []; 342 | const { baseSpeed, u, f, baseValue } = prepare(); 343 | let goingUp = false; 344 | let goingDown = false; 345 | let lastLevel = getLevel(0, baseSpeed, u[0], f[0], baseValue); 346 | timeline.items.forEach((time, index) => { 347 | const hour = timeline.hours[index]; 348 | const level = getLevel(hour, baseSpeed, u[index], f[index], baseValue); 349 | // Compare this level to the last one, if we 350 | // are changing angle, then the last one was high or low 351 | if (level > lastLevel && goingDown) { 352 | results.push( 353 | addExtremesOffsets( 354 | { 355 | time: timeline.items[index - 1], 356 | level: lastLevel, 357 | high: false, 358 | low: true, 359 | label: getExtremeLabel('low', labels) 360 | }, 361 | offsets 362 | ) 363 | ); 364 | } 365 | if (level < lastLevel && goingUp) { 366 | results.push( 367 | addExtremesOffsets( 368 | { 369 | time: timeline.items[index - 1], 370 | level: lastLevel, 371 | high: true, 372 | low: false, 373 | label: getExtremeLabel('high', labels) 374 | }, 375 | offsets 376 | ) 377 | ); 378 | } 379 | if (level > lastLevel) { 380 | goingUp = true; 381 | goingDown = false; 382 | } 383 | if (level < lastLevel) { 384 | goingUp = false; 385 | goingDown = true; 386 | } 387 | lastLevel = level; 388 | }); 389 | return results 390 | }; 391 | 392 | prediction.getTimelinePrediction = () => { 393 | const results = []; 394 | const { baseSpeed, u, f, baseValue } = prepare(); 395 | timeline.items.forEach((time, index) => { 396 | const hour = timeline.hours[index]; 397 | const prediction = { 398 | time, 399 | hour, 400 | level: getLevel(hour, baseSpeed, u[index], f[index], baseValue) 401 | }; 402 | 403 | results.push(prediction); 404 | }); 405 | return results 406 | }; 407 | 408 | const prepare = () => { 409 | const baseAstro = astro(start); 410 | 411 | const baseValue = {}; 412 | const baseSpeed = {}; 413 | const u = []; 414 | const f = []; 415 | constituents.forEach((constituent) => { 416 | const value = constituent._model.value(baseAstro); 417 | const speed = constituent._model.speed(baseAstro); 418 | baseValue[constituent.name] = d2r * value; 419 | baseSpeed[constituent.name] = d2r * speed; 420 | }); 421 | timeline.items.forEach((time) => { 422 | const uItem = {}; 423 | const fItem = {}; 424 | const itemAstro = astro(time); 425 | constituents.forEach((constituent) => { 426 | const constituentU = modulus(constituent._model.u(itemAstro), 360); 427 | 428 | uItem[constituent.name] = d2r * constituentU; 429 | fItem[constituent.name] = modulus(constituent._model.f(itemAstro), 360); 430 | }); 431 | u.push(uItem); 432 | f.push(fItem); 433 | }); 434 | 435 | return { 436 | baseValue, 437 | baseSpeed, 438 | u, 439 | f 440 | } 441 | }; 442 | 443 | return Object.freeze(prediction) 444 | }; 445 | 446 | const corrections = { 447 | fUnity() { 448 | return 1 449 | }, 450 | 451 | // Schureman equations 73, 65 452 | fMm(a) { 453 | const omega = d2r * a.omega.value; 454 | const i = d2r * a.i.value; 455 | const I = d2r * a.I.value; 456 | const mean = 457 | (2 / 3.0 - Math.pow(Math.sin(omega), 2)) * 458 | (1 - (3 / 2.0) * Math.pow(Math.sin(i), 2)); 459 | return (2 / 3.0 - Math.pow(Math.sin(I), 2)) / mean 460 | }, 461 | 462 | // Schureman equations 74, 66 463 | fMf(a) { 464 | const omega = d2r * a.omega.value; 465 | const i = d2r * a.i.value; 466 | const I = d2r * a.I.value; 467 | const mean = Math.pow(Math.sin(omega), 2) * Math.pow(Math.cos(0.5 * i), 4); 468 | return Math.pow(Math.sin(I), 2) / mean 469 | }, 470 | 471 | // Schureman equations 75, 67 472 | fO1(a) { 473 | const omega = d2r * a.omega.value; 474 | const i = d2r * a.i.value; 475 | const I = d2r * a.I.value; 476 | const mean = 477 | Math.sin(omega) * 478 | Math.pow(Math.cos(0.5 * omega), 2) * 479 | Math.pow(Math.cos(0.5 * i), 4); 480 | return (Math.sin(I) * Math.pow(Math.cos(0.5 * I), 2)) / mean 481 | }, 482 | 483 | // Schureman equations 76, 68 484 | fJ1(a) { 485 | const omega = d2r * a.omega.value; 486 | const i = d2r * a.i.value; 487 | const I = d2r * a.I.value; 488 | const mean = 489 | Math.sin(2 * omega) * (1 - (3 / 2.0) * Math.pow(Math.sin(i), 2)); 490 | return Math.sin(2 * I) / mean 491 | }, 492 | 493 | // Schureman equations 77, 69 494 | fOO1(a) { 495 | const omega = d2r * a.omega.value; 496 | const i = d2r * a.i.value; 497 | const I = d2r * a.I.value; 498 | const mean = 499 | Math.sin(omega) * 500 | Math.pow(Math.sin(0.5 * omega), 2) * 501 | Math.pow(Math.cos(0.5 * i), 4); 502 | return (Math.sin(I) * Math.pow(Math.sin(0.5 * I), 2)) / mean 503 | }, 504 | 505 | // Schureman equations 78, 70 506 | fM2(a) { 507 | const omega = d2r * a.omega.value; 508 | const i = d2r * a.i.value; 509 | const I = d2r * a.I.value; 510 | const mean = 511 | Math.pow(Math.cos(0.5 * omega), 4) * Math.pow(Math.cos(0.5 * i), 4); 512 | return Math.pow(Math.cos(0.5 * I), 4) / mean 513 | }, 514 | 515 | // Schureman equations 227, 226, 68 516 | // Should probably eventually include the derivations of the magic numbers (0.5023 etc). 517 | fK1(a) { 518 | const omega = d2r * a.omega.value; 519 | const i = d2r * a.i.value; 520 | const I = d2r * a.I.value; 521 | const nu = d2r * a.nu.value; 522 | const sin2IcosnuMean = 523 | Math.sin(2 * omega) * (1 - (3 / 2.0) * Math.pow(Math.sin(i), 2)); 524 | const mean = 0.5023 * sin2IcosnuMean + 0.1681; 525 | return ( 526 | Math.pow( 527 | 0.2523 * Math.pow(Math.sin(2 * I), 2) + 528 | 0.1689 * Math.sin(2 * I) * Math.cos(nu) + 529 | 0.0283, 530 | 0.5 531 | ) / mean 532 | ) 533 | }, 534 | 535 | // Schureman equations 215, 213, 204 536 | // It can be (and has been) confirmed that the exponent for R_a reads 1/2 via Schureman Table 7 537 | fL2(a) { 538 | const P = d2r * a.P.value; 539 | const I = d2r * a.I.value; 540 | const rAInv = Math.pow( 541 | 1 - 542 | 12 * Math.pow(Math.tan(0.5 * I), 2) * Math.cos(2 * P) + 543 | 36 * Math.pow(Math.tan(0.5 * I), 4), 544 | 0.5 545 | ); 546 | return corrections.fM2(a) * rAInv 547 | }, 548 | 549 | // Schureman equations 235, 234, 71 550 | // Again, magic numbers 551 | fK2(a) { 552 | const omega = d2r * a.omega.value; 553 | const i = d2r * a.i.value; 554 | const I = d2r * a.I.value; 555 | const nu = d2r * a.nu.value; 556 | const sinsqIcos2nuMean = 557 | Math.sin(omega) ** 2 * (1 - (3 / 2.0) * Math.sin(i) ** 2); 558 | const mean = 0.5023 * sinsqIcos2nuMean + 0.0365; 559 | return ( 560 | Math.pow( 561 | 0.2523 * Math.pow(Math.sin(I), 4) + 562 | 0.0367 * Math.pow(Math.sin(I), 2) * Math.cos(2 * nu) + 563 | 0.0013, 564 | 0.5 565 | ) / mean 566 | ) 567 | }, 568 | // Schureman equations 206, 207, 195 569 | fM1(a) { 570 | const P = d2r * a.P.value; 571 | const I = d2r * a.I.value; 572 | const qAInv = Math.pow( 573 | 0.25 + 574 | 1.5 * 575 | Math.cos(I) * 576 | Math.cos(2 * P) * 577 | Math.pow(Math.cos(0.5 * I), -0.5) + 578 | 2.25 * Math.pow(Math.cos(I), 2) * Math.pow(Math.cos(0.5 * I), -4), 579 | 0.5 580 | ); 581 | return corrections.fO1(a) * qAInv 582 | }, 583 | 584 | // See e.g. Schureman equation 149 585 | fModd(a, n) { 586 | return Math.pow(corrections.fM2(a), n / 2.0) 587 | }, 588 | 589 | // Node factors u, see Table 2 of Schureman. 590 | 591 | uZero() { 592 | return 0.0 593 | }, 594 | 595 | uMf(a) { 596 | return -2.0 * a.xi.value 597 | }, 598 | 599 | uO1(a) { 600 | return 2.0 * a.xi.value - a.nu.value 601 | }, 602 | 603 | uJ1(a) { 604 | return -a.nu.value 605 | }, 606 | 607 | uOO1(a) { 608 | return -2.0 * a.xi.value - a.nu.value 609 | }, 610 | 611 | uM2(a) { 612 | return 2.0 * a.xi.value - 2.0 * a.nu.value 613 | }, 614 | 615 | uK1(a) { 616 | return -a.nup.value 617 | }, 618 | 619 | // Schureman 214 620 | uL2(a) { 621 | const I = d2r * a.I.value; 622 | const P = d2r * a.P.value; 623 | const R = 624 | r2d * 625 | Math.atan( 626 | Math.sin(2 * P) / 627 | ((1 / 6.0) * Math.pow(Math.tan(0.5 * I), -2) - Math.cos(2 * P)) 628 | ); 629 | return 2.0 * a.xi.value - 2.0 * a.nu.value - R 630 | }, 631 | 632 | uK2(a) { 633 | return -2.0 * a.nupp.value 634 | }, 635 | 636 | // Schureman 202 637 | uM1(a) { 638 | const I = d2r * a.I.value; 639 | const P = d2r * a.P.value; 640 | const Q = 641 | r2d * 642 | Math.atan(((5 * Math.cos(I) - 1) / (7 * Math.cos(I) + 1)) * Math.tan(P)); 643 | return a.xi.value - a.nu.value + Q 644 | }, 645 | 646 | uModd(a, n) { 647 | return (n / 2.0) * corrections.uM2(a) 648 | } 649 | }; 650 | 651 | /** 652 | * Computes the dot notation of two arrays 653 | * @param {*} a 654 | * @param {*} b 655 | */ 656 | const dotArray = (a, b) => { 657 | const results = []; 658 | a.forEach((value, index) => { 659 | results.push(value * b[index]); 660 | }); 661 | return results.reduce((total, value) => { 662 | return total + value 663 | }) 664 | }; 665 | 666 | const astronimicDoodsonNumber = (astro) => { 667 | return [ 668 | astro['T+h-s'], 669 | astro.s, 670 | astro.h, 671 | astro.p, 672 | astro.N, 673 | astro.pp, 674 | astro['90'] 675 | ] 676 | }; 677 | 678 | const astronomicSpeed = (astro) => { 679 | const results = []; 680 | astronimicDoodsonNumber(astro).forEach((number) => { 681 | results.push(number.speed); 682 | }); 683 | return results 684 | }; 685 | 686 | const astronomicValues = (astro) => { 687 | const results = []; 688 | astronimicDoodsonNumber(astro).forEach((number) => { 689 | results.push(number.value); 690 | }); 691 | return results 692 | }; 693 | 694 | const constituentFactory = (name, coefficients, u, f) => { 695 | if (!coefficients) { 696 | throw new Error('Coefficient must be defined for a constituent') 697 | } 698 | 699 | const constituent = { 700 | name, 701 | coefficients, 702 | 703 | value: (astro) => { 704 | return dotArray(coefficients, astronomicValues(astro)) 705 | }, 706 | 707 | speed(astro) { 708 | return dotArray(coefficients, astronomicSpeed(astro)) 709 | }, 710 | 711 | u: typeof u !== 'undefined' ? u : corrections.uZero, 712 | 713 | f: typeof f !== 'undefined' ? f : corrections.fUnity 714 | }; 715 | 716 | return Object.freeze(constituent) 717 | }; 718 | 719 | const compoundConstituentFactory = (name, members) => { 720 | const coefficients = []; 721 | members.forEach(({ constituent, factor }) => { 722 | constituent.coefficients.forEach((coefficient, index) => { 723 | if (typeof coefficients[index] === 'undefined') { 724 | coefficients[index] = 0; 725 | } 726 | coefficients[index] += coefficient * factor; 727 | }); 728 | }); 729 | 730 | const compoundConstituent = { 731 | name, 732 | coefficients, 733 | 734 | speed: (astro) => { 735 | let speed = 0; 736 | members.forEach(({ constituent, factor }) => { 737 | speed += constituent.speed(astro) * factor; 738 | }); 739 | return speed 740 | }, 741 | 742 | value: (astro) => { 743 | let value = 0; 744 | members.forEach(({ constituent, factor }) => { 745 | value += constituent.value(astro) * factor; 746 | }); 747 | return value 748 | }, 749 | 750 | u: (astro) => { 751 | let u = 0; 752 | members.forEach(({ constituent, factor }) => { 753 | u += constituent.u(astro) * factor; 754 | }); 755 | return u 756 | }, 757 | 758 | f: (astro) => { 759 | const f = []; 760 | members.forEach(({ constituent, factor }) => { 761 | f.push(Math.pow(constituent.f(astro), Math.abs(factor))); 762 | }); 763 | return f.reduce((previous, value) => { 764 | return previous * value 765 | }) 766 | } 767 | }; 768 | 769 | return Object.freeze(compoundConstituent) 770 | }; 771 | 772 | const constituents = {}; 773 | // Long Term 774 | constituents.Z0 = constituentFactory('Z0', [0, 0, 0, 0, 0, 0, 0], corrections.uZero, corrections.fUnity); 775 | constituents.SA = constituentFactory('Sa', [0, 0, 1, 0, 0, 0, 0], corrections.uZero, corrections.fUnity); 776 | constituents.SSA = constituentFactory( 777 | 'Ssa', 778 | [0, 0, 2, 0, 0, 0, 0], 779 | corrections.uZero, 780 | corrections.fUnity 781 | ); 782 | constituents.MM = constituentFactory('MM', [0, 1, 0, -1, 0, 0, 0], corrections.uZero, corrections.fMm); 783 | constituents.MF = constituentFactory('MF', [0, 2, 0, 0, 0, 0, 0], corrections.uMf, corrections.fMf); 784 | // Diurnals 785 | constituents.Q1 = constituentFactory('Q1', [1, -2, 0, 1, 0, 0, 1], corrections.uO1, corrections.fO1); 786 | constituents.O1 = constituentFactory('O1', [1, -1, 0, 0, 0, 0, 1], corrections.uO1, corrections.fO1); 787 | constituents.K1 = constituentFactory('K1', [1, 1, 0, 0, 0, 0, -1], corrections.uK1, corrections.fK1); 788 | constituents.J1 = constituentFactory('J1', [1, 2, 0, -1, 0, 0, -1], corrections.uJ1, corrections.fJ1); 789 | constituents.M1 = constituentFactory('M1', [1, 0, 0, 0, 0, 0, 1], corrections.uM1, corrections.fM1); 790 | constituents.P1 = constituentFactory('P1', [1, 1, -2, 0, 0, 0, 1], corrections.uZero, corrections.fUnity); 791 | constituents.S1 = constituentFactory('S1', [1, 1, -1, 0, 0, 0, 0], corrections.uZero, corrections.fUnity); 792 | constituents.OO1 = constituentFactory('OO1', [1, 3, 0, 0, 0, 0, -1], corrections.uOO1, corrections.fOO1); 793 | // Semi diurnals 794 | constituents['2N2'] = constituentFactory('2N2', [2, -2, 0, 2, 0, 0, 0], corrections.uM2, corrections.fM2); 795 | constituents.N2 = constituentFactory('N2', [2, -1, 0, 1, 0, 0, 0], corrections.uM2, corrections.fM2); 796 | constituents.NU2 = constituentFactory('NU2', [2, -1, 2, -1, 0, 0, 0], corrections.uM2, corrections.fM2); 797 | constituents.M2 = constituentFactory('M2', [2, 0, 0, 0, 0, 0, 0], corrections.uM2, corrections.fM2); 798 | constituents.LAM2 = constituentFactory('LAM2', [2, 1, -2, 1, 0, 0, 2], corrections.uM2, corrections.fM2); 799 | constituents.L2 = constituentFactory('L2', [2, 1, 0, -1, 0, 0, 2], corrections.uL2, corrections.fL2); 800 | constituents.T2 = constituentFactory('T2', [2, 2, -3, 0, 0, 1, 0], corrections.uZero, corrections.fUnity); 801 | constituents.S2 = constituentFactory('S2', [2, 2, -2, 0, 0, 0, 0], corrections.uZero, corrections.fUnity); 802 | constituents.R2 = constituentFactory( 803 | 'R2', 804 | [2, 2, -1, 0, 0, -1, 2], 805 | corrections.uZero, 806 | corrections.fUnity 807 | ); 808 | constituents.K2 = constituentFactory('K2', [2, 2, 0, 0, 0, 0, 0], corrections.uK2, corrections.fK2); 809 | // Third diurnal 810 | constituents.M3 = constituentFactory( 811 | 'M3', 812 | [3, 0, 0, 0, 0, 0, 0], 813 | (a) => { 814 | return corrections.uModd(a, 3) 815 | }, 816 | (a) => { 817 | return corrections.fModd(a, 3) 818 | } 819 | ); 820 | // Compound 821 | constituents.MSF = compoundConstituentFactory('MSF', [ 822 | { constituent: constituents.S2, factor: 1 }, 823 | { constituent: constituents.M2, factor: -1 } 824 | ]); 825 | 826 | // Diurnal 827 | constituents['2Q1'] = compoundConstituentFactory('2Q1', [ 828 | { constituent: constituents.N2, factor: 1 }, 829 | { constituent: constituents.J1, factor: -1 } 830 | ]); 831 | constituents.RHO = compoundConstituentFactory('RHO', [ 832 | { constituent: constituents.NU2, factor: 1 }, 833 | { constituent: constituents.K1, factor: -1 } 834 | ]); 835 | 836 | // Semi-Diurnal 837 | 838 | constituents.MU2 = compoundConstituentFactory('MU2', [ 839 | { constituent: constituents.M2, factor: 2 }, 840 | { constituent: constituents.S2, factor: -1 } 841 | ]); 842 | constituents['2SM2'] = compoundConstituentFactory('2SM2', [ 843 | { constituent: constituents.S2, factor: 2 }, 844 | { constituent: constituents.M2, factor: -1 } 845 | ]); 846 | 847 | // Third-Diurnal 848 | constituents['2MK3'] = compoundConstituentFactory('2MK3', [ 849 | { constituent: constituents.M2, factor: 1 }, 850 | { constituent: constituents.O1, factor: 1 } 851 | ]); 852 | constituents.MK3 = compoundConstituentFactory('MK3', [ 853 | { constituent: constituents.M2, factor: 1 }, 854 | { constituent: constituents.K1, factor: 1 } 855 | ]); 856 | 857 | // Quarter-Diurnal 858 | constituents.MN4 = compoundConstituentFactory('MN4', [ 859 | { constituent: constituents.M2, factor: 1 }, 860 | { constituent: constituents.N2, factor: 1 } 861 | ]); 862 | constituents.M4 = compoundConstituentFactory('M4', [ 863 | { constituent: constituents.M2, factor: 2 } 864 | ]); 865 | constituents.MS4 = compoundConstituentFactory('MS4', [ 866 | { constituent: constituents.M2, factor: 1 }, 867 | { constituent: constituents.S2, factor: 1 } 868 | ]); 869 | constituents.S4 = compoundConstituentFactory('S4', [ 870 | { constituent: constituents.S2, factor: 2 } 871 | ]); 872 | 873 | // Sixth-Diurnal 874 | constituents.M6 = compoundConstituentFactory('M6', [ 875 | { constituent: constituents.M2, factor: 3 } 876 | ]); 877 | constituents.S6 = compoundConstituentFactory('S6', [ 878 | { constituent: constituents.S2, factor: 3 } 879 | ]); 880 | 881 | // Eighth-Diurnals 882 | constituents.M8 = compoundConstituentFactory('M8', [ 883 | { constituent: constituents.M2, factor: 4 } 884 | ]); 885 | 886 | const getDate = (time) => { 887 | if (time instanceof Date) { 888 | return time 889 | } 890 | if (typeof time === 'number') { 891 | return new Date(time * 1000) 892 | } 893 | throw new Error('Invalid date format, should be a Date object, or timestamp') 894 | }; 895 | 896 | const getTimeline = (start, end, seconds) => { 897 | seconds = typeof seconds !== 'undefined' ? seconds : 10 * 60; 898 | const items = []; 899 | const endTime = end.getTime() / 1000; 900 | let lastTime = start.getTime() / 1000; 901 | const startTime = lastTime; 902 | const hours = []; 903 | while (lastTime <= endTime) { 904 | items.push(new Date(lastTime * 1000)); 905 | hours.push((lastTime - startTime) / (60 * 60)); 906 | lastTime += seconds; 907 | } 908 | 909 | return { 910 | items, 911 | hours 912 | } 913 | }; 914 | 915 | const harmonicsFactory = ({ harmonicConstituents, phaseKey, offset }) => { 916 | if (!Array.isArray(harmonicConstituents)) { 917 | throw new Error('Harmonic constituents are not an array') 918 | } 919 | const constituents$1 = []; 920 | harmonicConstituents.forEach((constituent) => { 921 | if (typeof constituent.name === 'undefined') { 922 | throw new Error('Harmonic constituents must have a name property') 923 | } 924 | if (typeof constituents[constituent.name] !== 'undefined') { 925 | constituent._model = constituents[constituent.name]; 926 | constituent._phase = d2r * constituent[phaseKey]; 927 | constituents$1.push(constituent); 928 | } 929 | }); 930 | 931 | if (offset !== false) { 932 | constituents$1.push({ 933 | name: 'Z0', 934 | _model: constituents.Z0, 935 | _phase: 0, 936 | amplitude: offset 937 | }); 938 | } 939 | 940 | let start = new Date(); 941 | let end = new Date(); 942 | 943 | const harmonics = {}; 944 | 945 | harmonics.setTimeSpan = (startTime, endTime) => { 946 | start = getDate(startTime); 947 | end = getDate(endTime); 948 | if (start.getTime() >= end.getTime()) { 949 | throw new Error('Start time must be before end time') 950 | } 951 | return harmonics 952 | }; 953 | 954 | harmonics.prediction = (options) => { 955 | options = 956 | typeof options !== 'undefined' ? options : { timeFidelity: 10 * 60 }; 957 | return predictionFactory({ 958 | timeline: getTimeline(start, end, options.timeFidelity), 959 | constituents: constituents$1, 960 | start 961 | }) 962 | }; 963 | 964 | return Object.freeze(harmonics) 965 | }; 966 | 967 | const tidePredictionFactory = (constituents, options) => { 968 | const harmonicsOptions = { 969 | harmonicConstituents: constituents, 970 | phaseKey: 'phase_GMT', 971 | offset: false 972 | }; 973 | 974 | if (typeof options !== 'undefined') { 975 | Object.keys(harmonicsOptions).forEach((key) => { 976 | if (typeof options[key] !== 'undefined') { 977 | harmonicsOptions[key] = options[key]; 978 | } 979 | }); 980 | } 981 | 982 | const tidePrediction = { 983 | getTimelinePrediction: ({ start, end }) => { 984 | return harmonicsFactory(harmonicsOptions) 985 | .setTimeSpan(start, end) 986 | .prediction() 987 | .getTimelinePrediction() 988 | }, 989 | 990 | getExtremesPrediction: ({ start, end, labels, offsets, timeFidelity }) => { 991 | return harmonicsFactory(harmonicsOptions) 992 | .setTimeSpan(start, end) 993 | .prediction({ timeFidelity }) 994 | .getExtremesPrediction(labels, offsets) 995 | }, 996 | 997 | getWaterLevelAtTime: ({ time }) => { 998 | const endDate = new Date(time.getTime() + 10 * 60 * 1000); 999 | return harmonicsFactory(harmonicsOptions) 1000 | .setTimeSpan(time, endDate) 1001 | .prediction() 1002 | .getTimelinePrediction()[0] 1003 | } 1004 | }; 1005 | 1006 | return tidePrediction 1007 | }; 1008 | 1009 | return tidePredictionFactory; 1010 | 1011 | })); 1012 | -------------------------------------------------------------------------------- /dist/web/tide-predictor.min.js: -------------------------------------------------------------------------------- 1 | (function(global,factory){typeof exports==="object"&&typeof module!=="undefined"?module.exports=factory():typeof define==="function"&&define.amd?define(factory):(global=typeof globalThis!=="undefined"?globalThis:global||self,global.tidePredictor=factory())})(this,function(){"use strict";const d2r=Math.PI/180;const r2d=180/Math.PI;const sexagesimalToDecimal=(degrees,arcmins,arcsecs,mas,muas)=>{arcmins=typeof arcmins!=="undefined"?arcmins:0;arcsecs=typeof arcsecs!=="undefined"?arcsecs:0;mas=typeof mas!=="undefined"?mas:0;muas=typeof muas!=="undefined"?muas:0;return degrees+arcmins/60+arcsecs/(60*60)+mas/(60*60*1e3)+muas/(60*60*1e6)};const coefficients={terrestrialObliquity:[sexagesimalToDecimal(23,26,21.448),-sexagesimalToDecimal(0,0,4680.93),-sexagesimalToDecimal(0,0,1.55),sexagesimalToDecimal(0,0,1999.25),-sexagesimalToDecimal(0,0,51.38),-sexagesimalToDecimal(0,0,249.67),-sexagesimalToDecimal(0,0,39.05),sexagesimalToDecimal(0,0,7.12),sexagesimalToDecimal(0,0,27.87),sexagesimalToDecimal(0,0,5.79),sexagesimalToDecimal(0,0,2.45)].map((number,index)=>{return number*Math.pow(.01,index)}),solarPerigee:[280.46645-357.5291,36000.76932-35999.0503,3032e-7+1559e-7,48e-8],solarLongitude:[280.46645,36000.76983,3032e-7],lunarInclination:[5.145],lunarLongitude:[218.3164591,481267.88134236,-.0013268,1/538841-1/65194e3],lunarNode:[125.044555,-1934.1361849,.0020762,1/467410,-1/60616e3],lunarPerigee:[83.353243,4069.0137111,-.0103238,-1/80053,1/18999e3]};const polynomial=(coefficients,argument)=>{const result=[];coefficients.forEach((coefficient,index)=>{result.push(coefficient*Math.pow(argument,index))});return result.reduce((a,b)=>{return a+b})};const derivativePolynomial=(coefficients,argument)=>{const result=[];coefficients.forEach((coefficient,index)=>{result.push(coefficient*index*Math.pow(argument,index-1))});return result.reduce((a,b)=>{return a+b})};const T=t=>{return(JD(t)-2451545)/36525};const JD=t=>{let Y=t.getFullYear();let M=t.getMonth()+1;const D=t.getDate()+t.getHours()/24+t.getMinutes()/(24*60)+t.getSeconds()/(24*60*60)+t.getMilliseconds()/(24*60*60*1e6);if(M<=2){Y=Y-1;M=M+12}const A=Math.floor(Y/100);const B=2-A+Math.floor(A/4);return Math.floor(365.25*(Y+4716))+Math.floor(30.6001*(M+1))+D+B-1524.5};const _I=(N,i,omega)=>{N=d2r*N;i=d2r*i;omega=d2r*omega;const cosI=Math.cos(i)*Math.cos(omega)-Math.sin(i)*Math.sin(omega)*Math.cos(N);return r2d*Math.acos(cosI)};const _xi=(N,i,omega)=>{N=d2r*N;i=d2r*i;omega=d2r*omega;let e1=Math.cos(.5*(omega-i))/Math.cos(.5*(omega+i))*Math.tan(.5*N);let e2=Math.sin(.5*(omega-i))/Math.sin(.5*(omega+i))*Math.tan(.5*N);e1=Math.atan(e1);e2=Math.atan(e2);e1=e1-.5*N;e2=e2-.5*N;return-(e1+e2)*r2d};const _nu=(N,i,omega)=>{N=d2r*N;i=d2r*i;omega=d2r*omega;let e1=Math.cos(.5*(omega-i))/Math.cos(.5*(omega+i))*Math.tan(.5*N);let e2=Math.sin(.5*(omega-i))/Math.sin(.5*(omega+i))*Math.tan(.5*N);e1=Math.atan(e1);e2=Math.atan(e2);e1=e1-.5*N;e2=e2-.5*N;return(e1-e2)*r2d};const _nup=(N,i,omega)=>{const I=d2r*_I(N,i,omega);const nu=d2r*_nu(N,i,omega);return r2d*Math.atan(Math.sin(2*I)*Math.sin(nu)/(Math.sin(2*I)*Math.cos(nu)+.3347))};const _nupp=(N,i,omega)=>{const I=d2r*_I(N,i,omega);const nu=d2r*_nu(N,i,omega);const tan2nupp=Math.sin(I)**2*Math.sin(2*nu)/(Math.sin(I)**2*Math.cos(2*nu)+.0727);return r2d*.5*Math.atan(tan2nupp)};const modulus$1=(a,b)=>{return(a%b+b)%b};const astro=time=>{const result={};const polynomials={s:coefficients.lunarLongitude,h:coefficients.solarLongitude,p:coefficients.lunarPerigee,N:coefficients.lunarNode,pp:coefficients.solarPerigee,90:[90],omega:coefficients.terrestrialObliquity,i:coefficients.lunarInclination};const dTdHour=1/(24*365.25*100);Object.keys(polynomials).forEach(name=>{result[name]={value:modulus$1(polynomial(polynomials[name],T(time)),360),speed:derivativePolynomial(polynomials[name],T(time))*dTdHour}});const functions={I:_I,xi:_xi,nu:_nu,nup:_nup,nupp:_nupp};Object.keys(functions).forEach(name=>{const functionCall=functions[name];result[name]={value:modulus$1(functionCall(result.N.value,result.i.value,result.omega.value),360),speed:null}});const hour={value:(JD(time)-Math.floor(JD(time)))*360,speed:15};result["T+h-s"]={value:hour.value+result.h.value-result.s.value,speed:hour.speed+result.h.speed-result.s.speed};result.P={value:result.p.value-result.xi.value%360,speed:null};return result};const modulus=(a,b)=>{return(a%b+b)%b};const addExtremesOffsets=(extreme,offsets)=>{if(typeof offsets==="undefined"||!offsets){return extreme}if(extreme.high&&offsets.height_offset&&offsets.height_offset.high){extreme.level*=offsets.height_offset.high}if(extreme.low&&offsets.height_offset&&offsets.height_offset.low){extreme.level*=offsets.height_offset.low}if(extreme.high&&offsets.time_offset&&offsets.time_offset.high){extreme.time=new Date(extreme.time.getTime()+offsets.time_offset.high*60*1e3)}if(extreme.low&&offsets.time_offset&&offsets.time_offset.low){extreme.time=new Date(extreme.time.getTime()+offsets.time_offset.low*60*1e3)}return extreme};const getExtremeLabel=(label,highLowLabels)=>{if(typeof highLowLabels!=="undefined"&&typeof highLowLabels[label]!=="undefined"){return highLowLabels[label]}const labels={high:"High",low:"Low"};return labels[label]};const predictionFactory=({timeline,constituents,start})=>{const getLevel=(hour,modelBaseSpeed,modelU,modelF,modelBaseValue)=>{const amplitudes=[];let result=0;constituents.forEach(constituent=>{const amplitude=constituent.amplitude;const phase=constituent._phase;const f=modelF[constituent.name];const speed=modelBaseSpeed[constituent.name];const u=modelU[constituent.name];const V0=modelBaseValue[constituent.name];amplitudes.push(amplitude*f*Math.cos(speed*hour+(V0+u)-phase))});amplitudes.forEach(item=>{result+=item});return result};const prediction={};prediction.getExtremesPrediction=options=>{const{labels,offsets}=typeof options!=="undefined"?options:{};const results=[];const{baseSpeed,u,f,baseValue}=prepare();let goingUp=false;let goingDown=false;let lastLevel=getLevel(0,baseSpeed,u[0],f[0],baseValue);timeline.items.forEach((time,index)=>{const hour=timeline.hours[index];const level=getLevel(hour,baseSpeed,u[index],f[index],baseValue);if(level>lastLevel&&goingDown){results.push(addExtremesOffsets({time:timeline.items[index-1],level:lastLevel,high:false,low:true,label:getExtremeLabel("low",labels)},offsets))}if(levellastLevel){goingUp=true;goingDown=false}if(level{const results=[];const{baseSpeed,u,f,baseValue}=prepare();timeline.items.forEach((time,index)=>{const hour=timeline.hours[index];const prediction={time:time,hour:hour,level:getLevel(hour,baseSpeed,u[index],f[index],baseValue)};results.push(prediction)});return results};const prepare=()=>{const baseAstro=astro(start);const baseValue={};const baseSpeed={};const u=[];const f=[];constituents.forEach(constituent=>{const value=constituent._model.value(baseAstro);const speed=constituent._model.speed(baseAstro);baseValue[constituent.name]=d2r*value;baseSpeed[constituent.name]=d2r*speed});timeline.items.forEach(time=>{const uItem={};const fItem={};const itemAstro=astro(time);constituents.forEach(constituent=>{const constituentU=modulus(constituent._model.u(itemAstro),360);uItem[constituent.name]=d2r*constituentU;fItem[constituent.name]=modulus(constituent._model.f(itemAstro),360)});u.push(uItem);f.push(fItem)});return{baseValue:baseValue,baseSpeed:baseSpeed,u:u,f:f}};return Object.freeze(prediction)};const corrections={fUnity(){return 1},fMm(a){const omega=d2r*a.omega.value;const i=d2r*a.i.value;const I=d2r*a.I.value;const mean=(2/3-Math.pow(Math.sin(omega),2))*(1-3/2*Math.pow(Math.sin(i),2));return(2/3-Math.pow(Math.sin(I),2))/mean},fMf(a){const omega=d2r*a.omega.value;const i=d2r*a.i.value;const I=d2r*a.I.value;const mean=Math.pow(Math.sin(omega),2)*Math.pow(Math.cos(.5*i),4);return Math.pow(Math.sin(I),2)/mean},fO1(a){const omega=d2r*a.omega.value;const i=d2r*a.i.value;const I=d2r*a.I.value;const mean=Math.sin(omega)*Math.pow(Math.cos(.5*omega),2)*Math.pow(Math.cos(.5*i),4);return Math.sin(I)*Math.pow(Math.cos(.5*I),2)/mean},fJ1(a){const omega=d2r*a.omega.value;const i=d2r*a.i.value;const I=d2r*a.I.value;const mean=Math.sin(2*omega)*(1-3/2*Math.pow(Math.sin(i),2));return Math.sin(2*I)/mean},fOO1(a){const omega=d2r*a.omega.value;const i=d2r*a.i.value;const I=d2r*a.I.value;const mean=Math.sin(omega)*Math.pow(Math.sin(.5*omega),2)*Math.pow(Math.cos(.5*i),4);return Math.sin(I)*Math.pow(Math.sin(.5*I),2)/mean},fM2(a){const omega=d2r*a.omega.value;const i=d2r*a.i.value;const I=d2r*a.I.value;const mean=Math.pow(Math.cos(.5*omega),4)*Math.pow(Math.cos(.5*i),4);return Math.pow(Math.cos(.5*I),4)/mean},fK1(a){const omega=d2r*a.omega.value;const i=d2r*a.i.value;const I=d2r*a.I.value;const nu=d2r*a.nu.value;const sin2IcosnuMean=Math.sin(2*omega)*(1-3/2*Math.pow(Math.sin(i),2));const mean=.5023*sin2IcosnuMean+.1681;return Math.pow(.2523*Math.pow(Math.sin(2*I),2)+.1689*Math.sin(2*I)*Math.cos(nu)+.0283,.5)/mean},fL2(a){const P=d2r*a.P.value;const I=d2r*a.I.value;const rAInv=Math.pow(1-12*Math.pow(Math.tan(.5*I),2)*Math.cos(2*P)+36*Math.pow(Math.tan(.5*I),4),.5);return corrections.fM2(a)*rAInv},fK2(a){const omega=d2r*a.omega.value;const i=d2r*a.i.value;const I=d2r*a.I.value;const nu=d2r*a.nu.value;const sinsqIcos2nuMean=Math.sin(omega)**2*(1-3/2*Math.sin(i)**2);const mean=.5023*sinsqIcos2nuMean+.0365;return Math.pow(.2523*Math.pow(Math.sin(I),4)+.0367*Math.pow(Math.sin(I),2)*Math.cos(2*nu)+.0013,.5)/mean},fM1(a){const P=d2r*a.P.value;const I=d2r*a.I.value;const qAInv=Math.pow(.25+1.5*Math.cos(I)*Math.cos(2*P)*Math.pow(Math.cos(.5*I),-.5)+2.25*Math.pow(Math.cos(I),2)*Math.pow(Math.cos(.5*I),-4),.5);return corrections.fO1(a)*qAInv},fModd(a,n){return Math.pow(corrections.fM2(a),n/2)},uZero(){return 0},uMf(a){return-2*a.xi.value},uO1(a){return 2*a.xi.value-a.nu.value},uJ1(a){return-a.nu.value},uOO1(a){return-2*a.xi.value-a.nu.value},uM2(a){return 2*a.xi.value-2*a.nu.value},uK1(a){return-a.nup.value},uL2(a){const I=d2r*a.I.value;const P=d2r*a.P.value;const R=r2d*Math.atan(Math.sin(2*P)/(1/6*Math.pow(Math.tan(.5*I),-2)-Math.cos(2*P)));return 2*a.xi.value-2*a.nu.value-R},uK2(a){return-2*a.nupp.value},uM1(a){const I=d2r*a.I.value;const P=d2r*a.P.value;const Q=r2d*Math.atan((5*Math.cos(I)-1)/(7*Math.cos(I)+1)*Math.tan(P));return a.xi.value-a.nu.value+Q},uModd(a,n){return n/2*corrections.uM2(a)}};const dotArray=(a,b)=>{const results=[];a.forEach((value,index)=>{results.push(value*b[index])});return results.reduce((total,value)=>{return total+value})};const astronimicDoodsonNumber=astro=>{return[astro["T+h-s"],astro.s,astro.h,astro.p,astro.N,astro.pp,astro["90"]]};const astronomicSpeed=astro=>{const results=[];astronimicDoodsonNumber(astro).forEach(number=>{results.push(number.speed)});return results};const astronomicValues=astro=>{const results=[];astronimicDoodsonNumber(astro).forEach(number=>{results.push(number.value)});return results};const constituentFactory=(name,coefficients,u,f)=>{if(!coefficients){throw new Error("Coefficient must be defined for a constituent")}const constituent={name:name,coefficients:coefficients,value:astro=>{return dotArray(coefficients,astronomicValues(astro))},speed(astro){return dotArray(coefficients,astronomicSpeed(astro))},u:typeof u!=="undefined"?u:corrections.uZero,f:typeof f!=="undefined"?f:corrections.fUnity};return Object.freeze(constituent)};const compoundConstituentFactory=(name,members)=>{const coefficients=[];members.forEach(({constituent,factor})=>{constituent.coefficients.forEach((coefficient,index)=>{if(typeof coefficients[index]==="undefined"){coefficients[index]=0}coefficients[index]+=coefficient*factor})});const compoundConstituent={name:name,coefficients:coefficients,speed:astro=>{let speed=0;members.forEach(({constituent,factor})=>{speed+=constituent.speed(astro)*factor});return speed},value:astro=>{let value=0;members.forEach(({constituent,factor})=>{value+=constituent.value(astro)*factor});return value},u:astro=>{let u=0;members.forEach(({constituent,factor})=>{u+=constituent.u(astro)*factor});return u},f:astro=>{const f=[];members.forEach(({constituent,factor})=>{f.push(Math.pow(constituent.f(astro),Math.abs(factor)))});return f.reduce((previous,value)=>{return previous*value})}};return Object.freeze(compoundConstituent)};const constituents={};constituents.Z0=constituentFactory("Z0",[0,0,0,0,0,0,0],corrections.uZero,corrections.fUnity);constituents.SA=constituentFactory("Sa",[0,0,1,0,0,0,0],corrections.uZero,corrections.fUnity);constituents.SSA=constituentFactory("Ssa",[0,0,2,0,0,0,0],corrections.uZero,corrections.fUnity);constituents.MM=constituentFactory("MM",[0,1,0,-1,0,0,0],corrections.uZero,corrections.fMm);constituents.MF=constituentFactory("MF",[0,2,0,0,0,0,0],corrections.uMf,corrections.fMf);constituents.Q1=constituentFactory("Q1",[1,-2,0,1,0,0,1],corrections.uO1,corrections.fO1);constituents.O1=constituentFactory("O1",[1,-1,0,0,0,0,1],corrections.uO1,corrections.fO1);constituents.K1=constituentFactory("K1",[1,1,0,0,0,0,-1],corrections.uK1,corrections.fK1);constituents.J1=constituentFactory("J1",[1,2,0,-1,0,0,-1],corrections.uJ1,corrections.fJ1);constituents.M1=constituentFactory("M1",[1,0,0,0,0,0,1],corrections.uM1,corrections.fM1);constituents.P1=constituentFactory("P1",[1,1,-2,0,0,0,1],corrections.uZero,corrections.fUnity);constituents.S1=constituentFactory("S1",[1,1,-1,0,0,0,0],corrections.uZero,corrections.fUnity);constituents.OO1=constituentFactory("OO1",[1,3,0,0,0,0,-1],corrections.uOO1,corrections.fOO1);constituents["2N2"]=constituentFactory("2N2",[2,-2,0,2,0,0,0],corrections.uM2,corrections.fM2);constituents.N2=constituentFactory("N2",[2,-1,0,1,0,0,0],corrections.uM2,corrections.fM2);constituents.NU2=constituentFactory("NU2",[2,-1,2,-1,0,0,0],corrections.uM2,corrections.fM2);constituents.M2=constituentFactory("M2",[2,0,0,0,0,0,0],corrections.uM2,corrections.fM2);constituents.LAM2=constituentFactory("LAM2",[2,1,-2,1,0,0,2],corrections.uM2,corrections.fM2);constituents.L2=constituentFactory("L2",[2,1,0,-1,0,0,2],corrections.uL2,corrections.fL2);constituents.T2=constituentFactory("T2",[2,2,-3,0,0,1,0],corrections.uZero,corrections.fUnity);constituents.S2=constituentFactory("S2",[2,2,-2,0,0,0,0],corrections.uZero,corrections.fUnity);constituents.R2=constituentFactory("R2",[2,2,-1,0,0,-1,2],corrections.uZero,corrections.fUnity);constituents.K2=constituentFactory("K2",[2,2,0,0,0,0,0],corrections.uK2,corrections.fK2);constituents.M3=constituentFactory("M3",[3,0,0,0,0,0,0],a=>{return corrections.uModd(a,3)},a=>{return corrections.fModd(a,3)});constituents.MSF=compoundConstituentFactory("MSF",[{constituent:constituents.S2,factor:1},{constituent:constituents.M2,factor:-1}]);constituents["2Q1"]=compoundConstituentFactory("2Q1",[{constituent:constituents.N2,factor:1},{constituent:constituents.J1,factor:-1}]);constituents.RHO=compoundConstituentFactory("RHO",[{constituent:constituents.NU2,factor:1},{constituent:constituents.K1,factor:-1}]);constituents.MU2=compoundConstituentFactory("MU2",[{constituent:constituents.M2,factor:2},{constituent:constituents.S2,factor:-1}]);constituents["2SM2"]=compoundConstituentFactory("2SM2",[{constituent:constituents.S2,factor:2},{constituent:constituents.M2,factor:-1}]);constituents["2MK3"]=compoundConstituentFactory("2MK3",[{constituent:constituents.M2,factor:1},{constituent:constituents.O1,factor:1}]);constituents.MK3=compoundConstituentFactory("MK3",[{constituent:constituents.M2,factor:1},{constituent:constituents.K1,factor:1}]);constituents.MN4=compoundConstituentFactory("MN4",[{constituent:constituents.M2,factor:1},{constituent:constituents.N2,factor:1}]);constituents.M4=compoundConstituentFactory("M4",[{constituent:constituents.M2,factor:2}]);constituents.MS4=compoundConstituentFactory("MS4",[{constituent:constituents.M2,factor:1},{constituent:constituents.S2,factor:1}]);constituents.S4=compoundConstituentFactory("S4",[{constituent:constituents.S2,factor:2}]);constituents.M6=compoundConstituentFactory("M6",[{constituent:constituents.M2,factor:3}]);constituents.S6=compoundConstituentFactory("S6",[{constituent:constituents.S2,factor:3}]);constituents.M8=compoundConstituentFactory("M8",[{constituent:constituents.M2,factor:4}]);const getDate=time=>{if(time instanceof Date){return time}if(typeof time==="number"){return new Date(time*1e3)}throw new Error("Invalid date format, should be a Date object, or timestamp")};const getTimeline=(start,end,seconds)=>{seconds=typeof seconds!=="undefined"?seconds:10*60;const items=[];const endTime=end.getTime()/1e3;let lastTime=start.getTime()/1e3;const startTime=lastTime;const hours=[];while(lastTime<=endTime){items.push(new Date(lastTime*1e3));hours.push((lastTime-startTime)/(60*60));lastTime+=seconds}return{items:items,hours:hours}};const harmonicsFactory=({harmonicConstituents,phaseKey,offset})=>{if(!Array.isArray(harmonicConstituents)){throw new Error("Harmonic constituents are not an array")}const constituents$1=[];harmonicConstituents.forEach(constituent=>{if(typeof constituent.name==="undefined"){throw new Error("Harmonic constituents must have a name property")}if(typeof constituents[constituent.name]!=="undefined"){constituent._model=constituents[constituent.name];constituent._phase=d2r*constituent[phaseKey];constituents$1.push(constituent)}});if(offset!==false){constituents$1.push({name:"Z0",_model:constituents.Z0,_phase:0,amplitude:offset})}let start=new Date;let end=new Date;const harmonics={};harmonics.setTimeSpan=(startTime,endTime)=>{start=getDate(startTime);end=getDate(endTime);if(start.getTime()>=end.getTime()){throw new Error("Start time must be before end time")}return harmonics};harmonics.prediction=options=>{options=typeof options!=="undefined"?options:{timeFidelity:10*60};return predictionFactory({timeline:getTimeline(start,end,options.timeFidelity),constituents:constituents$1,start:start})};return Object.freeze(harmonics)};const tidePredictionFactory=(constituents,options)=>{const harmonicsOptions={harmonicConstituents:constituents,phaseKey:"phase_GMT",offset:false};if(typeof options!=="undefined"){Object.keys(harmonicsOptions).forEach(key=>{if(typeof options[key]!=="undefined"){harmonicsOptions[key]=options[key]}})}const tidePrediction={getTimelinePrediction:({start,end})=>{return harmonicsFactory(harmonicsOptions).setTimeSpan(start,end).prediction().getTimelinePrediction()},getExtremesPrediction:({start,end,labels,offsets,timeFidelity})=>{return harmonicsFactory(harmonicsOptions).setTimeSpan(start,end).prediction({timeFidelity:timeFidelity}).getExtremesPrediction(labels,offsets)},getWaterLevelAtTime:({time})=>{const endDate=new Date(time.getTime()+10*60*1e3);return harmonicsFactory(harmonicsOptions).setTimeSpan(time,endDate).prediction().getTimelinePrediction()[0]}};return tidePrediction};return tidePredictionFactory}); -------------------------------------------------------------------------------- /examples/browser/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Tide Predictor in the browser 5 | 6 | 7 | 8 |
9 |

High/low tides for Monterey, CA

10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
TimeHigh/LowLevel (meters)
20 |
21 | 22 | 23 | 52 | 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@neaps/tide-predictor", 3 | "version": "0.1.1", 4 | "description": "Tide predictor", 5 | "repository": "https://github.com/neaps/tide-predictor", 6 | "author": "Kevin Miller ", 7 | "license": "MIT", 8 | "type": "module", 9 | "module": "./src/index.js", 10 | "browser": "./dist/web/tide-predictor.js", 11 | "commonjs": "./dist/commonjs/index.js", 12 | "exports": { 13 | ".": { 14 | "import": "./src/index.js", 15 | "require": "./dist/commonjs/index.js" 16 | } 17 | }, 18 | "devDependencies": { 19 | "c8": "^7.11.3", 20 | "eslint": "^8.18.0", 21 | "mocha": "^10.0.0", 22 | "npm-run-all": "^4.1.5", 23 | "rollup-plugin-node-resolve": "^5.2.0", 24 | "uglify-js": "^3.16.1" 25 | }, 26 | "scripts": { 27 | "test": "mocha --recursive", 28 | "lint": "eslint ./src", 29 | "coverage": "c8 --reporter=lcov mocha --recursive && codecov", 30 | "ci": "run-s lint test coverage", 31 | "build:rollup": "rollup --config rollup.config.js", 32 | "build:uglify": "uglifyjs ./dist/web/tide-predictor.js -o ./dist/web/tide-predictor.min.js", 33 | "build:commonDist": "echo \"{\\\"type\\\":\\\"commonjs\\\"}\" > ./dist/commonjs/package.json", 34 | "build": "run-s build:rollup build:commonDist build:uglify" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import pkg from './package.json' 2 | import resolve from 'rollup-plugin-node-resolve' 3 | 4 | export default { 5 | input: './src/index.js', 6 | output: [ 7 | { 8 | name: 'tidePredictor', 9 | file: pkg.browser, 10 | format: 'umd' 11 | }, 12 | { file: pkg.commonjs, format: 'commonjs', exports: 'default' } 13 | ], 14 | plugins: [ 15 | resolve({ 16 | mainFields: ['module', 'main'], 17 | 18 | jail: '/src' 19 | }) 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /src/astronomy/coefficients.js: -------------------------------------------------------------------------------- 1 | // Convert a sexagesimal angle into decimal degrees 2 | const sexagesimalToDecimal = (degrees, arcmins, arcsecs, mas, muas) => { 3 | arcmins = typeof arcmins !== 'undefined' ? arcmins : 0 4 | arcsecs = typeof arcsecs !== 'undefined' ? arcsecs : 0 5 | mas = typeof mas !== 'undefined' ? mas : 0 6 | muas = typeof muas !== 'undefined' ? muas : 0 7 | 8 | return ( 9 | degrees + 10 | arcmins / 60.0 + 11 | arcsecs / (60.0 * 60.0) + 12 | mas / (60.0 * 60.0 * 1e3) + 13 | muas / (60.0 * 60.0 * 1e6) 14 | ) 15 | } 16 | 17 | const coefficients = { 18 | // Meeus formula 21.3 19 | terrestrialObliquity: [ 20 | sexagesimalToDecimal(23, 26, 21.448), 21 | -sexagesimalToDecimal(0, 0, 4680.93), 22 | -sexagesimalToDecimal(0, 0, 1.55), 23 | sexagesimalToDecimal(0, 0, 1999.25), 24 | -sexagesimalToDecimal(0, 0, 51.38), 25 | -sexagesimalToDecimal(0, 0, 249.67), 26 | -sexagesimalToDecimal(0, 0, 39.05), 27 | sexagesimalToDecimal(0, 0, 7.12), 28 | sexagesimalToDecimal(0, 0, 27.87), 29 | sexagesimalToDecimal(0, 0, 5.79), 30 | sexagesimalToDecimal(0, 0, 2.45), 31 | ].map((number, index) => { 32 | return number * Math.pow(1e-2, index) 33 | }), 34 | 35 | solarPerigee: [ 36 | 280.46645 - 357.5291, 37 | 36000.76932 - 35999.0503, 38 | 0.0003032 + 0.0001559, 39 | 0.00000048, 40 | ], 41 | 42 | solarLongitude: [280.46645, 36000.76983, 0.0003032], 43 | 44 | lunarInclination: [5.145], 45 | 46 | lunarLongitude: [ 47 | 218.3164591, 48 | 481267.88134236, 49 | -0.0013268, 50 | 1 / 538841.0 - 1 / 65194000.0, 51 | ], 52 | 53 | lunarNode: [ 54 | 125.044555, 55 | -1934.1361849, 56 | 0.0020762, 57 | 1 / 467410.0, 58 | -1 / 60616000.0, 59 | ], 60 | 61 | lunarPerigee: [ 62 | 83.353243, 63 | 4069.0137111, 64 | -0.0103238, 65 | -1 / 80053.0, 66 | 1 / 18999000.0, 67 | ], 68 | } 69 | 70 | export default coefficients 71 | 72 | export { sexagesimalToDecimal } 73 | -------------------------------------------------------------------------------- /src/astronomy/constants.js: -------------------------------------------------------------------------------- 1 | const d2r = Math.PI / 180.0 2 | const r2d = 180.0 / Math.PI 3 | 4 | export { d2r, r2d } 5 | -------------------------------------------------------------------------------- /src/astronomy/index.js: -------------------------------------------------------------------------------- 1 | import { d2r, r2d } from './constants.js' 2 | import coefficients from './coefficients.js' 3 | 4 | // Evaluates a polynomial at argument 5 | const polynomial = (coefficients, argument) => { 6 | const result = [] 7 | coefficients.forEach((coefficient, index) => { 8 | result.push(coefficient * Math.pow(argument, index)) 9 | }) 10 | return result.reduce((a, b) => { 11 | return a + b 12 | }) 13 | } 14 | 15 | // Evaluates a derivative polynomial at argument 16 | const derivativePolynomial = (coefficients, argument) => { 17 | const result = [] 18 | coefficients.forEach((coefficient, index) => { 19 | result.push(coefficient * index * Math.pow(argument, index - 1)) 20 | }) 21 | return result.reduce((a, b) => { 22 | return a + b 23 | }) 24 | } 25 | 26 | // Meeus formula 11.1 27 | const T = (t) => { 28 | return (JD(t) - 2451545.0) / 36525 29 | } 30 | 31 | // Meeus formula 7.1 32 | const JD = (t) => { 33 | let Y = t.getFullYear() 34 | let M = t.getMonth() + 1 35 | const D = 36 | t.getDate() + 37 | t.getHours() / 24.0 + 38 | t.getMinutes() / (24.0 * 60.0) + 39 | t.getSeconds() / (24.0 * 60.0 * 60.0) + 40 | t.getMilliseconds() / (24.0 * 60.0 * 60.0 * 1e6) 41 | if (M <= 2) { 42 | Y = Y - 1 43 | M = M + 12 44 | } 45 | const A = Math.floor(Y / 100.0) 46 | const B = 2 - A + Math.floor(A / 4.0) 47 | return ( 48 | Math.floor(365.25 * (Y + 4716)) + 49 | Math.floor(30.6001 * (M + 1)) + 50 | D + 51 | B - 52 | 1524.5 53 | ) 54 | } 55 | 56 | /** 57 | * @todo - What's with the array returned from the arccos? 58 | * @param {*} N 59 | * @param {*} i 60 | * @param {*} omega 61 | */ 62 | const _I = (N, i, omega) => { 63 | N = d2r * N 64 | i = d2r * i 65 | omega = d2r * omega 66 | const cosI = 67 | Math.cos(i) * Math.cos(omega) - Math.sin(i) * Math.sin(omega) * Math.cos(N) 68 | return r2d * Math.acos(cosI) 69 | } 70 | 71 | const _xi = (N, i, omega) => { 72 | N = d2r * N 73 | i = d2r * i 74 | omega = d2r * omega 75 | let e1 = 76 | (Math.cos(0.5 * (omega - i)) / Math.cos(0.5 * (omega + i))) * 77 | Math.tan(0.5 * N) 78 | let e2 = 79 | (Math.sin(0.5 * (omega - i)) / Math.sin(0.5 * (omega + i))) * 80 | Math.tan(0.5 * N) 81 | e1 = Math.atan(e1) 82 | e2 = Math.atan(e2) 83 | e1 = e1 - 0.5 * N 84 | e2 = e2 - 0.5 * N 85 | return -(e1 + e2) * r2d 86 | } 87 | 88 | const _nu = (N, i, omega) => { 89 | N = d2r * N 90 | i = d2r * i 91 | omega = d2r * omega 92 | let e1 = 93 | (Math.cos(0.5 * (omega - i)) / Math.cos(0.5 * (omega + i))) * 94 | Math.tan(0.5 * N) 95 | let e2 = 96 | (Math.sin(0.5 * (omega - i)) / Math.sin(0.5 * (omega + i))) * 97 | Math.tan(0.5 * N) 98 | e1 = Math.atan(e1) 99 | e2 = Math.atan(e2) 100 | e1 = e1 - 0.5 * N 101 | e2 = e2 - 0.5 * N 102 | return (e1 - e2) * r2d 103 | } 104 | 105 | // Schureman equation 224 106 | const _nup = (N, i, omega) => { 107 | const I = d2r * _I(N, i, omega) 108 | const nu = d2r * _nu(N, i, omega) 109 | return ( 110 | r2d * 111 | Math.atan( 112 | (Math.sin(2 * I) * Math.sin(nu)) / 113 | (Math.sin(2 * I) * Math.cos(nu) + 0.3347) 114 | ) 115 | ) 116 | } 117 | 118 | // Schureman equation 232 119 | const _nupp = (N, i, omega) => { 120 | const I = d2r * _I(N, i, omega) 121 | const nu = d2r * _nu(N, i, omega) 122 | const tan2nupp = 123 | (Math.sin(I) ** 2 * Math.sin(2 * nu)) / 124 | (Math.sin(I) ** 2 * Math.cos(2 * nu) + 0.0727) 125 | return r2d * 0.5 * Math.atan(tan2nupp) 126 | } 127 | 128 | const modulus = (a, b) => { 129 | return ((a % b) + b) % b 130 | } 131 | 132 | const astro = (time) => { 133 | const result = {} 134 | const polynomials = { 135 | s: coefficients.lunarLongitude, 136 | h: coefficients.solarLongitude, 137 | p: coefficients.lunarPerigee, 138 | N: coefficients.lunarNode, 139 | pp: coefficients.solarPerigee, 140 | 90: [90.0], 141 | omega: coefficients.terrestrialObliquity, 142 | i: coefficients.lunarInclination 143 | } 144 | 145 | // Polynomials are in T, that is Julian Centuries; we want our speeds to be 146 | // in the more convenient unit of degrees per hour. 147 | const dTdHour = 1 / (24 * 365.25 * 100) 148 | Object.keys(polynomials).forEach((name) => { 149 | result[name] = { 150 | value: modulus(polynomial(polynomials[name], T(time)), 360.0), 151 | speed: derivativePolynomial(polynomials[name], T(time)) * dTdHour 152 | } 153 | }) 154 | 155 | // Some other parameters defined by Schureman which are dependent on the 156 | // parameters N, i, omega for use in node factor calculations. We don't need 157 | // their speeds. 158 | const functions = { 159 | I: _I, 160 | xi: _xi, 161 | nu: _nu, 162 | nup: _nup, 163 | nupp: _nupp 164 | } 165 | Object.keys(functions).forEach((name) => { 166 | const functionCall = functions[name] 167 | result[name] = { 168 | value: modulus( 169 | functionCall(result.N.value, result.i.value, result.omega.value), 170 | 360.0 171 | ), 172 | speed: null 173 | } 174 | }) 175 | 176 | // We don't work directly with the T (hours) parameter, instead our spanning 177 | // set for equilibrium arguments #is given by T+h-s, s, h, p, N, pp, 90. 178 | // This is in line with convention. 179 | const hour = { 180 | value: (JD(time) - Math.floor(JD(time))) * 360.0, 181 | speed: 15.0 182 | } 183 | 184 | result['T+h-s'] = { 185 | value: hour.value + result.h.value - result.s.value, 186 | speed: hour.speed + result.h.speed - result.s.speed 187 | } 188 | 189 | // It is convenient to calculate Schureman's P here since several node 190 | // factors need it, although it could be argued that these 191 | // (along with I, xi, nu etc) belong somewhere else. 192 | result.P = { 193 | value: result.p.value - (result.xi.value % 360.0), 194 | speed: null 195 | } 196 | 197 | return result 198 | } 199 | 200 | export default astro 201 | export { polynomial, derivativePolynomial, T, JD, _I, _xi, _nu, _nup, _nupp } 202 | -------------------------------------------------------------------------------- /src/constituents/compound-constituent.js: -------------------------------------------------------------------------------- 1 | const compoundConstituentFactory = (name, members) => { 2 | const coefficients = [] 3 | members.forEach(({ constituent, factor }) => { 4 | constituent.coefficients.forEach((coefficient, index) => { 5 | if (typeof coefficients[index] === 'undefined') { 6 | coefficients[index] = 0 7 | } 8 | coefficients[index] += coefficient * factor 9 | }) 10 | }) 11 | 12 | const compoundConstituent = { 13 | name, 14 | coefficients, 15 | 16 | speed: (astro) => { 17 | let speed = 0 18 | members.forEach(({ constituent, factor }) => { 19 | speed += constituent.speed(astro) * factor 20 | }) 21 | return speed 22 | }, 23 | 24 | value: (astro) => { 25 | let value = 0 26 | members.forEach(({ constituent, factor }) => { 27 | value += constituent.value(astro) * factor 28 | }) 29 | return value 30 | }, 31 | 32 | u: (astro) => { 33 | let u = 0 34 | members.forEach(({ constituent, factor }) => { 35 | u += constituent.u(astro) * factor 36 | }) 37 | return u 38 | }, 39 | 40 | f: (astro) => { 41 | const f = [] 42 | members.forEach(({ constituent, factor }) => { 43 | f.push(Math.pow(constituent.f(astro), Math.abs(factor))) 44 | }) 45 | return f.reduce((previous, value) => { 46 | return previous * value 47 | }) 48 | } 49 | } 50 | 51 | return Object.freeze(compoundConstituent) 52 | } 53 | 54 | export default compoundConstituentFactory 55 | -------------------------------------------------------------------------------- /src/constituents/constituent.js: -------------------------------------------------------------------------------- 1 | import nodeCorrections from '../node-corrections/index.js' 2 | 3 | /** 4 | * Computes the dot notation of two arrays 5 | * @param {*} a 6 | * @param {*} b 7 | */ 8 | const dotArray = (a, b) => { 9 | const results = [] 10 | a.forEach((value, index) => { 11 | results.push(value * b[index]) 12 | }) 13 | return results.reduce((total, value) => { 14 | return total + value 15 | }) 16 | } 17 | 18 | const astronimicDoodsonNumber = (astro) => { 19 | return [ 20 | astro['T+h-s'], 21 | astro.s, 22 | astro.h, 23 | astro.p, 24 | astro.N, 25 | astro.pp, 26 | astro['90'] 27 | ] 28 | } 29 | 30 | const astronomicSpeed = (astro) => { 31 | const results = [] 32 | astronimicDoodsonNumber(astro).forEach((number) => { 33 | results.push(number.speed) 34 | }) 35 | return results 36 | } 37 | 38 | const astronomicValues = (astro) => { 39 | const results = [] 40 | astronimicDoodsonNumber(astro).forEach((number) => { 41 | results.push(number.value) 42 | }) 43 | return results 44 | } 45 | 46 | const constituentFactory = (name, coefficients, u, f) => { 47 | if (!coefficients) { 48 | throw new Error('Coefficient must be defined for a constituent') 49 | } 50 | 51 | const constituent = { 52 | name, 53 | coefficients, 54 | 55 | value: (astro) => { 56 | return dotArray(coefficients, astronomicValues(astro)) 57 | }, 58 | 59 | speed(astro) { 60 | return dotArray(coefficients, astronomicSpeed(astro)) 61 | }, 62 | 63 | u: typeof u !== 'undefined' ? u : nodeCorrections.uZero, 64 | 65 | f: typeof f !== 'undefined' ? f : nodeCorrections.fUnity 66 | } 67 | 68 | return Object.freeze(constituent) 69 | } 70 | 71 | export default constituentFactory 72 | 73 | export { astronimicDoodsonNumber, astronomicSpeed, astronomicValues } 74 | -------------------------------------------------------------------------------- /src/constituents/index.js: -------------------------------------------------------------------------------- 1 | import constituent from './constituent.js' 2 | import compoundConstituent from './compound-constituent.js' 3 | import nc from '../node-corrections/index.js' 4 | 5 | const constituents = {} 6 | // Long Term 7 | constituents.Z0 = constituent('Z0', [0, 0, 0, 0, 0, 0, 0], nc.uZero, nc.fUnity) 8 | constituents.SA = constituent('Sa', [0, 0, 1, 0, 0, 0, 0], nc.uZero, nc.fUnity) 9 | constituents.SSA = constituent( 10 | 'Ssa', 11 | [0, 0, 2, 0, 0, 0, 0], 12 | nc.uZero, 13 | nc.fUnity 14 | ) 15 | constituents.MM = constituent('MM', [0, 1, 0, -1, 0, 0, 0], nc.uZero, nc.fMm) 16 | constituents.MF = constituent('MF', [0, 2, 0, 0, 0, 0, 0], nc.uMf, nc.fMf) 17 | // Diurnals 18 | constituents.Q1 = constituent('Q1', [1, -2, 0, 1, 0, 0, 1], nc.uO1, nc.fO1) 19 | constituents.O1 = constituent('O1', [1, -1, 0, 0, 0, 0, 1], nc.uO1, nc.fO1) 20 | constituents.K1 = constituent('K1', [1, 1, 0, 0, 0, 0, -1], nc.uK1, nc.fK1) 21 | constituents.J1 = constituent('J1', [1, 2, 0, -1, 0, 0, -1], nc.uJ1, nc.fJ1) 22 | constituents.M1 = constituent('M1', [1, 0, 0, 0, 0, 0, 1], nc.uM1, nc.fM1) 23 | constituents.P1 = constituent('P1', [1, 1, -2, 0, 0, 0, 1], nc.uZero, nc.fUnity) 24 | constituents.S1 = constituent('S1', [1, 1, -1, 0, 0, 0, 0], nc.uZero, nc.fUnity) 25 | constituents.OO1 = constituent('OO1', [1, 3, 0, 0, 0, 0, -1], nc.uOO1, nc.fOO1) 26 | // Semi diurnals 27 | constituents['2N2'] = constituent('2N2', [2, -2, 0, 2, 0, 0, 0], nc.uM2, nc.fM2) 28 | constituents.N2 = constituent('N2', [2, -1, 0, 1, 0, 0, 0], nc.uM2, nc.fM2) 29 | constituents.NU2 = constituent('NU2', [2, -1, 2, -1, 0, 0, 0], nc.uM2, nc.fM2) 30 | constituents.M2 = constituent('M2', [2, 0, 0, 0, 0, 0, 0], nc.uM2, nc.fM2) 31 | constituents.LAM2 = constituent('LAM2', [2, 1, -2, 1, 0, 0, 2], nc.uM2, nc.fM2) 32 | constituents.L2 = constituent('L2', [2, 1, 0, -1, 0, 0, 2], nc.uL2, nc.fL2) 33 | constituents.T2 = constituent('T2', [2, 2, -3, 0, 0, 1, 0], nc.uZero, nc.fUnity) 34 | constituents.S2 = constituent('S2', [2, 2, -2, 0, 0, 0, 0], nc.uZero, nc.fUnity) 35 | constituents.R2 = constituent( 36 | 'R2', 37 | [2, 2, -1, 0, 0, -1, 2], 38 | nc.uZero, 39 | nc.fUnity 40 | ) 41 | constituents.K2 = constituent('K2', [2, 2, 0, 0, 0, 0, 0], nc.uK2, nc.fK2) 42 | // Third diurnal 43 | constituents.M3 = constituent( 44 | 'M3', 45 | [3, 0, 0, 0, 0, 0, 0], 46 | (a) => { 47 | return nc.uModd(a, 3) 48 | }, 49 | (a) => { 50 | return nc.fModd(a, 3) 51 | } 52 | ) 53 | // Compound 54 | constituents.MSF = compoundConstituent('MSF', [ 55 | { constituent: constituents.S2, factor: 1 }, 56 | { constituent: constituents.M2, factor: -1 } 57 | ]) 58 | 59 | // Diurnal 60 | constituents['2Q1'] = compoundConstituent('2Q1', [ 61 | { constituent: constituents.N2, factor: 1 }, 62 | { constituent: constituents.J1, factor: -1 } 63 | ]) 64 | constituents.RHO = compoundConstituent('RHO', [ 65 | { constituent: constituents.NU2, factor: 1 }, 66 | { constituent: constituents.K1, factor: -1 } 67 | ]) 68 | 69 | // Semi-Diurnal 70 | 71 | constituents.MU2 = compoundConstituent('MU2', [ 72 | { constituent: constituents.M2, factor: 2 }, 73 | { constituent: constituents.S2, factor: -1 } 74 | ]) 75 | constituents['2SM2'] = compoundConstituent('2SM2', [ 76 | { constituent: constituents.S2, factor: 2 }, 77 | { constituent: constituents.M2, factor: -1 } 78 | ]) 79 | 80 | // Third-Diurnal 81 | constituents['2MK3'] = compoundConstituent('2MK3', [ 82 | { constituent: constituents.M2, factor: 1 }, 83 | { constituent: constituents.O1, factor: 1 } 84 | ]) 85 | constituents.MK3 = compoundConstituent('MK3', [ 86 | { constituent: constituents.M2, factor: 1 }, 87 | { constituent: constituents.K1, factor: 1 } 88 | ]) 89 | 90 | // Quarter-Diurnal 91 | constituents.MN4 = compoundConstituent('MN4', [ 92 | { constituent: constituents.M2, factor: 1 }, 93 | { constituent: constituents.N2, factor: 1 } 94 | ]) 95 | constituents.M4 = compoundConstituent('M4', [ 96 | { constituent: constituents.M2, factor: 2 } 97 | ]) 98 | constituents.MS4 = compoundConstituent('MS4', [ 99 | { constituent: constituents.M2, factor: 1 }, 100 | { constituent: constituents.S2, factor: 1 } 101 | ]) 102 | constituents.S4 = compoundConstituent('S4', [ 103 | { constituent: constituents.S2, factor: 2 } 104 | ]) 105 | 106 | // Sixth-Diurnal 107 | constituents.M6 = compoundConstituent('M6', [ 108 | { constituent: constituents.M2, factor: 3 } 109 | ]) 110 | constituents.S6 = compoundConstituent('S6', [ 111 | { constituent: constituents.S2, factor: 3 } 112 | ]) 113 | 114 | // Eighth-Diurnals 115 | constituents.M8 = compoundConstituent('M8', [ 116 | { constituent: constituents.M2, factor: 4 } 117 | ]) 118 | 119 | export default constituents 120 | -------------------------------------------------------------------------------- /src/harmonics/index.js: -------------------------------------------------------------------------------- 1 | import prediction from './prediction.js' 2 | import constituentModels from '../constituents/index.js' 3 | import { d2r } from '../astronomy/constants.js' 4 | 5 | const getDate = (time) => { 6 | if (time instanceof Date) { 7 | return time 8 | } 9 | if (typeof time === 'number') { 10 | return new Date(time * 1000) 11 | } 12 | throw new Error('Invalid date format, should be a Date object, or timestamp') 13 | } 14 | 15 | const getTimeline = (start, end, seconds) => { 16 | seconds = typeof seconds !== 'undefined' ? seconds : 10 * 60 17 | const items = [] 18 | const endTime = end.getTime() / 1000 19 | let lastTime = start.getTime() / 1000 20 | const startTime = lastTime 21 | const hours = [] 22 | while (lastTime <= endTime) { 23 | items.push(new Date(lastTime * 1000)) 24 | hours.push((lastTime - startTime) / (60 * 60)) 25 | lastTime += seconds 26 | } 27 | 28 | return { 29 | items, 30 | hours 31 | } 32 | } 33 | 34 | const harmonicsFactory = ({ harmonicConstituents, phaseKey, offset }) => { 35 | if (!Array.isArray(harmonicConstituents)) { 36 | throw new Error('Harmonic constituents are not an array') 37 | } 38 | const constituents = [] 39 | harmonicConstituents.forEach((constituent) => { 40 | if (typeof constituent.name === 'undefined') { 41 | throw new Error('Harmonic constituents must have a name property') 42 | } 43 | if (typeof constituentModels[constituent.name] !== 'undefined') { 44 | constituent._model = constituentModels[constituent.name] 45 | constituent._phase = d2r * constituent[phaseKey] 46 | constituents.push(constituent) 47 | } 48 | }) 49 | 50 | if (offset !== false) { 51 | constituents.push({ 52 | name: 'Z0', 53 | _model: constituentModels.Z0, 54 | _phase: 0, 55 | amplitude: offset 56 | }) 57 | } 58 | 59 | let start = new Date() 60 | let end = new Date() 61 | 62 | const harmonics = {} 63 | 64 | harmonics.setTimeSpan = (startTime, endTime) => { 65 | start = getDate(startTime) 66 | end = getDate(endTime) 67 | if (start.getTime() >= end.getTime()) { 68 | throw new Error('Start time must be before end time') 69 | } 70 | return harmonics 71 | } 72 | 73 | harmonics.prediction = (options) => { 74 | options = 75 | typeof options !== 'undefined' ? options : { timeFidelity: 10 * 60 } 76 | return prediction({ 77 | timeline: getTimeline(start, end, options.timeFidelity), 78 | constituents, 79 | start 80 | }) 81 | } 82 | 83 | return Object.freeze(harmonics) 84 | } 85 | 86 | export default harmonicsFactory 87 | export { getDate, getTimeline } 88 | -------------------------------------------------------------------------------- /src/harmonics/prediction.js: -------------------------------------------------------------------------------- 1 | import astro from '../astronomy/index.js' 2 | import { d2r } from '../astronomy/constants.js' 3 | 4 | const modulus = (a, b) => { 5 | return ((a % b) + b) % b 6 | } 7 | 8 | const addExtremesOffsets = (extreme, offsets) => { 9 | if (typeof offsets === 'undefined' || !offsets) { 10 | return extreme 11 | } 12 | if (extreme.high && offsets.height_offset && offsets.height_offset.high) { 13 | extreme.level *= offsets.height_offset.high 14 | } 15 | if (extreme.low && offsets.height_offset && offsets.height_offset.low) { 16 | extreme.level *= offsets.height_offset.low 17 | } 18 | if (extreme.high && offsets.time_offset && offsets.time_offset.high) { 19 | extreme.time = new Date( 20 | extreme.time.getTime() + offsets.time_offset.high * 60 * 1000 21 | ) 22 | } 23 | if (extreme.low && offsets.time_offset && offsets.time_offset.low) { 24 | extreme.time = new Date( 25 | extreme.time.getTime() + offsets.time_offset.low * 60 * 1000 26 | ) 27 | } 28 | return extreme 29 | } 30 | 31 | const getExtremeLabel = (label, highLowLabels) => { 32 | if ( 33 | typeof highLowLabels !== 'undefined' && 34 | typeof highLowLabels[label] !== 'undefined' 35 | ) { 36 | return highLowLabels[label] 37 | } 38 | const labels = { 39 | high: 'High', 40 | low: 'Low' 41 | } 42 | return labels[label] 43 | } 44 | 45 | const predictionFactory = ({ timeline, constituents, start }) => { 46 | const getLevel = (hour, modelBaseSpeed, modelU, modelF, modelBaseValue) => { 47 | const amplitudes = [] 48 | let result = 0 49 | 50 | constituents.forEach((constituent) => { 51 | const amplitude = constituent.amplitude 52 | const phase = constituent._phase 53 | const f = modelF[constituent.name] 54 | const speed = modelBaseSpeed[constituent.name] 55 | const u = modelU[constituent.name] 56 | const V0 = modelBaseValue[constituent.name] 57 | amplitudes.push(amplitude * f * Math.cos(speed * hour + (V0 + u) - phase)) 58 | }) 59 | // sum up each row 60 | amplitudes.forEach((item) => { 61 | result += item 62 | }) 63 | return result 64 | } 65 | 66 | const prediction = {} 67 | 68 | prediction.getExtremesPrediction = (options) => { 69 | const { labels, offsets } = typeof options !== 'undefined' ? options : {} 70 | const results = [] 71 | const { baseSpeed, u, f, baseValue } = prepare() 72 | let goingUp = false 73 | let goingDown = false 74 | let lastLevel = getLevel(0, baseSpeed, u[0], f[0], baseValue) 75 | timeline.items.forEach((time, index) => { 76 | const hour = timeline.hours[index] 77 | const level = getLevel(hour, baseSpeed, u[index], f[index], baseValue) 78 | // Compare this level to the last one, if we 79 | // are changing angle, then the last one was high or low 80 | if (level > lastLevel && goingDown) { 81 | results.push( 82 | addExtremesOffsets( 83 | { 84 | time: timeline.items[index - 1], 85 | level: lastLevel, 86 | high: false, 87 | low: true, 88 | label: getExtremeLabel('low', labels) 89 | }, 90 | offsets 91 | ) 92 | ) 93 | } 94 | if (level < lastLevel && goingUp) { 95 | results.push( 96 | addExtremesOffsets( 97 | { 98 | time: timeline.items[index - 1], 99 | level: lastLevel, 100 | high: true, 101 | low: false, 102 | label: getExtremeLabel('high', labels) 103 | }, 104 | offsets 105 | ) 106 | ) 107 | } 108 | if (level > lastLevel) { 109 | goingUp = true 110 | goingDown = false 111 | } 112 | if (level < lastLevel) { 113 | goingUp = false 114 | goingDown = true 115 | } 116 | lastLevel = level 117 | }) 118 | return results 119 | } 120 | 121 | prediction.getTimelinePrediction = () => { 122 | const results = [] 123 | const { baseSpeed, u, f, baseValue } = prepare() 124 | timeline.items.forEach((time, index) => { 125 | const hour = timeline.hours[index] 126 | const prediction = { 127 | time, 128 | hour, 129 | level: getLevel(hour, baseSpeed, u[index], f[index], baseValue) 130 | } 131 | 132 | results.push(prediction) 133 | }) 134 | return results 135 | } 136 | 137 | const prepare = () => { 138 | const baseAstro = astro(start) 139 | 140 | const baseValue = {} 141 | const baseSpeed = {} 142 | const u = [] 143 | const f = [] 144 | constituents.forEach((constituent) => { 145 | const value = constituent._model.value(baseAstro) 146 | const speed = constituent._model.speed(baseAstro) 147 | baseValue[constituent.name] = d2r * value 148 | baseSpeed[constituent.name] = d2r * speed 149 | }) 150 | timeline.items.forEach((time) => { 151 | const uItem = {} 152 | const fItem = {} 153 | const itemAstro = astro(time) 154 | constituents.forEach((constituent) => { 155 | const constituentU = modulus(constituent._model.u(itemAstro), 360) 156 | 157 | uItem[constituent.name] = d2r * constituentU 158 | fItem[constituent.name] = modulus(constituent._model.f(itemAstro), 360) 159 | }) 160 | u.push(uItem) 161 | f.push(fItem) 162 | }) 163 | 164 | return { 165 | baseValue, 166 | baseSpeed, 167 | u, 168 | f 169 | } 170 | } 171 | 172 | return Object.freeze(prediction) 173 | } 174 | 175 | export default predictionFactory 176 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import harmonics from './harmonics/index.js' 2 | 3 | const tidePredictionFactory = (constituents, options) => { 4 | const harmonicsOptions = { 5 | harmonicConstituents: constituents, 6 | phaseKey: 'phase_GMT', 7 | offset: false 8 | } 9 | 10 | if (typeof options !== 'undefined') { 11 | Object.keys(harmonicsOptions).forEach((key) => { 12 | if (typeof options[key] !== 'undefined') { 13 | harmonicsOptions[key] = options[key] 14 | } 15 | }) 16 | } 17 | 18 | const tidePrediction = { 19 | getTimelinePrediction: ({ start, end }) => { 20 | return harmonics(harmonicsOptions) 21 | .setTimeSpan(start, end) 22 | .prediction() 23 | .getTimelinePrediction() 24 | }, 25 | 26 | getExtremesPrediction: ({ start, end, labels, offsets, timeFidelity }) => { 27 | return harmonics(harmonicsOptions) 28 | .setTimeSpan(start, end) 29 | .prediction({ timeFidelity }) 30 | .getExtremesPrediction(labels, offsets) 31 | }, 32 | 33 | getWaterLevelAtTime: ({ time }) => { 34 | const endDate = new Date(time.getTime() + 10 * 60 * 1000) 35 | return harmonics(harmonicsOptions) 36 | .setTimeSpan(time, endDate) 37 | .prediction() 38 | .getTimelinePrediction()[0] 39 | } 40 | } 41 | 42 | return tidePrediction 43 | } 44 | 45 | export default tidePredictionFactory 46 | -------------------------------------------------------------------------------- /src/node-corrections/index.js: -------------------------------------------------------------------------------- 1 | import { d2r, r2d } from '../astronomy/constants.js' 2 | 3 | const corrections = { 4 | fUnity() { 5 | return 1 6 | }, 7 | 8 | // Schureman equations 73, 65 9 | fMm(a) { 10 | const omega = d2r * a.omega.value 11 | const i = d2r * a.i.value 12 | const I = d2r * a.I.value 13 | const mean = 14 | (2 / 3.0 - Math.pow(Math.sin(omega), 2)) * 15 | (1 - (3 / 2.0) * Math.pow(Math.sin(i), 2)) 16 | return (2 / 3.0 - Math.pow(Math.sin(I), 2)) / mean 17 | }, 18 | 19 | // Schureman equations 74, 66 20 | fMf(a) { 21 | const omega = d2r * a.omega.value 22 | const i = d2r * a.i.value 23 | const I = d2r * a.I.value 24 | const mean = Math.pow(Math.sin(omega), 2) * Math.pow(Math.cos(0.5 * i), 4) 25 | return Math.pow(Math.sin(I), 2) / mean 26 | }, 27 | 28 | // Schureman equations 75, 67 29 | fO1(a) { 30 | const omega = d2r * a.omega.value 31 | const i = d2r * a.i.value 32 | const I = d2r * a.I.value 33 | const mean = 34 | Math.sin(omega) * 35 | Math.pow(Math.cos(0.5 * omega), 2) * 36 | Math.pow(Math.cos(0.5 * i), 4) 37 | return (Math.sin(I) * Math.pow(Math.cos(0.5 * I), 2)) / mean 38 | }, 39 | 40 | // Schureman equations 76, 68 41 | fJ1(a) { 42 | const omega = d2r * a.omega.value 43 | const i = d2r * a.i.value 44 | const I = d2r * a.I.value 45 | const mean = 46 | Math.sin(2 * omega) * (1 - (3 / 2.0) * Math.pow(Math.sin(i), 2)) 47 | return Math.sin(2 * I) / mean 48 | }, 49 | 50 | // Schureman equations 77, 69 51 | fOO1(a) { 52 | const omega = d2r * a.omega.value 53 | const i = d2r * a.i.value 54 | const I = d2r * a.I.value 55 | const mean = 56 | Math.sin(omega) * 57 | Math.pow(Math.sin(0.5 * omega), 2) * 58 | Math.pow(Math.cos(0.5 * i), 4) 59 | return (Math.sin(I) * Math.pow(Math.sin(0.5 * I), 2)) / mean 60 | }, 61 | 62 | // Schureman equations 78, 70 63 | fM2(a) { 64 | const omega = d2r * a.omega.value 65 | const i = d2r * a.i.value 66 | const I = d2r * a.I.value 67 | const mean = 68 | Math.pow(Math.cos(0.5 * omega), 4) * Math.pow(Math.cos(0.5 * i), 4) 69 | return Math.pow(Math.cos(0.5 * I), 4) / mean 70 | }, 71 | 72 | // Schureman equations 227, 226, 68 73 | // Should probably eventually include the derivations of the magic numbers (0.5023 etc). 74 | fK1(a) { 75 | const omega = d2r * a.omega.value 76 | const i = d2r * a.i.value 77 | const I = d2r * a.I.value 78 | const nu = d2r * a.nu.value 79 | const sin2IcosnuMean = 80 | Math.sin(2 * omega) * (1 - (3 / 2.0) * Math.pow(Math.sin(i), 2)) 81 | const mean = 0.5023 * sin2IcosnuMean + 0.1681 82 | return ( 83 | Math.pow( 84 | 0.2523 * Math.pow(Math.sin(2 * I), 2) + 85 | 0.1689 * Math.sin(2 * I) * Math.cos(nu) + 86 | 0.0283, 87 | 0.5 88 | ) / mean 89 | ) 90 | }, 91 | 92 | // Schureman equations 215, 213, 204 93 | // It can be (and has been) confirmed that the exponent for R_a reads 1/2 via Schureman Table 7 94 | fL2(a) { 95 | const P = d2r * a.P.value 96 | const I = d2r * a.I.value 97 | const rAInv = Math.pow( 98 | 1 - 99 | 12 * Math.pow(Math.tan(0.5 * I), 2) * Math.cos(2 * P) + 100 | 36 * Math.pow(Math.tan(0.5 * I), 4), 101 | 0.5 102 | ) 103 | return corrections.fM2(a) * rAInv 104 | }, 105 | 106 | // Schureman equations 235, 234, 71 107 | // Again, magic numbers 108 | fK2(a) { 109 | const omega = d2r * a.omega.value 110 | const i = d2r * a.i.value 111 | const I = d2r * a.I.value 112 | const nu = d2r * a.nu.value 113 | const sinsqIcos2nuMean = 114 | Math.sin(omega) ** 2 * (1 - (3 / 2.0) * Math.sin(i) ** 2) 115 | const mean = 0.5023 * sinsqIcos2nuMean + 0.0365 116 | return ( 117 | Math.pow( 118 | 0.2523 * Math.pow(Math.sin(I), 4) + 119 | 0.0367 * Math.pow(Math.sin(I), 2) * Math.cos(2 * nu) + 120 | 0.0013, 121 | 0.5 122 | ) / mean 123 | ) 124 | }, 125 | // Schureman equations 206, 207, 195 126 | fM1(a) { 127 | const P = d2r * a.P.value 128 | const I = d2r * a.I.value 129 | const qAInv = Math.pow( 130 | 0.25 + 131 | 1.5 * 132 | Math.cos(I) * 133 | Math.cos(2 * P) * 134 | Math.pow(Math.cos(0.5 * I), -0.5) + 135 | 2.25 * Math.pow(Math.cos(I), 2) * Math.pow(Math.cos(0.5 * I), -4), 136 | 0.5 137 | ) 138 | return corrections.fO1(a) * qAInv 139 | }, 140 | 141 | // See e.g. Schureman equation 149 142 | fModd(a, n) { 143 | return Math.pow(corrections.fM2(a), n / 2.0) 144 | }, 145 | 146 | // Node factors u, see Table 2 of Schureman. 147 | 148 | uZero() { 149 | return 0.0 150 | }, 151 | 152 | uMf(a) { 153 | return -2.0 * a.xi.value 154 | }, 155 | 156 | uO1(a) { 157 | return 2.0 * a.xi.value - a.nu.value 158 | }, 159 | 160 | uJ1(a) { 161 | return -a.nu.value 162 | }, 163 | 164 | uOO1(a) { 165 | return -2.0 * a.xi.value - a.nu.value 166 | }, 167 | 168 | uM2(a) { 169 | return 2.0 * a.xi.value - 2.0 * a.nu.value 170 | }, 171 | 172 | uK1(a) { 173 | return -a.nup.value 174 | }, 175 | 176 | // Schureman 214 177 | uL2(a) { 178 | const I = d2r * a.I.value 179 | const P = d2r * a.P.value 180 | const R = 181 | r2d * 182 | Math.atan( 183 | Math.sin(2 * P) / 184 | ((1 / 6.0) * Math.pow(Math.tan(0.5 * I), -2) - Math.cos(2 * P)) 185 | ) 186 | return 2.0 * a.xi.value - 2.0 * a.nu.value - R 187 | }, 188 | 189 | uK2(a) { 190 | return -2.0 * a.nupp.value 191 | }, 192 | 193 | // Schureman 202 194 | uM1(a) { 195 | const I = d2r * a.I.value 196 | const P = d2r * a.P.value 197 | const Q = 198 | r2d * 199 | Math.atan(((5 * Math.cos(I) - 1) / (7 * Math.cos(I) + 1)) * Math.tan(P)) 200 | return a.xi.value - a.nu.value + Q 201 | }, 202 | 203 | uModd(a, n) { 204 | return (n / 2.0) * corrections.uM2(a) 205 | } 206 | } 207 | 208 | export default corrections 209 | -------------------------------------------------------------------------------- /test/_mocks/constituents.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | number: 1, 4 | name: 'M2', 5 | description: 'Principal lunar semidiurnal constituent', 6 | amplitude: 1.61, 7 | phase_GMT: 181.3, 8 | phase_local: 309.4, 9 | speed: 28.984104 10 | }, 11 | { 12 | number: 2, 13 | name: 'S2', 14 | description: 'Principal solar semidiurnal constituent', 15 | amplitude: 0.43, 16 | phase_GMT: 180.1, 17 | phase_local: 300.1, 18 | speed: 30.0 19 | }, 20 | { 21 | number: 3, 22 | name: 'N2', 23 | description: 'Larger lunar elliptic semidiurnal constituent', 24 | amplitude: 0.37, 25 | phase_GMT: 155.0, 26 | phase_local: 287.4, 27 | speed: 28.43973 28 | }, 29 | { 30 | number: 4, 31 | name: 'K1', 32 | description: 'Lunar diurnal constituent', 33 | amplitude: 1.2, 34 | phase_GMT: 219.6, 35 | phase_local: 99.2, 36 | speed: 15.041069 37 | }, 38 | { 39 | number: 5, 40 | name: 'M4', 41 | description: 'Shallow water overtides of principal lunar constituent', 42 | amplitude: 0.0, 43 | phase_GMT: 272.7, 44 | phase_local: 168.9, 45 | speed: 57.96821 46 | }, 47 | { 48 | number: 6, 49 | name: 'O1', 50 | description: 'Lunar diurnal constituent', 51 | amplitude: 0.75, 52 | phase_GMT: 203.5, 53 | phase_local: 91.9, 54 | speed: 13.943035 55 | }, 56 | { 57 | number: 7, 58 | name: 'M6', 59 | description: 'Shallow water overtides of principal lunar constituent', 60 | amplitude: 0.0, 61 | phase_GMT: 0.0, 62 | phase_local: 0.0, 63 | speed: 86.95232 64 | }, 65 | { 66 | number: 8, 67 | name: 'MK3', 68 | description: 'Shallow water terdiurnal', 69 | amplitude: 0.0, 70 | phase_GMT: 0.0, 71 | phase_local: 0.0, 72 | speed: 44.025173 73 | }, 74 | { 75 | number: 9, 76 | name: 'S4', 77 | description: 'Shallow water overtides of principal solar constituent', 78 | amplitude: 0.0, 79 | phase_GMT: 343.5, 80 | phase_local: 223.5, 81 | speed: 60.0 82 | }, 83 | { 84 | number: 10, 85 | name: 'MN4', 86 | description: 'Shallow water quarter diurnal constituent', 87 | amplitude: 0.0, 88 | phase_GMT: 233.4, 89 | phase_local: 134.0, 90 | speed: 57.423832 91 | }, 92 | { 93 | number: 11, 94 | name: 'NU2', 95 | description: 'Larger lunar evectional constituent', 96 | amplitude: 0.07, 97 | phase_GMT: 160.9, 98 | phase_local: 292.8, 99 | speed: 28.512583 100 | }, 101 | { 102 | number: 12, 103 | name: 'S6', 104 | description: 'Shallow water overtides of principal solar constituent', 105 | amplitude: 0.0, 106 | phase_GMT: 0.0, 107 | phase_local: 0.0, 108 | speed: 90.0 109 | }, 110 | { 111 | number: 13, 112 | name: 'MU2', 113 | description: 'Variational constituent', 114 | amplitude: 0.04, 115 | phase_GMT: 114.7, 116 | phase_local: 250.9, 117 | speed: 27.968208 118 | }, 119 | { 120 | number: 14, 121 | name: '2N2', 122 | description: 'Lunar elliptical semidiurnal second-order constituent', 123 | amplitude: 0.05, 124 | phase_GMT: 125.4, 125 | phase_local: 262.2, 126 | speed: 27.895355 127 | }, 128 | { 129 | number: 15, 130 | name: 'OO1', 131 | description: 'Lunar diurnal', 132 | amplitude: 0.04, 133 | phase_GMT: 246.4, 134 | phase_local: 117.3, 135 | speed: 16.139101 136 | }, 137 | { 138 | number: 16, 139 | name: 'LAM2', 140 | description: 'Smaller lunar evectional constituent', 141 | amplitude: 0.01, 142 | phase_GMT: 213.6, 143 | phase_local: 337.9, 144 | speed: 29.455626 145 | }, 146 | { 147 | number: 17, 148 | name: 'S1', 149 | description: 'Solar diurnal constituent', 150 | amplitude: 0.03, 151 | phase_GMT: 317.1, 152 | phase_local: 197.1, 153 | speed: 15.0 154 | }, 155 | { 156 | number: 18, 157 | name: 'M1', 158 | description: 'Smaller lunar elliptic diurnal constituent', 159 | amplitude: 0.04, 160 | phase_GMT: 224.9, 161 | phase_local: 108.9, 162 | speed: 14.496694 163 | }, 164 | { 165 | number: 19, 166 | name: 'J1', 167 | description: 'Smaller lunar elliptic diurnal constituent', 168 | amplitude: 0.07, 169 | phase_GMT: 232.3, 170 | phase_local: 107.5, 171 | speed: 15.5854435 172 | }, 173 | { 174 | number: 20, 175 | name: 'MM', 176 | description: 'Lunar monthly constituent', 177 | amplitude: 0.0, 178 | phase_GMT: 0.0, 179 | phase_local: 0.0, 180 | speed: 0.5443747 181 | }, 182 | { 183 | number: 21, 184 | name: 'SSA', 185 | description: 'Solar semiannual constituent', 186 | amplitude: 0.07, 187 | phase_GMT: 264.6, 188 | phase_local: 263.9, 189 | speed: 0.0821373 190 | }, 191 | { 192 | number: 22, 193 | name: 'SA', 194 | description: 'Solar annual constituent', 195 | amplitude: 0.2, 196 | phase_GMT: 198.5, 197 | phase_local: 198.1, 198 | speed: 0.0410686 199 | }, 200 | { 201 | number: 23, 202 | name: 'MSF', 203 | description: 'Lunisolar synodic fortnightly constituent', 204 | amplitude: 0.0, 205 | phase_GMT: 0.0, 206 | phase_local: 0.0, 207 | speed: 1.0158958 208 | }, 209 | { 210 | number: 24, 211 | name: 'MF', 212 | description: 'Lunisolar fortnightly constituent', 213 | amplitude: 0.04, 214 | phase_GMT: 138.1, 215 | phase_local: 129.3, 216 | speed: 1.0980331 217 | }, 218 | { 219 | number: 25, 220 | name: 'RHO', 221 | description: 'Larger lunar evectional diurnal constituent', 222 | amplitude: 0.02, 223 | phase_GMT: 197.0, 224 | phase_local: 89.2, 225 | speed: 13.471515 226 | }, 227 | { 228 | number: 26, 229 | name: 'Q1', 230 | description: 'Larger lunar elliptic diurnal constituent', 231 | amplitude: 0.13, 232 | phase_GMT: 194.8, 233 | phase_local: 87.5, 234 | speed: 13.398661 235 | }, 236 | { 237 | number: 27, 238 | name: 'T2', 239 | description: 'Larger solar elliptic constituent', 240 | amplitude: 0.02, 241 | phase_GMT: 165.3, 242 | phase_local: 285.6, 243 | speed: 29.958933 244 | }, 245 | { 246 | number: 28, 247 | name: 'R2', 248 | description: 'Smaller solar elliptic constituent', 249 | amplitude: 0.0, 250 | phase_GMT: 163.2, 251 | phase_local: 282.9, 252 | speed: 30.041067 253 | }, 254 | { 255 | number: 29, 256 | name: '2Q1', 257 | description: 'Larger elliptic diurnal', 258 | amplitude: 0.02, 259 | phase_GMT: 191.7, 260 | phase_local: 88.8, 261 | speed: 12.854286 262 | }, 263 | { 264 | number: 30, 265 | name: 'P1', 266 | description: 'Solar diurnal constituent', 267 | amplitude: 0.38, 268 | phase_GMT: 215.8, 269 | phase_local: 96.1, 270 | speed: 14.958931 271 | }, 272 | { 273 | number: 31, 274 | name: '2SM2', 275 | description: 'Shallow water semidiurnal constituent', 276 | amplitude: 0.0, 277 | phase_GMT: 348.1, 278 | phase_local: 99.9, 279 | speed: 31.015896 280 | }, 281 | { 282 | number: 32, 283 | name: 'M3', 284 | description: 'Lunar terdiurnal constituent', 285 | amplitude: 0.01, 286 | phase_GMT: 7.0, 287 | phase_local: 19.1, 288 | speed: 43.47616 289 | }, 290 | { 291 | number: 33, 292 | name: 'L2', 293 | description: 'Smaller lunar elliptic semidiurnal constituent', 294 | amplitude: 0.04, 295 | phase_GMT: 213.4, 296 | phase_local: 337.1, 297 | speed: 29.528479 298 | }, 299 | { 300 | number: 34, 301 | name: '2MK3', 302 | description: 'Shallow water terdiurnal constituent', 303 | amplitude: 0.0, 304 | phase_GMT: 126.8, 305 | phase_local: 143.4, 306 | speed: 42.92714 307 | }, 308 | { 309 | number: 35, 310 | name: 'K2', 311 | description: 'Lunisolar semidiurnal constituent', 312 | amplitude: 0.12, 313 | phase_GMT: 170.6, 314 | phase_local: 289.9, 315 | speed: 30.082138 316 | }, 317 | { 318 | number: 36, 319 | name: 'M8', 320 | description: 'Shallow water eighth diurnal constituent', 321 | amplitude: 0.0, 322 | phase_GMT: 0.0, 323 | phase_local: 0.0, 324 | speed: 115.93642 325 | }, 326 | { 327 | number: 37, 328 | name: 'MS4', 329 | description: 'Shallow water quarter diurnal constituent', 330 | amplitude: 0.0, 331 | phase_GMT: 264.0, 332 | phase_local: 152.1, 333 | speed: 58.984104 334 | } 335 | ] 336 | -------------------------------------------------------------------------------- /test/_mocks/secondary-station.js: -------------------------------------------------------------------------------- 1 | export default { 2 | reference_station: '', 3 | height_offset: { 4 | high: 1, 5 | low: 2 6 | }, 7 | time_offset: { 8 | high: 1, 9 | low: 2 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/_mocks/water-levels.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neaps/tide-predictor/d4c87d91bb13dad56cd72a9f875ac50c37007dac/test/_mocks/water-levels.js -------------------------------------------------------------------------------- /test/astronomy/coefficients.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import coefficients, { 3 | sexagesimalToDecimal 4 | } from '../../src/astronomy/coefficients.js' 5 | 6 | describe('astronomy coefficients', () => { 7 | it('converts a sexagesimal angle into decimal degrees', () => { 8 | assert.ok(sexagesimalToDecimal(10, 10, 10, 10, 10) === 10.169447225) 9 | assert.ok(sexagesimalToDecimal(10) === 10) 10 | }) 11 | 12 | it('calculates terrestrial oliquity coefficients rewritten to T', () => { 13 | assert.ok(coefficients.terrestrialObliquity[1] === -0.013002583333333335) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /test/astronomy/index.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import closeTo from '../lib/close-to.js' 3 | import astro, { 4 | polynomial, 5 | derivativePolynomial, 6 | JD, 7 | T, 8 | _I, 9 | _xi, 10 | _nu, 11 | _nup, 12 | _nupp 13 | } from '../../src/astronomy/index.js' 14 | 15 | const sampleTime = new Date() 16 | sampleTime.setFullYear(2019) 17 | sampleTime.setMonth(9) 18 | sampleTime.setDate(4) 19 | sampleTime.setHours(10) 20 | sampleTime.setMinutes(15) 21 | sampleTime.setSeconds(40) 22 | sampleTime.setMilliseconds(10) 23 | 24 | describe('astronomy', () => { 25 | it('complete astronomic calculation', () => { 26 | const result = astro(sampleTime) 27 | closeTo(result.s.value, 258.23871057233191, 4) 28 | closeTo(result.s.speed, 0.54901651929993922, 4) 29 | 30 | closeTo(result.pp.value, 283.27697979858613, 4) 31 | closeTo(result.pp.speed, 1.9612154426341654e-6, 4) 32 | 33 | closeTo(result.h.value, 192.82639897760328, 4) 34 | closeTo(result.h.speed, 0.041068640143510367, 4) 35 | 36 | closeTo(result.xi.value, 11.989946298635664, 4) 37 | assert.ok(result.xi.speed === null) 38 | 39 | closeTo(result.I.value, 22.811296275568843, 4) 40 | assert.ok(result.I.speed === null) 41 | 42 | closeTo(result.P.value, 155.24265065565865, 4) 43 | assert.ok(result.P.speed === null) 44 | 45 | closeTo(result.nupp.value, 8.8162480626605451, 4) 46 | assert.ok(result.nupp.speed === null) 47 | 48 | closeTo(result.nu.value, 13.028571777192044, 4) 49 | assert.ok(result.nu.speed === null) 50 | 51 | closeTo(result['T+h-s'].value, 268.50435506200392, 4) 52 | closeTo(result['T+h-s'].speed, 14.492052120843571, 4) 53 | 54 | closeTo(result.omega.value, 23.436722306067253, 4) 55 | closeTo(result.omega.speed, -1.4832917321024327e-8, 4) 56 | }) 57 | 58 | it('evaluates a polynomial', () => { 59 | assert.ok(polynomial([1, 2, 3], 3) === 34) 60 | }) 61 | 62 | it('evaluates derivative polynomials', () => { 63 | assert.ok(derivativePolynomial([1, 2, 3], 3) === 20) 64 | }) 65 | 66 | it('evaluates Meeus formula 7.1 (JD) correctly', () => { 67 | sampleTime.setMonth(9) 68 | closeTo(JD(sampleTime), 2458760.92755, 2) 69 | // Months of less than 2 go back a year 70 | sampleTime.setMonth(0) 71 | closeTo(JD(sampleTime), 2458487.92755, 2) 72 | }) 73 | 74 | it('evaluates Meeus formula 11.1 (T) correctly', () => { 75 | sampleTime.setMonth(9) 76 | closeTo(T(sampleTime), 0.19756132, 2) 77 | }) 78 | 79 | it('evaluates value for _I correctly', () => { 80 | closeTo(_I(4, 10, 5), 14.9918364991, 4) 81 | }) 82 | 83 | it('evaluates value for _xi correctly', () => { 84 | closeTo(_xi(4, 3, 10), 0.911946348144, 4) 85 | }) 86 | 87 | it('evaluates value for _nu correctly', () => { 88 | closeTo(_nu(10, 4, 5), 4.45767377718, 4) 89 | }) 90 | 91 | it('evaluates value for _nup correctly', () => { 92 | closeTo(_nup(10, 4, 5), 2.13580480226, 4) 93 | }) 94 | 95 | it('evaluates value for _nupp correctly', () => { 96 | closeTo(_nupp(10, 4, 5), 1.1146589591, 4) 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /test/constituents/compound-constituent.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import closeTo from '../lib/close-to.js' 3 | import compoundConstituent from '../../src/constituents/compound-constituent.js' 4 | import Constituent from '../../src/constituents/constituent.js' 5 | import astro from '../../src/astronomy/index.js' 6 | 7 | const sampleTime = new Date() 8 | sampleTime.setFullYear(2019) 9 | sampleTime.setMonth(9) 10 | sampleTime.setDate(4) 11 | sampleTime.setHours(10) 12 | sampleTime.setMinutes(15) 13 | sampleTime.setSeconds(40) 14 | sampleTime.setMilliseconds(10) 15 | 16 | const testAstro = astro(sampleTime) 17 | 18 | // This is a made-up doodson number for a test coefficient 19 | const testConstituentA = Constituent('testa', [1, 1, -1, 0, 0, 0, 1]) 20 | const testConstituentB = Constituent('testb', [0, 1, -1, 0, 0, 0, 1]) 21 | 22 | const compoundTest = compoundConstituent('test compound', [ 23 | { constituent: testConstituentA, factor: 1 }, 24 | { constituent: testConstituentB, factor: -1 } 25 | ]) 26 | describe('compund constituent', () => { 27 | it('it calculates compound coefficients', () => { 28 | assert.deepStrictEqual(compoundTest.coefficients, [1, 0, 0, 0, 0, 0, 0]) 29 | }) 30 | 31 | it('it calculates speed', () => { 32 | closeTo(compoundTest.speed(testAstro), 14.4920521208, 4) 33 | }) 34 | 35 | it('it calculates value', () => { 36 | closeTo(compoundTest.value(testAstro), 268.504355062, 4) 37 | }) 38 | 39 | it('it returns u correctly', () => { 40 | assert.ok(compoundTest.u(testAstro) === 0) 41 | }) 42 | 43 | it('it returns f correctly', () => { 44 | assert.ok(compoundTest.f(testAstro) === 1) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /test/constituents/constituent.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import closeTo from '../lib/close-to.js' 3 | import constituent, { 4 | astronimicDoodsonNumber, 5 | astronomicSpeed, 6 | astronomicValues 7 | } from '../../src/constituents/constituent.js' 8 | import astro from '../../src/astronomy/index.js' 9 | 10 | const sampleTime = new Date() 11 | sampleTime.setFullYear(2019) 12 | sampleTime.setMonth(9) 13 | sampleTime.setDate(4) 14 | sampleTime.setHours(10) 15 | sampleTime.setMinutes(15) 16 | sampleTime.setSeconds(40) 17 | sampleTime.setMilliseconds(10) 18 | 19 | const testAstro = astro(sampleTime) 20 | 21 | // This is a made-up doodson number for a test coefficient 22 | const testConstituent = constituent('test', [1, 1, -1, 0, 0, 0, 1]) 23 | 24 | describe('constituent', () => { 25 | it('it throws error if missing coefficients', () => { 26 | let errorMessage = false 27 | try { 28 | const a = constituent('fail') // eslint-disable-line 29 | } catch (error) { 30 | errorMessage = error 31 | } 32 | assert.ok( 33 | errorMessage.message === 'Coefficient must be defined for a constituent' 34 | ) 35 | }) 36 | 37 | it('it fetches astronimic Doodson Number values', () => { 38 | const values = astronimicDoodsonNumber(testAstro) 39 | assert.ok(values[0].value === testAstro['T+h-s'].value) 40 | }) 41 | 42 | it('it fetches astronimic speed', () => { 43 | const values = astronomicSpeed(testAstro) 44 | assert.ok(values[0] === testAstro['T+h-s'].speed) 45 | }) 46 | 47 | it('it fetches astronimic values', () => { 48 | const values = astronomicValues(testAstro) 49 | assert.ok(values[0] === testAstro['T+h-s'].value) 50 | }) 51 | 52 | it('it computes constituent value', () => { 53 | closeTo(testConstituent.value(testAstro), 423.916666657, 4) 54 | }) 55 | 56 | it('it computes constituent speed', () => { 57 | assert.ok(testConstituent.speed(testAstro) === 15) 58 | }) 59 | 60 | it('it returns u correctly', () => { 61 | assert.ok(testConstituent.u(testAstro) === 0) 62 | }) 63 | 64 | it('it returns f correctly', () => { 65 | assert.ok(testConstituent.f(testAstro) === 1) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /test/constituents/index.js: -------------------------------------------------------------------------------- 1 | import closeTo from '../lib/close-to.js' 2 | import constituents from '../../src/constituents/index.js' 3 | import astro from '../../src/astronomy/index.js' 4 | 5 | const sampleTime = new Date() 6 | sampleTime.setFullYear(2019) 7 | sampleTime.setMonth(9) 8 | sampleTime.setDate(4) 9 | sampleTime.setHours(10) 10 | sampleTime.setMinutes(15) 11 | sampleTime.setSeconds(40) 12 | sampleTime.setMilliseconds(10) 13 | 14 | const testAstro = astro(sampleTime) 15 | 16 | describe('Base constituent definitions', () => { 17 | it('it prepared constituent SA', () => { 18 | closeTo(constituents.SA.value(testAstro), 192.826398978, 4) 19 | }) 20 | 21 | it('it prepared constituent SSA', () => { 22 | closeTo(constituents.SSA.value(testAstro), 385.652797955, 4) 23 | }) 24 | 25 | it('it prepared constituent M2', () => { 26 | closeTo(constituents.M2.value(testAstro), 537.008710124, 4) 27 | closeTo(constituents.M2.u(testAstro), -2.07725095711, 4) 28 | closeTo(constituents.M2.f(testAstro), 1.00853563237, 4) 29 | }) 30 | 31 | it('has a correct lambda for M3', () => { 32 | closeTo(constituents.M3.u(testAstro), -3.11587643567, 4) 33 | closeTo(constituents.M3.f(testAstro), 1.01283073119, 4) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /test/harmonics/index.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import harmonics, { getDate, getTimeline } from '../../src/harmonics/index.js' 3 | import mockHarmonicConstituents from '../_mocks/constituents.js' 4 | 5 | const startDate = new Date(1567346400 * 1000) // 2019-09-01 6 | const endDate = new Date(1569966078 * 1000) // 2019-10-01 7 | 8 | describe('harmonics', () => { 9 | it('it checks constituents', () => { 10 | let errorMessage = false 11 | 12 | try { 13 | harmonics({ harmonicConstituents: 'not array' }) 14 | } catch (error) { 15 | errorMessage = error 16 | } 17 | assert.ok(errorMessage.message === 'Harmonic constituents are not an array') 18 | 19 | errorMessage = false 20 | 21 | try { 22 | harmonics({ 23 | harmonicConstituents: [ 24 | { 25 | name: 'M2', 26 | description: 'Principal lunar semidiurnal constituent', 27 | amplitude: 1.61, 28 | phase_GMT: 181.3, 29 | phase_local: 309.4, 30 | speed: 28.984104 31 | }, 32 | { 33 | description: 'Principal solar semidiurnal constituent', 34 | amplitude: 0.43, 35 | phase_GMT: 180.1, 36 | phase_local: 309.4 37 | } 38 | ] 39 | }) 40 | } catch (error) { 41 | errorMessage = error 42 | } 43 | assert.ok( 44 | errorMessage.message === 'Harmonic constituents must have a name property' 45 | ) 46 | 47 | errorMessage = false 48 | 49 | try { 50 | harmonics({ 51 | harmonicConstituents: [ 52 | { 53 | name: 'not a name', 54 | description: 'Principal lunar semidiurnal constituent', 55 | amplitude: 1.61, 56 | phase_GMT: 181.3, 57 | phase_local: 309.4, 58 | speed: 28.984104 59 | }, 60 | { 61 | name: 'M2', 62 | description: 'Principal solar semidiurnal constituent', 63 | amplitude: 0.43, 64 | phase_GMT: 180.1, 65 | phase_local: 309.4 66 | } 67 | ] 68 | }) 69 | } catch (error) { 70 | errorMessage = error 71 | } 72 | assert.ok(!errorMessage.message) 73 | }) 74 | 75 | it('it checks start and end times', () => { 76 | const testHarmonics = harmonics({ 77 | harmonicConstituents: mockHarmonicConstituents 78 | }) 79 | let timeErrorMessage = false 80 | try { 81 | testHarmonics.setTimeSpan('lkjsdlf', 'sdfklj') 82 | } catch (error) { 83 | timeErrorMessage = error 84 | } 85 | assert.ok( 86 | timeErrorMessage.message === 87 | 'Invalid date format, should be a Date object, or timestamp' 88 | ) 89 | 90 | timeErrorMessage = false 91 | try { 92 | testHarmonics.setTimeSpan(startDate, startDate) 93 | } catch (error) { 94 | timeErrorMessage = error 95 | } 96 | assert.ok(timeErrorMessage.message === 'Start time must be before end time') 97 | 98 | timeErrorMessage = false 99 | try { 100 | testHarmonics.setTimeSpan(startDate, endDate) 101 | } catch (error) { 102 | timeErrorMessage = error 103 | } 104 | assert.ok(!timeErrorMessage.message) 105 | }) 106 | 107 | it('it parses dates correctly', () => { 108 | const parsedDate = getDate(startDate) 109 | assert.ok(parsedDate.getTime() === startDate.getTime()) 110 | 111 | const parsedUnixDate = getDate(startDate.getTime() / 1000) 112 | assert.ok(parsedUnixDate.getTime() === startDate.getTime()) 113 | }) 114 | 115 | it('it creates timeline correctly', () => { 116 | const seconds = 20 * 60 117 | const difference = 118 | Math.round( 119 | (endDate.getTime() / 1000 - startDate.getTime() / 1000) / seconds 120 | ) + 1 121 | const { items, hours } = getTimeline(startDate, endDate, seconds) 122 | assert.ok(items.length === difference) 123 | assert.ok(hours.length === difference) 124 | }) 125 | }) 126 | -------------------------------------------------------------------------------- /test/harmonics/prediction.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import closeTo from '../lib/close-to.js' 3 | import harmonics from '../../src/harmonics/index.js' 4 | import mockHarmonicConstituents from '../_mocks/constituents.js' 5 | import mockSecondaryStation from '../_mocks/secondary-station.js' 6 | 7 | const startDate = new Date() 8 | startDate.setFullYear(2019) 9 | startDate.setMonth(8) 10 | startDate.setDate(1) 11 | startDate.setHours(0) 12 | startDate.setMinutes(0) 13 | startDate.setSeconds(0) 14 | startDate.setMilliseconds(0) 15 | 16 | const endDate = new Date() 17 | endDate.setFullYear(2019) 18 | endDate.setMonth(8) 19 | endDate.setDate(1) 20 | endDate.setHours(6) 21 | endDate.setMinutes(0) 22 | endDate.setSeconds(0) 23 | endDate.setMilliseconds(0) 24 | 25 | const extremesEndDate = new Date() 26 | extremesEndDate.setFullYear(2019) 27 | extremesEndDate.setMonth(8) 28 | extremesEndDate.setDate(3) 29 | extremesEndDate.setHours(0) 30 | extremesEndDate.setMinutes(0) 31 | extremesEndDate.setSeconds(0) 32 | extremesEndDate.setMilliseconds(0) 33 | 34 | const setUpPrediction = () => { 35 | const harmonic = harmonics({ 36 | harmonicConstituents: mockHarmonicConstituents, 37 | phaseKey: 'phase_GMT', 38 | offset: false 39 | }) 40 | harmonic.setTimeSpan(startDate, endDate) 41 | return harmonic.prediction() 42 | } 43 | 44 | describe('harmonic prediction', () => { 45 | it('it creates a timeline prediction', () => { 46 | const testPrediction = setUpPrediction() 47 | const results = testPrediction.getTimelinePrediction() 48 | const lastResult = results.pop() 49 | closeTo(results[0].level, -1.347125, 3) 50 | closeTo(lastResult.level, 2.85263589, 3) 51 | }) 52 | 53 | it('it creates a timeline prediction with a non-default phase key', () => { 54 | const results = harmonics({ 55 | harmonicConstituents: mockHarmonicConstituents, 56 | phaseKey: 'phase_local', 57 | offset: false 58 | }) 59 | .setTimeSpan(startDate, endDate) 60 | .prediction() 61 | .getTimelinePrediction() 62 | closeTo(results[0].level, 2.7560979, 3) 63 | closeTo(results.pop().level, -2.9170977, 3) 64 | }) 65 | 66 | it('it finds high and low tides', () => { 67 | const results = harmonics({ 68 | harmonicConstituents: mockHarmonicConstituents, 69 | phaseKey: 'phase_GMT', 70 | offset: false 71 | }) 72 | .setTimeSpan(startDate, extremesEndDate) 73 | .prediction() 74 | .getExtremesPrediction() 75 | closeTo(results[0].level, -1.5650332, 4) 76 | 77 | const customLabels = { 78 | high: 'Super high', 79 | low: 'Wayyy low' 80 | } 81 | 82 | const labelResults = harmonics({ 83 | harmonicConstituents: mockHarmonicConstituents, 84 | phaseKey: 'phase_GMT', 85 | offset: false 86 | }) 87 | .setTimeSpan(startDate, extremesEndDate) 88 | .prediction() 89 | .getExtremesPrediction({ labels: customLabels }) 90 | assert.ok(labelResults[0].label === customLabels.low) 91 | }) 92 | 93 | it('it finds high and low tides with high fidelity', () => { 94 | const results = harmonics({ 95 | harmonicConstituents: mockHarmonicConstituents, 96 | phaseKey: 'phase_GMT', 97 | offset: false 98 | }) 99 | .setTimeSpan(startDate, extremesEndDate) 100 | .prediction({ timeFidelity: 60 }) 101 | .getExtremesPrediction() 102 | closeTo(results[0].level, -1.5653894, 4) 103 | }) 104 | }) 105 | 106 | describe('Secondary stations', () => { 107 | it('it can add offsets to secondary stations', () => { 108 | const regularResults = harmonics({ 109 | harmonicConstituents: mockHarmonicConstituents, 110 | phaseKey: 'phase_GMT', 111 | offset: false 112 | }) 113 | .setTimeSpan(startDate, extremesEndDate) 114 | .prediction() 115 | .getExtremesPrediction() 116 | 117 | const offsetResults = harmonics({ 118 | harmonicConstituents: mockHarmonicConstituents, 119 | phaseKey: 'phase_GMT', 120 | offset: false 121 | }) 122 | .setTimeSpan(startDate, extremesEndDate) 123 | .prediction() 124 | .getExtremesPrediction({ offsets: mockSecondaryStation }) 125 | 126 | offsetResults.forEach((offsetResult, index) => { 127 | if (offsetResult.low) { 128 | closeTo( 129 | offsetResult.level, 130 | regularResults[index].level * mockSecondaryStation.height_offset.low, 131 | 4 132 | ) 133 | assert.ok( 134 | offsetResult.time.getTime() === 135 | regularResults[index].time.getTime() + 136 | mockSecondaryStation.time_offset.low * 60 * 1000 137 | ) 138 | } 139 | if (offsetResult.high) { 140 | closeTo( 141 | offsetResult.level, 142 | regularResults[index].level * mockSecondaryStation.height_offset.high, 143 | 4 144 | ) 145 | 146 | assert.ok( 147 | offsetResult.time.getTime() === 148 | regularResults[index].time.getTime() + 149 | mockSecondaryStation.time_offset.high * 60 * 1000 150 | ) 151 | } 152 | }) 153 | }) 154 | }) 155 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import closeTo from './lib/close-to.js' 3 | import mockConstituents from './_mocks/constituents.js' 4 | import tidePrediction from '../src/index.js' 5 | 6 | const startDate = new Date() 7 | startDate.setFullYear(2019) 8 | startDate.setMonth(8) 9 | startDate.setDate(1) 10 | startDate.setHours(0) 11 | startDate.setMinutes(0) 12 | startDate.setSeconds(0) 13 | startDate.setMilliseconds(0) 14 | 15 | const endDate = new Date() 16 | endDate.setFullYear(2019) 17 | endDate.setMonth(8) 18 | endDate.setDate(1) 19 | endDate.setHours(6) 20 | endDate.setMinutes(0) 21 | endDate.setSeconds(0) 22 | endDate.setMilliseconds(0) 23 | 24 | describe('Tidal station', () => { 25 | it('it is created correctly', () => { 26 | let stationCreated = true 27 | 28 | try { 29 | tidePrediction(mockConstituents) 30 | } catch (e) { 31 | stationCreated = false 32 | } 33 | assert.ok(stationCreated) 34 | 35 | try { 36 | tidePrediction(mockConstituents) 37 | } catch (e) { 38 | stationCreated = false 39 | } 40 | assert.ok(stationCreated) 41 | }) 42 | 43 | it('it predicts the tides in a timeline', () => { 44 | const results = tidePrediction(mockConstituents).getTimelinePrediction({ 45 | start: startDate, 46 | end: endDate 47 | }) 48 | closeTo(results[0].level, -1.34712509, 3) 49 | closeTo(results.pop().level, 2.85263589, 3) 50 | }) 51 | 52 | it('it predicts the tidal extremes', () => { 53 | const results = tidePrediction(mockConstituents).getExtremesPrediction({ 54 | start: startDate, 55 | end: endDate 56 | }) 57 | closeTo(results[0].level, -1.565033, 4) 58 | }) 59 | 60 | it('it predicts the tidal extremes with high fidelity', () => { 61 | const results = tidePrediction(mockConstituents).getExtremesPrediction({ 62 | start: startDate, 63 | end: endDate, 64 | timeFidelity: 60 65 | }) 66 | closeTo(results[0].level, -1.565389, 4) 67 | }) 68 | 69 | it('it fetches a single water level', () => { 70 | const result = tidePrediction(mockConstituents).getWaterLevelAtTime({ 71 | time: startDate 72 | }) 73 | closeTo(result.level, -1.34712509, 4) 74 | }) 75 | 76 | it('it adds offset phases', () => { 77 | const results = tidePrediction(mockConstituents, { 78 | offset: 3 79 | }).getExtremesPrediction({ start: startDate, end: endDate }) 80 | 81 | closeTo(results[0].level, 1.43496678, 4) 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /test/lib/close-to.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | 3 | export default (received, expected, precision) => { 4 | const expectedDiff = Math.pow(10, -precision) / 2 5 | const receivedDiff = Math.abs(expected - received) 6 | assert.ok(receivedDiff < expectedDiff) 7 | } 8 | -------------------------------------------------------------------------------- /test/noaa.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import fs from 'fs' 3 | import https from 'https' 4 | import tidePrediction from '../src/index.js' 5 | 6 | // Create a directory for test cache 7 | if (!fs.existsSync('./.test-cache')) { 8 | fs.mkdirSync('./.test-cache') 9 | } 10 | 11 | const stations = ['9413450', '9411340', '2695535', '8761724', '8410140'] 12 | 13 | const makeRequest = (url) => 14 | new Promise((resolve, reject) => { 15 | https.get(url, (response) => { 16 | const data = [] 17 | 18 | response.on('data', (fragment) => { 19 | data.push(fragment) 20 | }) 21 | 22 | response.on('end', () => { 23 | const body = Buffer.concat(data) 24 | resolve(JSON.parse(body.toString())) 25 | }) 26 | 27 | response.on('error', (error) => { 28 | reject(error) 29 | }) 30 | }) 31 | }) 32 | 33 | const getStation = (station, callback) => { 34 | const filePath = `./.test-cache/${station}.json` 35 | if (fs.existsSync(filePath)) { 36 | fs.readFile(filePath, (err, data) => { 37 | if (err) { 38 | throw new Error('Cannot access test cache') 39 | } 40 | callback(JSON.parse(data)) 41 | }) 42 | return 43 | } 44 | const stationData = {} 45 | const tasks = [] 46 | 47 | tasks.push( 48 | makeRequest( 49 | `https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/${station}/harcon.json?units=metric` 50 | ).then((data) => { 51 | stationData.harmonics = data 52 | }) 53 | ) 54 | 55 | tasks.push( 56 | makeRequest( 57 | `https://api.tidesandcurrents.noaa.gov/api/prod/datagetter?date=recent&station=${station}&product=predictions&datum=MTL&time_zone=gmt&units=metric&format=json` 58 | ).then((data) => { 59 | stationData.levels = data 60 | }) 61 | ) 62 | 63 | tasks.push( 64 | makeRequest( 65 | `https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/${station}/datums.json?units=metric` 66 | ).then((data) => { 67 | stationData.info = data 68 | }) 69 | ) 70 | 71 | Promise.all(tasks).then(() => { 72 | fs.writeFile(filePath, JSON.stringify(stationData), (error) => { 73 | if (error) { 74 | throw new Error('Cannot write to test cache') 75 | } 76 | callback(stationData) 77 | }) 78 | }) 79 | } 80 | 81 | describe('Results compare to NOAA', () => { 82 | stations.forEach((station) => { 83 | it(`it compares with station ${station}`, (done) => { 84 | getStation(station, ({ harmonics, levels, info }) => { 85 | let mtl = 0 86 | let mllw = 0 87 | info.datums.forEach((datum) => { 88 | if (datum.name === 'MTL') { 89 | mtl = datum.value 90 | } 91 | if (datum.name === 'MLLW') { 92 | mllw = datum.value 93 | } 94 | }) 95 | const tideStation = tidePrediction( 96 | harmonics.HarmonicConstituents, 97 | mtl - mllw 98 | ) 99 | levels.predictions.forEach((prediction) => { 100 | const neapsPrediction = tideStation.getWaterLevelAtTime({ 101 | time: new Date(prediction.t) 102 | }) 103 | assert.ok(parseFloat(prediction.v) >= neapsPrediction.level - 0.5) 104 | assert.ok(parseFloat(prediction.v) <= neapsPrediction.level + 0.5) 105 | }) 106 | done() 107 | }) 108 | }).timeout(20000) 109 | }) 110 | }) 111 | -------------------------------------------------------------------------------- /test/node-corrections/index.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import closeTo from '../lib/close-to.js' 3 | import nodeCorrections from '../../src/node-corrections/index.js' 4 | 5 | const testItems = { 6 | i: { 7 | value: 5 8 | }, 9 | I: { value: 6 }, 10 | omega: { value: 3 }, 11 | nu: { value: 4 }, 12 | nup: { value: 4 }, 13 | nupp: { value: 2 }, 14 | P: { value: 14 }, 15 | xi: { 16 | value: 4 17 | } 18 | } 19 | 20 | describe('Node corrections', () => { 21 | it('have correct unity', () => { 22 | assert.ok(nodeCorrections.fUnity() === 1) 23 | }) 24 | 25 | it('calculates Schureman equations 73, 65 (f_Mm)', () => { 26 | closeTo(nodeCorrections.fMm(testItems), 0.999051998091, 4) 27 | }) 28 | 29 | it('calculates Schureman equations 74, 66 (f_Mf)', () => { 30 | closeTo(nodeCorrections.fMf(testItems), 4.00426673883, 4) 31 | }) 32 | 33 | it('calculates Schureman equations 75, 67 (f_O1)', () => { 34 | closeTo(nodeCorrections.fO1(testItems), 2.00076050158, 4) 35 | }) 36 | 37 | it('calculates Schureman equations 76, 68 (f_J1)', () => { 38 | closeTo(nodeCorrections.fJ1(testItems), 2.0119685329, 4) 39 | }) 40 | 41 | it('calculates Schureman equations 77, 69 (f_OO1)', () => { 42 | closeTo(nodeCorrections.fOO1(testItems), 8.01402871709, 4) 43 | }) 44 | 45 | it('calculates Schureman equations 78, 70 (f_M2)', () => { 46 | closeTo(nodeCorrections.fM2(testItems), 0.999694287563, 4) 47 | }) 48 | 49 | it('calculates Schureman equations 227, 226, 68 (f_K1)', () => { 50 | closeTo(nodeCorrections.fK1(testItems), 1.23843964182, 4) 51 | }) 52 | 53 | it('calculates Schureman equations 215, 213, 204 (f_L2)', () => { 54 | closeTo(nodeCorrections.fL2(testItems), 0.98517860327, 4) 55 | }) 56 | 57 | it('calculates Schureman equations 235, 234, 71 (f_K2)', () => { 58 | closeTo(nodeCorrections.fK2(testItems), 1.09775430048, 4) 59 | }) 60 | 61 | it('calculates Schureman equations 206, 207, 195 (f_M1)', () => { 62 | closeTo(nodeCorrections.fM1(testItems), 3.90313810168, 4) 63 | }) 64 | 65 | it('calculates e.g. Schureman equation 149 (f_Modd)', () => { 66 | closeTo(nodeCorrections.fModd(testItems, 3), 0.999541466395, 4) 67 | }) 68 | 69 | it('has a zero for u_zero', () => { 70 | assert.ok(nodeCorrections.uZero() === 0.0) 71 | }) 72 | 73 | it('calculates u_Mf', () => { 74 | assert.ok(nodeCorrections.uMf(testItems) === -8.0) 75 | }) 76 | 77 | it('calculates u_O1', () => { 78 | assert.ok(nodeCorrections.uO1(testItems) === 4.0) 79 | }) 80 | 81 | it('calculates u_J1', () => { 82 | assert.ok(nodeCorrections.uJ1(testItems) === -4) 83 | }) 84 | 85 | it('calculates u_OO1', () => { 86 | assert.ok(nodeCorrections.uOO1(testItems) === -12.0) 87 | }) 88 | 89 | it('calculates u_M2', () => { 90 | assert.ok(nodeCorrections.uM2(testItems) === 0.0) 91 | }) 92 | 93 | it('calculates u_K1', () => { 94 | assert.ok(nodeCorrections.uK1(testItems) === -4) 95 | }) 96 | 97 | it('calculates u_L2', () => { 98 | closeTo(nodeCorrections.uL2(testItems), -0.449812364499, 4) 99 | }) 100 | 101 | it('calculates u_K2', () => { 102 | assert.ok(nodeCorrections.uK2(testItems) === -4.0) 103 | }) 104 | 105 | it('calculates u_K2', () => { 106 | assert.ok(nodeCorrections.uK2(testItems) === -4.0) 107 | }) 108 | 109 | it('calculates u_M1', () => { 110 | closeTo(nodeCorrections.uM1(testItems), 7.09154172301, 4) 111 | }) 112 | 113 | it('calculates u_Modd', () => { 114 | assert.ok(nodeCorrections.uModd(testItems, 3) === 0) 115 | }) 116 | }) 117 | --------------------------------------------------------------------------------