signalk-racer 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npm test:*)",
5
+ "Bash(git -C /home/gregw/src/signalk-racer tag -l)",
6
+ "Bash(git -C /home/gregw/src/signalk-racer log --oneline -5)",
7
+ "Bash(git -C /home/gregw/src/signalk-racer status -sb)",
8
+ "Bash(git -C /home/gregw/src/signalk-racer remote -v)"
9
+ ]
10
+ }
11
+ }
package/README.md CHANGED
@@ -189,4 +189,41 @@ npm install
189
189
  npm link
190
190
  cd ~/.signalk
191
191
  npm link signalk-racer
192
- ```
192
+ ```
193
+
194
+ ---
195
+ ## 🚀 Releasing
196
+
197
+ Releases are cut manually from a clean `main` checkout and published to npm.
198
+
199
+ ### Pre-flight
200
+
201
+ 1. Confirm `main` is up to date and clean: `git checkout main && git pull && git status`.
202
+ 2. Run the test suite: `npm test`.
203
+ 3. Review commits since the last release: `git log $(git describe --tags --abbrev=0)..HEAD --oneline`.
204
+ 4. Decide the version bump (semver): `patch` for bug fixes, `minor` for features, `major` for breaking changes.
205
+
206
+ ### Cut the release
207
+
208
+ 1. Update `ReleaseNotes.md` — add a new entry at the top with the new version and a short bullet list of changes, then `git add ReleaseNotes.md`.
209
+ 2. Bump the version and create the git tag in one step (note the non-default tag prefix so it matches the existing `signalk-racer-X.Y.Z` convention):
210
+ ```
211
+ npm version <patch|minor|major> --tag-version-prefix=signalk-racer-
212
+ ```
213
+ This rewrites `package.json`, includes any staged files (e.g. `ReleaseNotes.md`) in the bump commit, and creates tag `signalk-racer-X.Y.Z`.
214
+ 3. Push the commit and the tag:
215
+ ```
216
+ git push origin main
217
+ git push origin signalk-racer-X.Y.Z
218
+ ```
219
+
220
+ ### Publish to npm
221
+
222
+ 1. Confirm npm auth: `npm whoami` (login with `npm login` if needed).
223
+ 2. Dry-run the publish to inspect the tarball contents: `npm publish --dry-run`.
224
+ 3. Publish: `npm publish` (the package is public and unscoped; no `--access` flag needed).
225
+
226
+ ### After release
227
+
228
+ 1. Verify on npm: `npm view signalk-racer version` should report the new version.
229
+ 2. (Optional) Create a GitHub release from the new tag with notes copied from `ReleaseNotes.md`.
package/ReleaseNotes.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## 1.1.2 TTL fixes
2
+ + use a FIFO queue for VMGs towards the line.
3
+ + avoid undefined values in deltas
4
+
5
+ ## 1.1.1 Unit tests
6
+ + split into index.js and racing.js for unit testing
7
+ + unit tested TTL
8
+ + allows negative TTB
9
+ + added configurations for VMG calculations
10
+
1
11
  ## 1.1.0 Named lines
2
12
  + Added Named lines support
3
13
  + Refactored distance to line calculations
package/index.js CHANGED
@@ -2,11 +2,6 @@ module.exports = (app) => {
2
2
  const state = {
3
3
  startLineName: null,
4
4
  startLine: null,
5
-
6
- vmgToLinePos: [], // VMG towards the line (not OCS to line)
7
- vmgToLineNeg: [], // VMG away from the line (OCS to line)
8
- vmgToZonePort: [], // Motion along the line towards the PORT end (pin)
9
- vmgToZoneStb: [], // Motion along the line towards the STARBOARD end (boat)
10
5
  };
11
6
  const geolib = require('geolib')
12
7
  const racerSchema = {
@@ -43,7 +38,26 @@ module.exports = (app) => {
43
38
  title: 'Start Timer default initial period in seconds',
44
39
  default: 300
45
40
  },
46
-
41
+ maxSamples : {
42
+ type: 'number',
43
+ title: 'Number of samples to collect for VMG calculations',
44
+ default: 600
45
+ },
46
+ percentile: {
47
+ type: 'number',
48
+ title: 'Percentile used to select sample for VMG calculations (0-1)',
49
+ default: 0.9
50
+ },
51
+ minSog: {
52
+ type: 'number',
53
+ title: 'Minimum speed over ground (SOG) in knots to consider for VMG calculations',
54
+ default: 1.0
55
+ },
56
+ maxDistance: {
57
+ type: 'number',
58
+ title: 'Maximum distance to line and/or line zone in meters to consider for VMG calculations',
59
+ default: 2000
60
+ },
47
61
  lines: {
48
62
  type: 'array',
49
63
  title: 'Named lines',
@@ -84,7 +98,16 @@ module.exports = (app) => {
84
98
  }
85
99
  }
86
100
  }
87
- const { v4: uuidv4 } = require('uuid')
101
+ const { v4: uuidv4 } = require('uuid');
102
+ const {
103
+ initRacer,
104
+ toDegrees,
105
+ toRadians,
106
+ collectVmgSamples,
107
+ resetVmgSamples,
108
+ computeTimeToLine
109
+ } = require('./racer');
110
+
88
111
  const unsubscribes = [];
89
112
 
90
113
  // Update meta data for non standard paths
@@ -173,19 +196,22 @@ module.exports = (app) => {
173
196
  });
174
197
 
175
198
  // send multiple deltas, each in the form of { path, value }
199
+ // null values are preserved (meaningful in SignalK for clearing a value);
200
+ // undefined values are dropped to avoid "Delta is missing value" warnings.
176
201
  function sendDeltas(updatesArray) {
177
- const timestamp = new Date().toISOString();
202
+ const values = updatesArray
203
+ .filter(({value}) => value !== undefined)
204
+ .map(({path, value}) => ({path, value}));
205
+
206
+ if (values.length === 0) return;
178
207
 
179
208
  const delta = {
180
209
  context: 'vessels.self',
181
210
  updates: [
182
211
  {
183
212
  source: {label: 'signalk-racer'},
184
- timestamp: timestamp,
185
- values: updatesArray.map(({path, value}) => ({
186
- path,
187
- value
188
- }))
213
+ timestamp: new Date().toISOString(),
214
+ values
189
215
  }
190
216
  ]
191
217
  };
@@ -210,16 +236,6 @@ module.exports = (app) => {
210
236
  }
211
237
  }
212
238
 
213
- function toDegrees(rad) {
214
- if (rad === null || rad === undefined) return null;
215
- return rad * (180 / Math.PI);
216
- }
217
-
218
- function toRadians(degrees) {
219
- if (degrees === null || degrees === undefined) return null;
220
- return degrees * (Math.PI / 180);
221
- }
222
-
223
239
  function toOtherEnd(end) {
224
240
  switch (end) {
225
241
  case 'port' :
@@ -672,10 +688,7 @@ module.exports = (app) => {
672
688
 
673
689
  app.debug(`STARTLINE: startLinePort: ${JSON.stringify(startLine)}`);
674
690
  state.startLine = startLine;
675
- state.vmgToLineNeg = [];
676
- state.vmgToLinePos = [];
677
- state.vmgToZoneStb = [];
678
- state.vmgToZonePort = [];
691
+ resetVmgSamples();
679
692
  deltas = [
680
693
  {path: 'navigation.racing.startLinePort', value: startLine.port},
681
694
  {path: 'navigation.racing.startLineStb', value: startLine.stb},
@@ -802,8 +815,8 @@ module.exports = (app) => {
802
815
  if (cog != null && sog != null) {
803
816
  collectVmgSamples(cog, sog, startLine.bearing, toZoneVz, perpToLineVx);
804
817
  }
805
- const ttl = computeTimeToLine(cog, sog, startLine.bearing, toZoneVz, perpToLineVx, ocs, closestEnd);
806
- const ttb = !ocs && state.timerRunning && state.timeToStart > 0 ? Math.max(0, state.timeToStart - ttl) : null;
818
+ const ttl = computeTimeToLine(cog, sog, startLine.bearing, toZoneVz, perpToLineVx, ocs, closestEnd, state.timeToStart ?? 0);
819
+ const ttb = !ocs && state.timerRunning && state.timeToStart > 0 && ttl != null ? (state.timeToStart - ttl) : null;
807
820
 
808
821
  sendDeltas([
809
822
  {path: 'navigation.racing.distanceStartline', value: distanceToLine},
@@ -822,121 +835,6 @@ module.exports = (app) => {
822
835
  }
823
836
  }
824
837
 
825
- function collectVmgSamples(cog, sog, lineBearing, toZoneVz, perpToLineVx) {
826
- try {
827
- // TODO configure these rejection values
828
- if (sog < 1.0) return; // ignore drifting / tacking stalls
829
- if (toZoneVz > 1000 || perpToLineVx > 1000) return; // to far away from the line
830
-
831
- // Compute angle boatDir-relative to line bearing
832
- let angleRad = cog - toRadians(lineBearing);
833
-
834
- // VMG components
835
- const vmgNormal = sog * Math.sin(angleRad); // +ve = towards line, -ve = away
836
- const vmgTangent = sog * Math.cos(angleRad); // +ve = towards pin, -ve = towards stb
837
-
838
- // Insert vmg arrays
839
- insertSample(state.vmgToLinePos, vmgNormal);
840
- insertSample(state.vmgToLineNeg, -vmgNormal);
841
- insertSample(state.vmgToZonePort, vmgTangent);
842
- insertSample(state.vmgToZoneStb, -vmgTangent);
843
- } catch (err) {
844
- app.error(err);
845
- }
846
- }
847
-
848
- function insertSample(arr, value) {
849
- if (value <= 1.0) return; // reject low VMGs
850
- if (arr.length >= 600) arr.shift(); // Keep up to 10 minutes at 1Hz
851
-
852
- let lo = 0, hi = arr.length;
853
- while (lo < hi) {
854
- const mid = (lo + hi) >> 1;
855
- if (arr[mid] < value) lo = mid + 1;
856
- else hi = mid;
857
- }
858
- arr.splice(lo, 0, value);
859
- }
860
-
861
- function computeTimeToLine(cog, sog, lineBearing, toZoneVz, perpToLineVx, ocs, closestEnd) {
862
-
863
- let vmgNormalSigned = 0;
864
- let vmgTangentSigned = 0;
865
-
866
- if (cog != null && sog != null) {
867
- const lineBearingRad = toRadians(lineBearing);
868
- if (lineBearingRad === null) {
869
- return 0;
870
- }
871
-
872
- // Angle between boat COG and line bearing
873
- const angleRad = cog - lineBearingRad;
874
-
875
- // Signed VMG components from *current* COG/SOG
876
- vmgNormalSigned = sog * Math.sin(angleRad); // normal to line
877
- vmgTangentSigned = sog * Math.cos(angleRad); // along line (stb->port is +)
878
- }
879
-
880
- // 1. Effective VMG normal (perpendicular to line)
881
- //
882
- // History:
883
- // - if OCS, use vmgToLineNeg (away from line => back towards line in this case)
884
- // - otherwise, use vmgToLinePos
885
- const histNormal = percentileFromSorted(ocs ? state.vmgToLineNeg : state.vmgToLinePos, 0.9);
886
-
887
- // Instantaneous VMG in the *required* direction
888
- // if we are OCS, then the required direction is the opposite of the effective direction
889
- let vmgInstNormal = ocs ? -vmgNormalSigned : vmgNormalSigned;
890
- const vmgEffNormal = Math.max(histNormal || 0, vmgInstNormal || 0);
891
-
892
- // 2. Effective VMG along the line (towards the chosen zone entry)
893
- let vmgHistParallel = 0;
894
- let vmgInstParallel = 0;
895
-
896
- if (toZoneVz > 0) {
897
- if (closestEnd === 'port') {
898
- // Coming from the pin end, we will sail from PORT towards STB
899
- // => use STB-direction samples.
900
- vmgHistParallel = percentileFromSorted(state.vmgToZoneStb, 0.9);
901
- if (vmgTangentSigned < 0) {
902
- vmgInstParallel = -vmgTangentSigned; // towards stb
903
- }
904
- } else {
905
- // Coming from the boat end, we will sail from STB towards PORT
906
- // => use PORT-direction samples.
907
- vmgHistParallel = percentileFromSorted(state.vmgToZonePort, 0.9);
908
- if (vmgTangentSigned > 0) {
909
- vmgInstParallel = vmgTangentSigned; // towards port
910
- }
911
- }
912
- }
913
-
914
- const vmgEffectParallel = Math.max(vmgHistParallel || 0, vmgInstParallel || 0);
915
-
916
- // 3. Combine legs: along to zone, then perpendicular to line
917
- let ttl = 0;
918
-
919
- // Outside the start zone: first go along the line (or zone boundary)
920
- if (toZoneVz > 0 && vmgEffectParallel > 0) {
921
- ttl += toZoneVz / vmgEffectParallel;
922
- }
923
-
924
- // Then go perpendicular to the line to actually hit it
925
- if (perpToLineVx > 0 && vmgEffNormal > 0) {
926
- ttl += perpToLineVx / vmgEffNormal;
927
- }
928
-
929
- app.debug(`computeTimeToLine: ${closestEnd} ${toZoneVz} ${perpToLineVx} ${vmgEffectParallel} ${vmgEffNormal} = ${ttl}`);
930
- // If we still somehow have zero (no speed or distances), just return time to start so TTB logic doesn't explode.
931
- return ttl <= 0 ? state.timeToStart : ttl;
932
- }
933
-
934
- function percentileFromSorted(arr, p) {
935
- if (!arr || !arr.length) return 0;
936
- const idx = Math.floor(p * (arr.length - 1));
937
- return arr[idx];
938
- }
939
-
940
838
  function processWind(twd) {
941
839
  if (!twd) {
942
840
  twd = app.getSelfPath('environment.wind.directionTrue');
@@ -1073,6 +971,14 @@ module.exports = (app) => {
1073
971
  app.error('Missing waypoint names: startLinePort and/or startLineStb not configured.');
1074
972
  return;
1075
973
  }
974
+
975
+ initRacer({
976
+ minSog: options.minSog ?? 1.0,
977
+ maxDistance: options.maxDistance ?? 2000,
978
+ maxSamples: options.maxSamples ?? 600,
979
+ percentile: options.percentile ?? 0.9
980
+ }, app);
981
+
1076
982
  app.debug('startLinePort:' + options.startLinePort);
1077
983
  app.debug('startLineStb:' + options.startLineStb);
1078
984
  app.debug('app.selfId:' + app.selfId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-racer",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Signalk plugin to calculate values of interest to sail racers, such as: Time to Start; Time of Start, Time to Burn; Distance to Line; Next leg TWA.",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -26,5 +26,11 @@
26
26
  "dependencies": {
27
27
  "geolib": "^3.3.4",
28
28
  "uuid": "^8.1.0"
29
+ },
30
+ "devDependencies": {
31
+ "jest": "^30.0.0"
32
+ },
33
+ "scripts": {
34
+ "test": "jest"
29
35
  }
30
36
  }
package/public/index.html CHANGED
@@ -1,4 +1,5 @@
1
1
  <!DOCTYPE html>
2
+ <!-- public/index.html -->
2
3
  <html lang="en">
3
4
  <head>
4
5
  <meta charset="UTF-8">
package/racer.js ADDED
@@ -0,0 +1,183 @@
1
+ // lib/racerMath.js
2
+ 'use strict';
3
+
4
+ let cfg = {
5
+ minSog: 1.0,
6
+ maxDistance: 2000,
7
+ maxSamples: 600,
8
+ percentile: 0.9
9
+ };
10
+
11
+ let log = {
12
+ debug: () => {
13
+ }
14
+ };
15
+
16
+ function initRacer(config = {}, logger = null) {
17
+ cfg = {...cfg, ...config};
18
+ if (logger?.debug) log = logger;
19
+ }
20
+
21
+ // VMG sample state is kept private to this module.
22
+ // Each entry has a `sorted` array (ascending, for O(1) percentile) and a
23
+ // `queue` array (insertion-order FIFO, for correct oldest-first eviction).
24
+ const vmgState = {
25
+ vmgToLinePos: {sorted: [], queue: []}, // VMG towards the line (not OCS to line)
26
+ vmgToLineNeg: {sorted: [], queue: []}, // VMG away from the line (OCS to line)
27
+ vmgToZonePort: {sorted: [], queue: []}, // Motion along the line towards the PORT end (pin)
28
+ vmgToZoneStb: {sorted: [], queue: []}, // Motion along the line towards the STARBOARD end (boat)
29
+ };
30
+
31
+ function resetVmgSamples() {
32
+ for (const vmg of Object.values(vmgState)) {
33
+ vmg.sorted.length = 0;
34
+ vmg.queue.length = 0;
35
+ }
36
+ }
37
+
38
+ function toDegrees(rad) {
39
+ if (rad === null || rad === undefined) return null;
40
+ return rad * (180 / Math.PI);
41
+ }
42
+
43
+ function toRadians(deg) {
44
+ if (deg === null || deg === undefined) return null;
45
+ return deg * (Math.PI / 180);
46
+ }
47
+
48
+ function percentile(vmg, p) {
49
+ if (!vmg.sorted.length) return 0;
50
+ const idx = Math.floor(p * (vmg.sorted.length - 1));
51
+ return vmg.sorted[idx];
52
+ }
53
+
54
+ function collectVmgSamples(cog, sog, lineBearing, toZoneVz, perpToLineVx) {
55
+ if (sog < cfg.minSog) return; // ignore drifting / tacking stalls
56
+ if (toZoneVz > cfg.maxDistance || perpToLineVx > cfg.maxDistance) return; // to far away from the line
57
+
58
+ // Compute angle boatDir-relative to line bearing
59
+ let angleRad = cog - toRadians(lineBearing);
60
+
61
+ // VMG components
62
+ const vmgNormal = sog * Math.sin(angleRad); // +ve = towards line, -ve = away
63
+ const vmgTangent = sog * Math.cos(angleRad); // +ve = towards pin, -ve = towards stb
64
+
65
+ // Insert vmg arrays
66
+ insertSample(vmgState.vmgToLinePos, vmgNormal);
67
+ insertSample(vmgState.vmgToLineNeg, -vmgNormal);
68
+ insertSample(vmgState.vmgToZonePort, vmgTangent);
69
+ insertSample(vmgState.vmgToZoneStb, -vmgTangent);
70
+ }
71
+
72
+ function insertSample(vmg, value) {
73
+ if (value <= 1.0)
74
+ return; // reject low VMGs
75
+ if (vmg.queue.length >= cfg.maxSamples) {
76
+ const oldest = vmg.queue.shift(); // evict oldest from FIFO
77
+ // remove oldest from sorted (binary search)
78
+ let lo = 0, hi = vmg.sorted.length;
79
+ while (lo < hi) {
80
+ const mid = (lo + hi) >> 1;
81
+ if (vmg.sorted[mid] < oldest)
82
+ lo = mid + 1;
83
+ else
84
+ hi = mid;
85
+ }
86
+ vmg.sorted.splice(lo, 1);
87
+ }
88
+ // insert value into sorted (binary search)
89
+ let lo = 0, hi = vmg.sorted.length;
90
+ while (lo < hi) {
91
+ const mid = (lo + hi) >> 1;
92
+ if (vmg.sorted[mid] < value)
93
+ lo = mid + 1;
94
+ else
95
+ hi = mid;
96
+ }
97
+ vmg.sorted.splice(lo, 0, value);
98
+ vmg.queue.push(value);
99
+ }
100
+
101
+ function computeTimeToLine(cog, sog, lineBearing, toZoneVz, perpToLineVx, ocs, closestEnd, timeToStart = 0) {
102
+ let vmgNormalSigned = 0;
103
+ let vmgTangentSigned = 0;
104
+
105
+ if (cog != null && sog != null) {
106
+ const lineBearingRad = toRadians(lineBearing);
107
+ if (lineBearingRad === null) {
108
+ return 0;
109
+ }
110
+
111
+ // Angle between boat COG and line bearing
112
+ const angleRad = cog - lineBearingRad;
113
+
114
+ // Signed VMG components from *current* COG/SOG
115
+ vmgNormalSigned = sog * Math.sin(angleRad); // normal to line
116
+ vmgTangentSigned = sog * Math.cos(angleRad); // along line (stb->port is +)
117
+ }
118
+
119
+ // 1. Effective VMG normal (perpendicular to line)
120
+ //
121
+ // History:
122
+ // - if OCS, use vmgToLineNeg (away from line => back towards line in this case)
123
+ // - otherwise, use vmgToLinePos
124
+ const histNormal = percentile(ocs ? vmgState.vmgToLineNeg : vmgState.vmgToLinePos, cfg.percentile);
125
+
126
+ // Instantaneous VMG in the *required* direction
127
+ // if we are OCS, then the required direction is the opposite of the effective direction
128
+ let vmgInstNormal = ocs ? -vmgNormalSigned : vmgNormalSigned;
129
+ const vmgEffNormal = Math.max(histNormal || 0, vmgInstNormal || 0);
130
+
131
+ // 2. Effective VMG along the line (towards the chosen zone entry)
132
+ let vmgHistParallel = 0;
133
+ let vmgInstParallel = 0;
134
+
135
+ if (toZoneVz > 0) {
136
+ if (closestEnd === 'port') {
137
+ // Coming from the pin end, we will sail from PORT towards STB
138
+ // => use STB-direction samples.
139
+ vmgHistParallel = percentile(vmgState.vmgToZoneStb, cfg.percentile);
140
+ if (vmgTangentSigned < 0) {
141
+ vmgInstParallel = -vmgTangentSigned; // towards stb
142
+ }
143
+ } else {
144
+ // Coming from the boat end, we will sail from STB towards PORT
145
+ // => use PORT-direction samples.
146
+ vmgHistParallel = percentile(vmgState.vmgToZonePort, cfg.percentile);
147
+ if (vmgTangentSigned > 0) {
148
+ vmgInstParallel = vmgTangentSigned; // towards port
149
+ }
150
+ }
151
+ }
152
+
153
+ const vmgEffectParallel = Math.max(vmgHistParallel || 0, vmgInstParallel || 0);
154
+
155
+ // 3. Combine legs: along to zone, then perpendicular to line
156
+ let ttl = 0;
157
+
158
+ // Outside the start zone: first go along the line (or zone boundary)
159
+ if (toZoneVz > 0 && vmgEffectParallel > 0) {
160
+ ttl += toZoneVz / vmgEffectParallel;
161
+ }
162
+
163
+ // Then go perpendicular to the line to actually hit it
164
+ if (perpToLineVx > 0 && vmgEffNormal > 0) {
165
+ ttl += perpToLineVx / vmgEffNormal;
166
+ }
167
+
168
+ // If we still somehow have zero (no speed or distances), just return time to start so TTB logic doesn't explode.
169
+ return ttl <= 0 ? timeToStart : ttl;
170
+ }
171
+
172
+ module.exports = {
173
+ initRacer,
174
+ toDegrees,
175
+ toRadians,
176
+ resetVmgSamples,
177
+ collectVmgSamples,
178
+ computeTimeToLine,
179
+
180
+ _percentile: percentile, // for testing purposes only
181
+ _vmgState: vmgState // for testing purposes only
182
+
183
+ };
@@ -0,0 +1,36 @@
1
+ // tests/racerMath.test.js
2
+ /* global describe, test, expect */
3
+ 'use strict';
4
+
5
+ const {
6
+ toDegrees,
7
+ toRadians,
8
+ _percentile,
9
+ } = require('../racer');
10
+
11
+ describe('racer math', () => {
12
+ test('toDegrees: test maths', () => {
13
+ expect(toDegrees(Math.PI)).toBeCloseTo(180);
14
+ expect(toDegrees(Math.PI / 2)).toBeCloseTo(90);
15
+ expect(toDegrees(0)).toBeCloseTo(0);
16
+ expect(toDegrees(null)).toBeNull();
17
+ expect(toDegrees(undefined)).toBeNull();
18
+ });
19
+ test('toRadians: test maths', () => {
20
+ expect(toRadians(180)).toBeCloseTo(Math.PI);
21
+ expect(toRadians(90)).toBeCloseTo(Math.PI / 2);
22
+ expect(toRadians(0)).toBeCloseTo(0);
23
+ expect(toRadians(null)).toBeNull();
24
+ expect(toRadians(undefined)).toBeNull();
25
+ });
26
+ test('percentile: test maths', () => {
27
+ const vmg = (arr) => ({ sorted: arr, queue: [] });
28
+ expect(_percentile(vmg([]), 0.5)).toBe(0);
29
+ expect(_percentile(vmg([10]), 0.5)).toBe(10);
30
+ expect(_percentile(vmg([10, 20, 30]), 0)).toBe(10);
31
+ expect(_percentile(vmg([10, 20, 30]), 0.5)).toBe(20);
32
+ expect(_percentile(vmg([10, 20, 30]), 0.9)).toBe(20);
33
+ expect(_percentile(vmg([10, 20, 30]), 1)).toBe(30);
34
+ expect(_percentile(vmg([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]), 0.75)).toBe(7);
35
+ })
36
+ });
@@ -0,0 +1,103 @@
1
+ // tests/racerMath.test.js
2
+ /* global describe, test, expect */
3
+ 'use strict';
4
+
5
+ const {
6
+ toRadians,
7
+ computeTimeToLine,
8
+ resetVmgSamples,
9
+ _vmgState
10
+ } = require('../racer');
11
+
12
+
13
+ describe('racer ttl', () => {
14
+ test('timeToLineNoVMG: no VMG', () => {
15
+ resetVmgSamples();
16
+ expect(computeTimeToLine(toRadians(0), 10, 270, 0, 100, false, 'stb')).toBeCloseTo(10);
17
+ // Fallback: ttl computed as 0, so returns the supplied timeToStart.
18
+ expect(computeTimeToLine(toRadians(0), 10, 270, 100, 0, false, 'stb', 300)).toBe(300);
19
+ // Default timeToStart parameter is 0.
20
+ expect(computeTimeToLine(toRadians(0), 10, 270, 100, 0, false, 'stb')).toBe(0);
21
+ });
22
+
23
+ test('timeToLineSmallVmg: small VMG', () => {
24
+ resetVmgSamples();
25
+ _vmgState.vmgToLinePos.sorted.push(1, 1, 1);
26
+ _vmgState.vmgToLineNeg.sorted.push(2, 2, 2);
27
+ _vmgState.vmgToZonePort.sorted.push(1, 1, 1);
28
+ _vmgState.vmgToZoneStb.sorted.push(2, 2, 2);
29
+
30
+ expect(computeTimeToLine(toRadians(-1), 10, 270, 0, 100, false, 'stb')).toBeCloseTo(10);
31
+ expect(computeTimeToLine(toRadians(0), 10, 270, 100, 0, false, 'stb')).toBeCloseTo(100);
32
+ expect(computeTimeToLine(toRadians(1), 10, 270, 100, 100, false, 'stb')).toBeCloseTo(110);
33
+
34
+ expect(computeTimeToLine(toRadians(89), 10, 270, 0, 100, false, 'stb')).toBeCloseTo(100);
35
+ expect(computeTimeToLine(toRadians(90), 10, 270, 100, 0, false, 'stb')).toBeCloseTo(100);
36
+ expect(computeTimeToLine(toRadians(91), 10, 270, 100, 100, false, 'stb')).toBeCloseTo(200);
37
+
38
+ expect(computeTimeToLine(toRadians(179), 10, 270, 0, 100, false, 'stb')).toBeCloseTo(100);
39
+ expect(computeTimeToLine(toRadians(180), 10, 270, 100, 0, false, 'stb')).toBeCloseTo(100);
40
+ expect(computeTimeToLine(toRadians(181), 10, 270, 100, 100, false, 'stb')).toBeCloseTo(200);
41
+
42
+ expect(computeTimeToLine(toRadians(271), 10, 270, 0, 100, false, 'stb')).toBeCloseTo(100);
43
+ expect(computeTimeToLine(toRadians(269), 10, 270, 100, 0, false, 'stb')).toBeCloseTo(10);
44
+ expect(computeTimeToLine(toRadians(270), 10, 270, 100, 100, false, 'stb')).toBeCloseTo(110);
45
+
46
+ expect(computeTimeToLine(toRadians(359), 10, 270, 0, 100, true, 'port')).toBeCloseTo(50);
47
+ expect(computeTimeToLine(toRadians(0), 10, 270, 100, 0, true, 'port')).toBeCloseTo(50);
48
+ expect(computeTimeToLine(toRadians(1), 10, 270, 100, 100, true, 'port')).toBeCloseTo(100);
49
+
50
+ expect(computeTimeToLine(toRadians(89), 10, 270, 0, 100, true, 'port')).toBeCloseTo(50);
51
+ expect(computeTimeToLine(toRadians(90), 10, 270, 100, 0, true, 'port')).toBeCloseTo(10);
52
+ expect(computeTimeToLine(toRadians(91), 10, 270, 100, 100, true, 'port')).toBeCloseTo(60);
53
+
54
+ expect(computeTimeToLine(toRadians(179), 10, 270, 0, 100, true, 'port')).toBeCloseTo(10);
55
+ expect(computeTimeToLine(toRadians(180), 10, 270, 100, 0, true, 'port')).toBeCloseTo(50);
56
+ expect(computeTimeToLine(toRadians(181), 10, 270, 100, 100, true, 'port')).toBeCloseTo(60);
57
+
58
+ expect(computeTimeToLine(toRadians(271), 10, 270, 0, 100, true, 'port')).toBeCloseTo(50);
59
+ expect(computeTimeToLine(toRadians(269), 10, 270, 100, 0, true, 'port')).toBeCloseTo(50);
60
+ expect(computeTimeToLine(toRadians(270), 10, 270, 100, 100, true, 'port')).toBeCloseTo(100);
61
+ });
62
+
63
+ test('timeToLineSmallInstant: small instant', () => {
64
+ resetVmgSamples();
65
+ _vmgState.vmgToLinePos.sorted.push(10, 10, 10, 10, 10, 10, 10, 10, 10, 15);
66
+ _vmgState.vmgToLineNeg.sorted.push(20, 20, 20, 20, 20, 20, 20, 20, 20, 25);
67
+ _vmgState.vmgToZonePort.sorted.push(10, 10, 10, 10, 10, 10, 10, 10, 10, 15);
68
+ _vmgState.vmgToZoneStb.sorted.push(20, 20, 20, 20, 20, 20, 20, 20, 20, 25);
69
+
70
+ expect(computeTimeToLine(toRadians(-1), 1, 270, 0, 100, false, 'stb')).toBeCloseTo(10);
71
+ expect(computeTimeToLine(toRadians(0), 1, 270, 100, 0, false, 'stb')).toBeCloseTo(10);
72
+ expect(computeTimeToLine(toRadians(1), 1, 270, 100, 100, false, 'stb')).toBeCloseTo(20);
73
+
74
+ expect(computeTimeToLine(toRadians(89), 1, 270, 0, 100, false, 'stb')).toBeCloseTo(10);
75
+ expect(computeTimeToLine(toRadians(90), 1, 270, 100, 0, false, 'stb')).toBeCloseTo(10);
76
+ expect(computeTimeToLine(toRadians(91), 1, 270, 100, 100, false, 'stb')).toBeCloseTo(20);
77
+
78
+ expect(computeTimeToLine(toRadians(179), 1, 270, 0, 100, false, 'stb')).toBeCloseTo(10);
79
+ expect(computeTimeToLine(toRadians(180), 1, 270, 100, 0, false, 'stb')).toBeCloseTo(10);
80
+ expect(computeTimeToLine(toRadians(181), 1, 270, 100, 100, false, 'stb')).toBeCloseTo(20);
81
+
82
+ expect(computeTimeToLine(toRadians(271), 1, 270, 0, 100, false, 'stb')).toBeCloseTo(10);
83
+ expect(computeTimeToLine(toRadians(269), 1, 270, 100, 0, false, 'stb')).toBeCloseTo(10);
84
+ expect(computeTimeToLine(toRadians(270), 1, 270, 100, 100, false, 'stb')).toBeCloseTo(20);
85
+
86
+ expect(computeTimeToLine(toRadians(359), 1, 270, 0, 100, true, 'port')).toBeCloseTo(5);
87
+ expect(computeTimeToLine(toRadians(0), 1, 270, 100, 0, true, 'port')).toBeCloseTo(5);
88
+ expect(computeTimeToLine(toRadians(1), 1, 270, 100, 100, true, 'port')).toBeCloseTo(10);
89
+
90
+ expect(computeTimeToLine(toRadians(89), 1, 270, 0, 100, true, 'port')).toBeCloseTo(5);
91
+ expect(computeTimeToLine(toRadians(90), 1, 270, 100, 0, true, 'port')).toBeCloseTo(5);
92
+ expect(computeTimeToLine(toRadians(91), 1, 270, 100, 100, true, 'port')).toBeCloseTo(10);
93
+
94
+ expect(computeTimeToLine(toRadians(179), 1, 270, 0, 100, true, 'port')).toBeCloseTo(5);
95
+ expect(computeTimeToLine(toRadians(180), 1, 270, 100, 0, true, 'port')).toBeCloseTo(5);
96
+ expect(computeTimeToLine(toRadians(181), 1, 270, 100, 100, true, 'port')).toBeCloseTo(10);
97
+
98
+ expect(computeTimeToLine(toRadians(271), 1, 270, 0, 100, true, 'port')).toBeCloseTo(5);
99
+ expect(computeTimeToLine(toRadians(269), 1, 270, 100, 0, true, 'port')).toBeCloseTo(5);
100
+ expect(computeTimeToLine(toRadians(270), 1, 270, 100, 100, true, 'port')).toBeCloseTo(10);
101
+ });
102
+
103
+ });
@@ -0,0 +1,168 @@
1
+ // tests/racerMath.test.js
2
+ /* global describe, test, expect */
3
+ 'use strict';
4
+
5
+ const {
6
+ toRadians,
7
+ collectVmgSamples,
8
+ resetVmgSamples,
9
+ _vmgState
10
+ } = require('../racer');
11
+
12
+ describe('racer vmg', () => {
13
+ test('resetVmgSamples: test arrays are cleared', () => {
14
+ _vmgState.vmgToLinePos.sorted.push(1, 2, 3);
15
+ _vmgState.vmgToLinePos.queue.push(1, 2, 3);
16
+ _vmgState.vmgToLineNeg.sorted.push(4, 5, 6);
17
+ _vmgState.vmgToLineNeg.queue.push(4, 5, 6);
18
+ _vmgState.vmgToZonePort.sorted.push(7, 8, 9);
19
+ _vmgState.vmgToZonePort.queue.push(7, 8, 9);
20
+ _vmgState.vmgToZoneStb.sorted.push(10, 11, 12);
21
+ _vmgState.vmgToZoneStb.queue.push(10, 11, 12);
22
+ expect(_vmgState.vmgToLinePos.sorted.length).toBe(3);
23
+ expect(_vmgState.vmgToLineNeg.sorted.length).toBe(3);
24
+ expect(_vmgState.vmgToZonePort.sorted.length).toBe(3);
25
+ expect(_vmgState.vmgToZoneStb.sorted.length).toBe(3);
26
+ resetVmgSamples();
27
+ expect(_vmgState.vmgToLinePos.sorted.length).toBe(0);
28
+ expect(_vmgState.vmgToLineNeg.sorted.length).toBe(0);
29
+ expect(_vmgState.vmgToZonePort.sorted.length).toBe(0);
30
+ expect(_vmgState.vmgToZoneStb.sorted.length).toBe(0);
31
+ });
32
+
33
+
34
+ test('collectVmgSamplesEastWestNarrow: test east west line, with perpendicular and narrow approaches', () => {
35
+ resetVmgSamples();
36
+ collectVmgSamples(toRadians(0), 19.7, 270, 0, 0);
37
+ expect(_vmgState.vmgToLinePos.sorted.length).toBe(1);
38
+ expect(_vmgState.vmgToLinePos.sorted[0]).toBeCloseTo(19.7);
39
+ expect(_vmgState.vmgToLineNeg.sorted.length).toBe(0);
40
+ expect(_vmgState.vmgToZonePort.sorted.length).toBe(0);
41
+ expect(_vmgState.vmgToZoneStb.sorted.length).toBe(0);
42
+
43
+ collectVmgSamples(toRadians(8.21), 19.7, 270, 0, 0);
44
+ expect(_vmgState.vmgToLinePos.sorted.length).toBe(2);
45
+ expect(_vmgState.vmgToLinePos.sorted[0]).toBeCloseTo(19.5);
46
+ expect(_vmgState.vmgToLinePos.sorted[1]).toBeCloseTo(19.7);
47
+ expect(_vmgState.vmgToLineNeg.sorted.length).toBe(0);
48
+ expect(_vmgState.vmgToZonePort.sorted.length).toBe(0);
49
+ expect(_vmgState.vmgToZoneStb.sorted.length).toBe(1);
50
+ expect(_vmgState.vmgToZoneStb.sorted[0]).toBeCloseTo(2.81);
51
+ });
52
+
53
+
54
+ test('collectVmgSamplesEastWest: test east west line with circle', () => {
55
+ resetVmgSamples();
56
+ collectVmgSamples(toRadians(0), 5, 270, 0, 0);
57
+ expect(_vmgState.vmgToLinePos.sorted.length).toBe(1);
58
+ expect(_vmgState.vmgToLinePos.sorted[0]).toBe(5);
59
+ expect(_vmgState.vmgToLineNeg.sorted.length).toBe(0);
60
+ expect(_vmgState.vmgToZonePort.sorted.length).toBe(0);
61
+ expect(_vmgState.vmgToZoneStb.sorted.length).toBe(0);
62
+
63
+ collectVmgSamples(toRadians(36.87), 5, 270, 0, 0);
64
+ expect(_vmgState.vmgToLinePos.sorted.length).toBe(2);
65
+ expect(_vmgState.vmgToLinePos.sorted[0]).toBeCloseTo(4);
66
+ expect(_vmgState.vmgToLinePos.sorted[1]).toBe(5);
67
+ expect(_vmgState.vmgToLineNeg.sorted.length).toBe(0);
68
+ expect(_vmgState.vmgToZonePort.sorted.length).toBe(0);
69
+ expect(_vmgState.vmgToZoneStb.sorted.length).toBe(1);
70
+ expect(_vmgState.vmgToZoneStb.sorted[0]).toBeCloseTo(3);
71
+
72
+ collectVmgSamples(toRadians(90), 5, 270, 0, 0);
73
+ expect(_vmgState.vmgToLinePos.sorted.length).toBe(2);
74
+ expect(_vmgState.vmgToLinePos.sorted[0]).toBeCloseTo(4);
75
+ expect(_vmgState.vmgToLinePos.sorted[1]).toBeCloseTo(5);
76
+ expect(_vmgState.vmgToLineNeg.sorted.length).toBe(0);
77
+ expect(_vmgState.vmgToZonePort.sorted.length).toBe(0);
78
+ expect(_vmgState.vmgToZoneStb.sorted.length).toBe(2);
79
+ expect(_vmgState.vmgToZoneStb.sorted[0]).toBeCloseTo(3);
80
+ expect(_vmgState.vmgToZoneStb.sorted[1]).toBeCloseTo(5);
81
+
82
+ collectVmgSamples(toRadians(90 + 36.87), 5, 270, 0, 0);
83
+ expect(_vmgState.vmgToLinePos.sorted.length).toBe(2);
84
+ expect(_vmgState.vmgToLinePos.sorted[0]).toBeCloseTo(4);
85
+ expect(_vmgState.vmgToLinePos.sorted[1]).toBeCloseTo(5);
86
+ expect(_vmgState.vmgToLineNeg.sorted.length).toBe(1);
87
+ expect(_vmgState.vmgToLineNeg.sorted[0]).toBeCloseTo(3);
88
+ expect(_vmgState.vmgToZonePort.sorted.length).toBe(0);
89
+ expect(_vmgState.vmgToZoneStb.sorted.length).toBe(3);
90
+ expect(_vmgState.vmgToZoneStb.sorted[0]).toBeCloseTo(3);
91
+ expect(_vmgState.vmgToZoneStb.sorted[1]).toBeCloseTo(4);
92
+ expect(_vmgState.vmgToZoneStb.sorted[2]).toBeCloseTo(5);
93
+
94
+ collectVmgSamples(toRadians(180), 5, 270, 0, 0);
95
+ expect(_vmgState.vmgToLinePos.sorted.length).toBe(2);
96
+ expect(_vmgState.vmgToLinePos.sorted[0]).toBeCloseTo(4);
97
+ expect(_vmgState.vmgToLinePos.sorted[1]).toBeCloseTo(5);
98
+ expect(_vmgState.vmgToLineNeg.sorted.length).toBe(2);
99
+ expect(_vmgState.vmgToLineNeg.sorted[0]).toBeCloseTo(3);
100
+ expect(_vmgState.vmgToLineNeg.sorted[1]).toBeCloseTo(5);
101
+ expect(_vmgState.vmgToZonePort.sorted.length).toBe(0);
102
+ expect(_vmgState.vmgToZoneStb.sorted.length).toBe(3);
103
+ expect(_vmgState.vmgToZoneStb.sorted[0]).toBeCloseTo(3);
104
+ expect(_vmgState.vmgToZoneStb.sorted[1]).toBeCloseTo(4);
105
+ expect(_vmgState.vmgToZoneStb.sorted[2]).toBeCloseTo(5);
106
+
107
+ collectVmgSamples(toRadians(180 + 53.13), 5, 270, 0, 0);
108
+ expect(_vmgState.vmgToLinePos.sorted.length).toBe(2);
109
+ expect(_vmgState.vmgToLinePos.sorted[0]).toBeCloseTo(4);
110
+ expect(_vmgState.vmgToLinePos.sorted[1]).toBeCloseTo(5);
111
+ expect(_vmgState.vmgToLineNeg.sorted.length).toBe(3);
112
+ expect(_vmgState.vmgToLineNeg.sorted[0]).toBeCloseTo(3);
113
+ expect(_vmgState.vmgToLineNeg.sorted[1]).toBeCloseTo(3);
114
+ expect(_vmgState.vmgToLineNeg.sorted[2]).toBeCloseTo(5);
115
+ expect(_vmgState.vmgToZonePort.sorted.length).toBe(1);
116
+ expect(_vmgState.vmgToZonePort.sorted[0]).toBeCloseTo(4);
117
+ expect(_vmgState.vmgToZoneStb.sorted.length).toBe(3);
118
+ expect(_vmgState.vmgToZoneStb.sorted[0]).toBeCloseTo(3);
119
+ expect(_vmgState.vmgToZoneStb.sorted[1]).toBeCloseTo(4);
120
+ expect(_vmgState.vmgToZoneStb.sorted[2]).toBeCloseTo(5);
121
+
122
+ collectVmgSamples(toRadians(270), 5, 270, 0, 0);
123
+ expect(_vmgState.vmgToLinePos.sorted.length).toBe(2);
124
+ expect(_vmgState.vmgToLinePos.sorted[0]).toBeCloseTo(4);
125
+ expect(_vmgState.vmgToLinePos.sorted[1]).toBeCloseTo(5);
126
+ expect(_vmgState.vmgToLineNeg.sorted.length).toBe(3);
127
+ expect(_vmgState.vmgToLineNeg.sorted[0]).toBeCloseTo(3);
128
+ expect(_vmgState.vmgToLineNeg.sorted[1]).toBeCloseTo(3);
129
+ expect(_vmgState.vmgToLineNeg.sorted[2]).toBeCloseTo(5);
130
+ expect(_vmgState.vmgToZonePort.sorted.length).toBe(2);
131
+ expect(_vmgState.vmgToZonePort.sorted[0]).toBeCloseTo(4);
132
+ expect(_vmgState.vmgToZonePort.sorted[1]).toBeCloseTo(5);
133
+ expect(_vmgState.vmgToZoneStb.sorted.length).toBe(3);
134
+ expect(_vmgState.vmgToZoneStb.sorted[0]).toBeCloseTo(3);
135
+ expect(_vmgState.vmgToZoneStb.sorted[1]).toBeCloseTo(4);
136
+ expect(_vmgState.vmgToZoneStb.sorted[2]).toBeCloseTo(5);
137
+
138
+ collectVmgSamples(-toRadians(53.13), 5, 270, 0, 0);
139
+ expect(_vmgState.vmgToLinePos.sorted.length).toBe(3);
140
+ expect(_vmgState.vmgToLinePos.sorted[0]).toBeCloseTo(3);
141
+ expect(_vmgState.vmgToLinePos.sorted[1]).toBeCloseTo(4);
142
+ expect(_vmgState.vmgToLinePos.sorted[2]).toBeCloseTo(5);
143
+ expect(_vmgState.vmgToLineNeg.sorted.length).toBe(3);
144
+ expect(_vmgState.vmgToLineNeg.sorted[0]).toBeCloseTo(3);
145
+ expect(_vmgState.vmgToLineNeg.sorted[1]).toBeCloseTo(3);
146
+ expect(_vmgState.vmgToLineNeg.sorted[2]).toBeCloseTo(5);
147
+ expect(_vmgState.vmgToZonePort.sorted.length).toBe(3);
148
+ expect(_vmgState.vmgToZonePort.sorted[0]).toBeCloseTo(4);
149
+ expect(_vmgState.vmgToZonePort.sorted[1]).toBeCloseTo(4);
150
+ expect(_vmgState.vmgToZonePort.sorted[2]).toBeCloseTo(5);
151
+ expect(_vmgState.vmgToZoneStb.sorted.length).toBe(3);
152
+ expect(_vmgState.vmgToZoneStb.sorted[0]).toBeCloseTo(3);
153
+ expect(_vmgState.vmgToZoneStb.sorted[1]).toBeCloseTo(4);
154
+ expect(_vmgState.vmgToZoneStb.sorted[2]).toBeCloseTo(5);
155
+
156
+ });
157
+
158
+ test('collectVmgSamplesNW2SE: test NW to SE line approached obliquely', () => {
159
+ resetVmgSamples();
160
+ collectVmgSamples(toRadians(0), 19.7, 8.21, 0, 0);
161
+ expect(_vmgState.vmgToLinePos.sorted.length).toBe(0);
162
+ expect(_vmgState.vmgToLineNeg.sorted.length).toBe(1);
163
+ expect(_vmgState.vmgToLineNeg.sorted[0]).toBeCloseTo(2.81);
164
+ expect(_vmgState.vmgToZonePort.sorted.length).toBe(1);
165
+ expect(_vmgState.vmgToZonePort.sorted[0]).toBeCloseTo(19.5);
166
+ expect(_vmgState.vmgToZoneStb.sorted.length).toBe(0);
167
+ });
168
+ });