signalk-racer 1.3.0 → 1.3.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.
package/README.md CHANGED
@@ -25,7 +25,8 @@ This plugin calculates and publishes the following Signal K paths:
25
25
  | `navigation.racing.bestVmg.toPortEnd` | Best VMG along the line towards the port end (pin) | `m/s` | |
26
26
  | `navigation.racing.bestVmg.toStbEnd` | Best VMG along the line towards the stb end (boat) | `m/s` | |
27
27
  | `navigation.racing.bestVmg.*.override` | The manual adjustment behind each best VMG, `null` when not adjusted | `m/s` | |
28
- | `navigation.racing.bestApproach` | The course actually sailed that achieved the best VMG towards the line | `{rad,m/s}` | |
28
+ | `navigation.racing.effectiveVmg.toLine` | VMG the perpendicular leg of the time to line is divided by | `m/s` | |
29
+ | `navigation.racing.effectiveVmg.alongLine` | VMG the along-line leg is divided by, 0 when inside the start zone | `m/s` | |
29
30
  | `navigation.racing.startLinePort` | Location of the port (pin) end of the start line | `{latitude,longitude}` | Y |
30
31
  | `navigation.racing.startLineStb` | Location of the starboard (boat) end of the start line | `{latitude,longitude}` | Y |
31
32
  | `navigation.racing.nextLegHeading` | True heading for the next leg of the course | `rad` | |
@@ -60,6 +61,7 @@ You can configure the plugin via the Signal K web interface or by editing `setti
60
61
  | `period` | How often to update values (in milliseconds) | `1000` |
61
62
  | `updateStartLineWaypoint` | Should the waypoints be updated if the line is set/adjusted | `true` |
62
63
  | `createStartLineWaypoint` | Should the waypoints be created if the line is set | `true` |
64
+ | `minEffectiveVmg` | Floor under each effective VMG used for time to line (in knots) | `0.5` |
63
65
  | `lines` | Array of named lines | null |
64
66
 
65
67
  ---
@@ -97,18 +99,13 @@ If `navigation.headingTrue` is not available, then `navigation.courseOverGroundT
97
99
 
98
100
  - Uses the effective VMG as the maximum VMG of either the current COG/SOG or the 90th percentile of the VMG recently achieved (approx in the last 10 minutes).
99
101
  This allows a boat to luff / delay without changing the time to line.
102
+ - Each effective VMG is floored at `minEffectiveVmg` (default 0.5 knots), so a direction
103
+ with nothing collected yet - or a drifting boat - still yields a time rather than none.
104
+ The floor is not applied to the along-line leg when the boat is inside the start zone,
105
+ there being no such leg to sail.
100
106
  - A boat within the start zone has the time calculated by the perpendicular distance to the line divided by their effective VMG to the line.
101
107
  - A boat outside the start zone also has the perpendicular time plus the time calculated by the parallel distance to the zone divided by their effective VMG in that direction.
102
108
  - If the line is changed, then the samples used to calculate the effective VMGs are cleared.
103
- - Every sample keeps the `cog` and `sog` that produced it, so the course behind the 90th
104
- percentile VMG towards the line can be recovered and is published as
105
- `navigation.racing.bestApproach`. The direction is chosen exactly as the time to line
106
- chooses its legs, and is reported alongside the course:
107
- - Inside the start zone the line is closed across it, so `toCourseSide` is used, or
108
- `fromCourseSide` when OCS.
109
- - Outside the start zone the boat must first run along the line, so the along-line
110
- samples are used, in the direction *away* from the closest end: beyond the pin it
111
- must travel towards the starboard end, so `toStbEnd` governs, and vice versa.
112
109
  - The four collected VMGs are published under `navigation.racing.bestVmg.*` and may be manually
113
110
  adjusted. An adjustment stands in for the 90th percentile of the samples, but the VMG the boat
114
111
  is actually sailing still wins if it is better, so an adjustment can never make the estimate
@@ -145,11 +142,11 @@ line and the boat, so it zooms as the boat closes. The line length and the headi
145
142
  the line are marked on the line itself, and the boat is drawn as a triangle pointing along its
146
143
  heading, amber when OCS.
147
144
 
148
- Two projections run from the boat. Both start at the boat and are drawn to the same scale as the
149
- rest of the drawing, so their **tips can be read directly against the line**. Hovering the boat
150
- reports its SOG and COG and names whichever of the two lines are currently drawn.
145
+ A projection runs from the boat, drawn to the same scale as the rest of the drawing, so its **tip
146
+ can be read directly against the line**. Hovering the boat reports its SOG and COG and names the
147
+ line running from it.
151
148
 
152
- #### Thick line — "Current cog/sog to start"
149
+ #### Course projection — "Current cog/sog to start"
153
150
 
154
151
  Runs along the boat's present `navigation.courseOverGroundTrue`. While the start timer is counting
155
152
  down, its length is `SOG × timeToStart`: the distance the boat will cover before the gun if nothing
@@ -162,24 +159,6 @@ changes. Its tip is therefore **where the boat will be when the start fires**:
162
159
  With no timer running there is nothing to project against, so it degrades to a short fixed-length
163
160
  stub showing course only.
164
161
 
165
- #### Thin faint line — "Best VMG to start"
166
-
167
- Runs along `navigation.racing.bestApproach`, with its length that course's own
168
- `SOG × timeToStart`. This is **not** a synthetic best case: it is a course the boat has genuinely
169
- sailed in the last few minutes, being the `cog`/`sog` recorded against the 90th percentile VMG
170
- sample for the direction that actually matters (across the line, or along it when outside the start
171
- zone — see [Time To Line](#time-to-line-navigationracingtimetoline)). Because it is a real point of
172
- sail it runs on its **own bearing**, not the current one, so it will diverge from the thick line
173
- whenever the boat is not sailing as well as it recently has.
174
-
175
- Comparing the two tips is the point of the pair: if the thin line reaches the line and the thick one
176
- falls short, then sailing the boat as well as it has already been sailed would get you there, and
177
- the difference between the tips is what is being left on the table. If no samples have been
178
- collected yet, or the timer is not running, this line is not drawn.
179
-
180
- Over a long countdown both projections run well off the drawing and are simply clipped at its edge;
181
- the part that matters — where they cross the line — stays visible.
182
-
183
162
  ---
184
163
 
185
164
  ## 🌐 API Access
@@ -226,14 +205,15 @@ Used to **adjust** or **reset** the best VMGs used to estimate the time to line.
226
205
  "vmg": "toCourseSide" | "fromCourseSide" | "toPortEnd" | "toStbEnd",
227
206
  "value": 5.0,
228
207
  "delta": 0.0514,
229
- "command": "reset"
208
+ "command": "reset" | "clear"
230
209
  }
231
210
  ```
232
211
 
233
212
  - `vmg`: which best VMG to adjust. Optional for `reset`, which otherwise clears all four.
234
213
  - `value`: absolute best VMG in `m/s`.
235
214
  - `delta`: adjustment in `m/s` applied to the current best VMG.
236
- - `command`: `"reset"` to clear the override and revert to the collected samples.
215
+ - `command`: `"reset"` to clear the override and revert to the collected samples, or
216
+ `"clear"` to throw away the collected samples themselves and start again.
237
217
 
238
218
  An absolute value may also be put directly to `navigation.racing.bestVmg.<name>`.
239
219
 
package/ReleaseNotes.md CHANGED
@@ -1,4 +1,33 @@
1
- ## 1.2.0 Visualize line and best VMGs
1
+ ## 1.3.2 Distance to length
2
+ + Set the metadata for distance to line as a length rather than as a distance.
3
+
4
+ ## 1.3.1 Start line approach
5
+ + Fixed the distance to the start zone being measured against a 54.7 degree wedge while
6
+ the zone itself was tested at 45 degrees, so `distanceStartline` and `timeToLine`
7
+ jumped by 0.29 times the offset as a boat crossed the boundary. They are now continuous
8
+ and the along-line leg reaches zero exactly at the wedge
9
+ + The start zone geometry moved from `index.js` into `racer.js` as `startZoneDistances`,
10
+ where it is unit tested - it had no coverage at all, which is how the above survived
11
+ + `navigation.racing.timeToBurn` is now published when OCS as well. The time to line is
12
+ then the time to get back over the line from the course side, so the spare time before
13
+ you have to turn and do it is just as real, and goes negative once it is too late
14
+ + Removed `navigation.racing.bestApproach` and the `cog`/`sog` each VMG sample carried
15
+ to produce it. It described one course the boat had genuinely sailed, which was useful
16
+ but not the path the time to line is computed over, so it read as a second answer to a
17
+ question that already had one. Best VMG angles will be revisited once the polar plugin
18
+ offers an API for them, by which point the shape is likely to be quite different
19
+ + Each effective VMG is now floored at a configurable minimum, `minEffectiveVmg`,
20
+ defaulting to 0.5 knots. A direction with nothing collected reported no VMG at all, which
21
+ left the time to line falling back to the time to start rather than estimating anything
22
+ + `navigation.racing.setBestVmg` accepts `command: "clear"`, which discards the collected
23
+ samples themselves rather than just the manual adjustments
24
+ + Published the two VMGs the time to line is actually divided by as
25
+ `navigation.racing.effectiveVmg.{toLine,alongLine}`: the collected best for each
26
+ direction, or the VMG being sailed right now when that is better. `bestVmg.*` reports
27
+ only the collected half, so a client drawing the approach from it disagreed with the
28
+ published time whenever the boat was going well
29
+
30
+ ## 1.3.0 Visualize line and best VMGs
2
31
  + Added `navigation.racing.swapStartLine` to swap the port and starboard ends of the line,
3
32
  carrying the collected VMG samples across with the ends
4
33
  + Published the four best VMGs behind the time to line as `navigation.racing.bestVmg.`
@@ -18,6 +47,8 @@
18
47
  + Fixed the start line bias not being cleared when the line is lost
19
48
  + Fixed the webapp race countdown never being displayed
20
49
 
50
+ ## 1.2.0 Never released
51
+
21
52
  ## 1.1.2 TTL fixes
22
53
  + use a FIFO queue for VMGs towards the line.
23
54
  + avoid undefined values in deltas
package/index.js CHANGED
@@ -5,7 +5,7 @@ module.exports = (app) => {
5
5
  // Last published best VMGs, so we only send deltas when they actually change.
6
6
  publishedBestVmg: {toCourseSide: null, fromCourseSide: null, toPortEnd: null, toStbEnd: null},
7
7
  publishedBestVmgOverrides: {toCourseSide: null, fromCourseSide: null, toPortEnd: null, toStbEnd: null},
8
- publishedBestApproach: 'null',
8
+ publishedEffectiveVmg: {toLine: null, alongLine: null},
9
9
  };
10
10
  const geolib = require('geolib')
11
11
  const racerSchema = {
@@ -62,6 +62,11 @@ module.exports = (app) => {
62
62
  title: 'Minimum VMG component in m/s for a sample to be collected in that direction',
63
63
  default: 0.01
64
64
  },
65
+ minEffectiveVmg: {
66
+ type: 'number',
67
+ title: 'Minimum effective VMG in knots used to estimate time to line',
68
+ default: 0.5
69
+ },
65
70
  maxDistance: {
66
71
  type: 'number',
67
72
  title: 'Maximum distance to line and/or line zone in meters to consider for VMG calculations',
@@ -116,13 +121,14 @@ module.exports = (app) => {
116
121
  collectVmgSamples,
117
122
  resetVmgSamples,
118
123
  computeTimeToLine,
124
+ startZoneDistances,
125
+ effectiveVmg,
119
126
  vmgNames,
120
127
  getAllBestVmg,
121
128
  getBestVmgOverrides,
122
129
  setBestVmg,
123
130
  clearBestVmgOverrides,
124
131
  swapVmgEnds,
125
- getBestApproach
126
132
  } = require('./racer');
127
133
 
128
134
  const unsubscribes = [];
@@ -139,11 +145,23 @@ module.exports = (app) => {
139
145
  "value": {
140
146
  "type": "number",
141
147
  "units": "m",
148
+ "displayUnits": { "category": "length" },
142
149
  "description": "Length of the start line",
143
150
  "displayName": "Length of the start line",
144
151
  "shortName": "SLL"
145
152
  }
146
153
  },
154
+ {
155
+ "path": "navigation.racing.distanceStartline",
156
+ "value": {
157
+ "type": "number",
158
+ "units": "m",
159
+ "displayUnits": { "category": "length" },
160
+ "description": "The minimum distance from the bow to the start line, via perpendicular approach",
161
+ "displayName": "Distance to the start line",
162
+ "shortName": "DTL"
163
+ }
164
+ },
147
165
  {
148
166
  "path": "navigation.racing.startLineBearing",
149
167
  "value": {
@@ -155,12 +173,23 @@ module.exports = (app) => {
155
173
  }
156
174
  },
157
175
  {
158
- "path": "navigation.racing.bestApproach",
176
+ "path": "navigation.racing.effectiveVmg.toLine",
159
177
  "value": {
160
- "type": "object",
161
- "description": "The course actually sailed that achieved the best VMG towards the line: {cog in rad, sog in m/s, direction}",
162
- "displayName": "Best approach course",
163
- "shortName": "Approach"
178
+ "type": "number",
179
+ "units": "m/s",
180
+ "description": "VMG the perpendicular leg of the time to line was divided by: the collected best across the line, or the VMG being sailed right now if that is better",
181
+ "displayName": "Effective VMG to line",
182
+ "shortName": "VMG>Line"
183
+ }
184
+ },
185
+ {
186
+ "path": "navigation.racing.effectiveVmg.alongLine",
187
+ "value": {
188
+ "type": "number",
189
+ "units": "m/s",
190
+ "description": "VMG the along-line leg of the time to line was divided by, zero when inside the start zone",
191
+ "displayName": "Effective VMG along line",
192
+ "shortName": "VMG>Zone"
164
193
  }
165
194
  },
166
195
  {
@@ -208,6 +237,7 @@ module.exports = (app) => {
208
237
  "value": {
209
238
  "type": "number",
210
239
  "units": "m",
240
+ "displayUnits": { "category": "length" },
211
241
  "description": "Bias of the start line for the starboard end",
212
242
  "displayName": "Bias of the start line for the starboard end",
213
243
  "shortName": "Bias"
@@ -322,9 +352,6 @@ module.exports = (app) => {
322
352
  function publishBestVmg(position = {}) {
323
353
  const best = state.startLine ? getAllBestVmg() : null;
324
354
  const overrides = state.startLine ? getBestVmgOverrides() : null;
325
- const approach = state.startLine
326
- ? getBestApproach(position.ocs, position.toZoneVz, position.closestEnd)
327
- : null;
328
355
  const deltas = [];
329
356
 
330
357
  for (const name of Object.keys(vmgNames)) {
@@ -344,17 +371,20 @@ module.exports = (app) => {
344
371
  }
345
372
  }
346
373
 
347
- // The actual course behind the best VMG towards the line, rounded so a sample
348
- // change rather than float noise is what triggers a delta.
349
- const rounded = approach ? {
350
- cog: Math.round(approach.cog * 10000) / 10000,
351
- sog: Math.round(approach.sog * 1000) / 1000,
352
- direction: approach.direction
353
- } : null;
354
- const approachJson = JSON.stringify(rounded);
355
- if (state.publishedBestApproach !== approachJson) {
356
- state.publishedBestApproach = approachJson;
357
- deltas.push({path: 'navigation.racing.bestApproach', value: rounded});
374
+ // The two VMGs the time to line was actually divided by. These are what a client
375
+ // needs to draw the legs the estimate was built from; the collected bests above
376
+ // are only half the story, since the boat sailing better than its history right
377
+ // now wins. Only published once there is a position to resolve them against.
378
+ if (position.lineBearing != null) {
379
+ const effective = effectiveVmg(position.cog, position.sog, position.lineBearing,
380
+ position.toZoneVz, position.ocs, position.closestEnd);
381
+ for (const leg of ['toLine', 'alongLine']) {
382
+ const value = state.startLine ? Math.round(effective[leg] * 1000) / 1000 : null;
383
+ if (state.publishedEffectiveVmg[leg] !== value) {
384
+ state.publishedEffectiveVmg[leg] = value;
385
+ deltas.push({path: `navigation.racing.effectiveVmg.${leg}`, value});
386
+ }
387
+ }
358
388
  }
359
389
 
360
390
  if (deltas.length > 0)
@@ -720,6 +750,14 @@ module.exports = (app) => {
720
750
  if (!args)
721
751
  return complete(callback, 400, 'Failed to set best VMG: no value');
722
752
 
753
+ if (args.command === 'clear') {
754
+ // Throw away everything collected, not just the manual adjustments, so
755
+ // the estimate starts again from what the boat does next.
756
+ resetVmgSamples();
757
+ publishBestVmg();
758
+ return complete(callback, 200, 'Cleared best VMG samples: OK');
759
+ }
760
+
723
761
  if (args.command === 'reset') {
724
762
  // No vmg name clears every override.
725
763
  if (!clearBestVmgOverrides(args.vmg))
@@ -1036,12 +1074,11 @@ module.exports = (app) => {
1036
1074
  angle = 180 - (bearingToEnd - startLine.bearing);
1037
1075
  }
1038
1076
  angle = ((angle + 180) % 360 + 360) % 360 - 180;
1039
- const ocs = angle < 0;
1040
1077
 
1041
- // We define a start zone which includes a 45° wedge off each end of the line.
1042
- // - If the boat is inside the start zone, distanceToLine = perpendicular to line (or extension).
1043
- // - If the boat is outside the start zone, distanceToLine = distance parallel to the line to enter the zone +
1044
- // perpendicular distance to the line (or extension).
1078
+ // The start zone is a 45 degree wedge off each end of the line.
1079
+ // - Inside the zone, distanceToLine = perpendicular to the line (or extension).
1080
+ // - Outside it, distanceToLine = distance parallel to the line out to the
1081
+ // wedge, plus that perpendicular distance: an L whose corner sits on the 45.
1045
1082
  // This is illustrated for the starboard closest end of the line below:
1046
1083
  // \ /
1047
1084
  // \ /
@@ -1053,24 +1090,10 @@ module.exports = (app) => {
1053
1090
  // / \
1054
1091
  //
1055
1092
  // P=pin; B=boat; x=extension; b=perpToBoat; z=zoneEntry; V=vessel; N=north
1056
- // bearing = NBP
1057
- // PBz == 135
1058
- // bBz == zBx == 45
1059
- // Bb == bz == Vx == perpendicular distance to the line or extension.
1060
- // PBV = abs(angle)
1061
- // VBx = 180 - PBV
1062
- // Vx = toEnd * sin (VBx)
1063
- // bBV = 90 - VBx
1064
- // Vb = toEnd * sin (bBV)
1065
- // zb = sqrt((Vx * Vx) / 2)
1066
- // Vz = Vb - zb
1067
- const anglePBV = Math.abs(angle);
1068
- const inStartZone = anglePBV <= 135;
1069
- const angleVBx = 180 - anglePBV;
1070
- const perpToLineVx = toEnd * Math.sin(toRadians(angleVBx));
1071
- let toZoneVz = inStartZone ? 0 : ( toEnd * Math.sin(toRadians(90 - angleVBx)) - Math.sqrt(perpToLineVx * perpToLineVx / 2.0));
1072
- const toLine = Math.round( 10 * (toZoneVz + perpToLineVx)) / 10;
1073
- const distanceToLine = ocs ? -toLine : toLine;
1093
+ // bearing = NBP, PBz == 135, bBz == zBx == 45, Bb == bz == Vx == perpendicular
1094
+ // distance to the line or extension, PBV = abs(angle), VBx = 180 - PBV.
1095
+ // The geometry itself lives in racer.js so it can be unit tested.
1096
+ const {ocs, perpToLineVx, toZoneVz, distanceToLine} = startZoneDistances(toEnd, angle);
1074
1097
  app.debug('distanceToLine:' + distanceToLine);
1075
1098
  state.distanceToLine = distanceToLine;
1076
1099
 
@@ -1081,14 +1104,18 @@ module.exports = (app) => {
1081
1104
  collectVmgSamples(cog, sog, startLine.bearing, toZoneVz, perpToLineVx);
1082
1105
  }
1083
1106
  const ttl = computeTimeToLine(cog, sog, startLine.bearing, toZoneVz, perpToLineVx, ocs, closestEnd, state.timeToStart ?? 0);
1084
- const ttb = !ocs && state.timerRunning && state.timeToStart > 0 && ttl != null ? (state.timeToStart - ttl) : null;
1107
+ // Time to burn stands when OCS too: the time to line is then the time to get
1108
+ // back over the line from the course side, so the spare time before you have
1109
+ // to turn and do it is just as real - and goes negative when you have left it
1110
+ // too late, which is exactly when it is worth reading.
1111
+ const ttb = state.timerRunning && state.timeToStart > 0 && ttl != null ? (state.timeToStart - ttl) : null;
1085
1112
 
1086
1113
  sendDeltas([
1087
1114
  {path: 'navigation.racing.distanceStartline', value: distanceToLine},
1088
1115
  {path: "navigation.racing.timeToLine", value: ttl},
1089
1116
  {path: "navigation.racing.timeToBurn", value: ttb}
1090
1117
  ]);
1091
- publishBestVmg({ocs, toZoneVz, closestEnd});
1118
+ publishBestVmg({ocs, toZoneVz, closestEnd, cog, sog, lineBearing: startLine.bearing});
1092
1119
 
1093
1120
  } else if (state.distanceToLine) {
1094
1121
  state.distanceToLine = null;
@@ -1242,6 +1269,7 @@ module.exports = (app) => {
1242
1269
  initRacer({
1243
1270
  minSog: options.minSog ?? 1.0,
1244
1271
  minVmg: options.minVmg ?? 0.01,
1272
+ minEffectiveVmg: (options.minEffectiveVmg ?? 0.5) * 0.514444,
1245
1273
  maxDistance: options.maxDistance ?? 2000,
1246
1274
  maxSamples: options.maxSamples ?? 600,
1247
1275
  percentile: options.percentile ?? 0.9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-racer",
3
- "version": "1.3.0",
3
+ "version": "1.3.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": {