@packet-net/pdn-aprs 0.2.0 → 0.3.0

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/README.md +8 -4
  3. package/dist/builder.d.ts +33 -8
  4. package/dist/builder.d.ts.map +1 -1
  5. package/dist/builder.js +64 -10
  6. package/dist/builder.js.map +1 -1
  7. package/dist/decode/index.d.ts.map +1 -1
  8. package/dist/decode/index.js +5 -0
  9. package/dist/decode/index.js.map +1 -1
  10. package/dist/decode/other.js +1 -1
  11. package/dist/decode/other.js.map +1 -1
  12. package/dist/decode/positioned.d.ts +17 -1
  13. package/dist/decode/positioned.d.ts.map +1 -1
  14. package/dist/decode/positioned.js +21 -7
  15. package/dist/decode/positioned.js.map +1 -1
  16. package/dist/decode/reports.d.ts.map +1 -1
  17. package/dist/decode/reports.js +34 -12
  18. package/dist/decode/reports.js.map +1 -1
  19. package/dist/decoder.d.ts.map +1 -1
  20. package/dist/decoder.js +2 -4
  21. package/dist/decoder.js.map +1 -1
  22. package/dist/deviceid.d.ts +8 -2
  23. package/dist/deviceid.d.ts.map +1 -1
  24. package/dist/deviceid.js +10 -2
  25. package/dist/deviceid.js.map +1 -1
  26. package/dist/encode/common.d.ts +13 -2
  27. package/dist/encode/common.d.ts.map +1 -1
  28. package/dist/encode/common.js +36 -4
  29. package/dist/encode/common.js.map +1 -1
  30. package/dist/encode/index.d.ts.map +1 -1
  31. package/dist/encode/index.js +36 -16
  32. package/dist/encode/index.js.map +1 -1
  33. package/dist/encode/other.d.ts.map +1 -1
  34. package/dist/encode/other.js +10 -2
  35. package/dist/encode/other.js.map +1 -1
  36. package/dist/encode/position.d.ts +9 -5
  37. package/dist/encode/position.d.ts.map +1 -1
  38. package/dist/encode/position.js +219 -121
  39. package/dist/encode/position.js.map +1 -1
  40. package/dist/index.d.ts +1 -1
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/neutral.d.ts.map +1 -1
  44. package/dist/neutral.js +20 -3
  45. package/dist/neutral.js.map +1 -1
  46. package/dist/types.d.ts +13 -5
  47. package/dist/types.d.ts.map +1 -1
  48. package/package.json +1 -1
  49. package/src/builder.ts +77 -14
  50. package/src/decode/index.ts +4 -0
  51. package/src/decode/other.ts +1 -1
  52. package/src/decode/positioned.ts +33 -9
  53. package/src/decode/reports.ts +33 -9
  54. package/src/decoder.ts +2 -5
  55. package/src/deviceid.ts +12 -3
  56. package/src/encode/common.ts +34 -4
  57. package/src/encode/index.ts +33 -14
  58. package/src/encode/other.ts +12 -2
  59. package/src/encode/position.ts +227 -119
  60. package/src/index.ts +1 -0
  61. package/src/neutral.ts +22 -3
  62. package/src/types.ts +14 -1
@@ -1,27 +1,59 @@
1
1
  // Encoding positions, objects, items and Mic-E reports, with their data extensions, weather and
2
- // comment elements, in the canonical order: extension, altitude, frequency, comment, base-91
3
- // telemetry, !DAO! (and for Mic-E, the device suffix last).
2
+ // comment elements, in the canonical order: extension, frequency, signpost or corridor braces,
3
+ // altitude, comment, base-91 telemetry, !DAO! (vectors README, "Encoding"). Mic-E status text
4
+ // keeps its own order: extension, altitude, frequency, comment, telemetry, !DAO!, device suffix.
4
5
  import { AREA_COLORS, AREA_SHAPES, KNOTS_TO_MPH, METRES_PER_FOOT, MICROWAVE_BASE } from '../decode/positioned.js';
5
- import { base91, checkSymbol, freeText, isValidTimestamp, pad, refuse, timestamp7, whole } from './common.js';
6
+ import { base91, checkSymbol, freeText, isValidTimestamp, nearest, pad, refuse, timestamp7, whole } from './common.js';
6
7
  const EPS = 1e-9;
8
+ const CUT_TOLERANCE = 1e-6;
7
9
  /** Negative, or negative zero: a zero coordinate keeps its hemisphere as the sign of zero. */
8
10
  export function isNegative(v) {
9
11
  return v < 0 || Object.is(v, -0);
10
12
  }
11
- /** Degrees and hundredths of a minute; truncated when a !DAO! or ambiguity follows, else rounded. */
12
- function digitsOf(value, truncate) {
13
+ /** Degrees and hundredths of a minute, and the `!DAO!` digit below them. */
14
+ function digitsOf(value, mode, ambiguity = 0) {
13
15
  const abs = Math.abs(value);
14
- let degrees = Math.floor(abs + EPS);
15
- let minutes = (abs - degrees) * 60;
16
- if (minutes < 0)
17
- minutes = 0;
18
- let hundredths = truncate ? Math.floor(minutes * 100 + EPS) : Math.round(minutes * 100);
16
+ // The ambiguity cut takes F2's tolerance: a value within 1e-6 of the box edge above it, in the
17
+ // box's units, is on that edge (vectors rulings, G2).
18
+ let degrees = Math.floor(abs + (mode === 'truncate' && ambiguity >= 4 ? CUT_TOLERANCE : EPS));
19
+ const minutes = Math.max(0, (abs - degrees) * 60);
20
+ let hundredths;
21
+ let extra = 0;
22
+ switch (mode) {
23
+ case 'round':
24
+ hundredths = nearest(minutes * 100);
25
+ break;
26
+ case 'truncate': {
27
+ // Hundredths of a minute in each box: a tenth, a minute, ten minutes, or a whole degree.
28
+ const box = ambiguity >= 4 ? 6000 : ambiguity === 3 ? 1000 : ambiguity === 2 ? 100 : ambiguity === 1 ? 10 : 1;
29
+ hundredths = ambiguity >= 4 ? 0 : Math.floor((minutes * 100) / box + (ambiguity > 0 ? CUT_TOLERANCE : EPS)) * box;
30
+ break;
31
+ }
32
+ case 'thousandths': {
33
+ const steps = nearest(minutes * 1000);
34
+ hundredths = Math.floor(steps / 10);
35
+ extra = steps % 10;
36
+ break;
37
+ }
38
+ case 'base91': {
39
+ const steps = nearest(minutes * 9100);
40
+ hundredths = Math.floor(steps / 91);
41
+ extra = steps % 91;
42
+ break;
43
+ }
44
+ }
19
45
  if (hundredths >= 6000) {
20
46
  degrees += 1;
21
47
  hundredths -= 6000;
22
48
  }
23
- const remainder = Math.max(0, minutes - hundredths / 100);
24
- return { degrees, hundredths, remainder };
49
+ return { degrees, hundredths, extra };
50
+ }
51
+ /** How a position's digits are found, given its ambiguity and `!DAO!`. */
52
+ function digitsMode(f) {
53
+ if ((f.ambiguity ?? 0) > 0)
54
+ return 'truncate';
55
+ const precision = f.dao?.precision;
56
+ return precision === 'thousandths' || precision === 'base91' ? precision : 'round';
25
57
  }
26
58
  function blank(text, positions, ambiguity) {
27
59
  const chars = text.split('');
@@ -29,8 +61,8 @@ function blank(text, positions, ambiguity) {
29
61
  chars[positions[positions.length - 1 - k]] = ' ';
30
62
  return chars.join('');
31
63
  }
32
- /** `ddmm.hhN/dddmm.hhW$`, with the DAO remainders. */
33
- function uncompressedPosition(f, truncate) {
64
+ /** `ddmm.hhN/dddmm.hhW$`, with the `!DAO!` digits. */
65
+ function uncompressedPosition(f) {
34
66
  if (!(f.latitude >= -90 && f.latitude <= 90))
35
67
  refuse(`latitude ${f.latitude} is out of range`);
36
68
  if (!(f.longitude >= -180 && f.longitude <= 180))
@@ -39,8 +71,9 @@ function uncompressedPosition(f, truncate) {
39
71
  if (!Number.isInteger(ambiguity) || ambiguity < 0 || ambiguity > 4)
40
72
  refuse('ambiguity is 0-4 digits');
41
73
  checkSymbol(f.symbol);
42
- const lat = digitsOf(f.latitude, truncate || ambiguity > 0);
43
- const lon = digitsOf(f.longitude, truncate || ambiguity > 0);
74
+ const mode = digitsMode(f);
75
+ const lat = digitsOf(f.latitude, mode, ambiguity);
76
+ const lon = digitsOf(f.longitude, mode, ambiguity);
44
77
  if (lat.degrees > 90 || (lat.degrees === 90 && lat.hundredths > 0))
45
78
  refuse('latitude is out of range');
46
79
  if (lon.degrees > 180 || (lon.degrees === 180 && lon.hundredths > 0))
@@ -51,8 +84,8 @@ function uncompressedPosition(f, truncate) {
51
84
  const lonText = blank(pad(lon.degrees, 3) + lonMin, [3, 4, 6, 7], ambiguity) + (isNegative(f.longitude) ? 'W' : 'E');
52
85
  return {
53
86
  text: latText + f.symbol.table + lonText + f.symbol.code,
54
- latRemainder: lat.remainder,
55
- lonRemainder: lon.remainder,
87
+ latExtra: lat.extra,
88
+ lonExtra: lon.extra,
56
89
  };
57
90
  }
58
91
  const SOURCES = ['other', 'gll', 'gga', 'rmc'];
@@ -65,7 +98,11 @@ function typeByte(c) {
65
98
  refuse('the compression type is not one APRS defines');
66
99
  return String.fromCharCode(((c.fix === 'current' ? 1 : 0) << 5) + (source << 3) + origin + 33);
67
100
  }
68
- /** The compressed position; `cs` is the two cs bytes or `undefined` for none. */
101
+ /**
102
+ * The compressed position; `cs` is the two cs bytes or `undefined` for none. Also gives the
103
+ * `!DAO!` digits for it: a decoder does not apply them, so they are the ones a `!DAO!` of that
104
+ * precision gives the position the bytes report, written uncompressed (vectors rulings, E2).
105
+ */
69
106
  function compressedPosition(f, cs, compression) {
70
107
  if (!(f.latitude >= -90 && f.latitude <= 90))
71
108
  refuse(`latitude ${f.latitude} is out of range`);
@@ -78,10 +115,15 @@ function compressedPosition(f, cs, compression) {
78
115
  if (table >= '0' && table <= '9')
79
116
  table = String.fromCharCode(table.charCodeAt(0) + 49); // 0-9 -> a-j
80
117
  const max = 91 ** 4 - 1;
81
- const y = Math.min(max, Math.max(0, Math.round(380926 * (90 - f.latitude))));
82
- const x = Math.min(max, Math.max(0, Math.round(190463 * (180 + f.longitude))));
118
+ const y = Math.min(max, Math.max(0, nearest(380926 * (90 - f.latitude))));
119
+ const x = Math.min(max, Math.max(0, nearest(190463 * (180 + f.longitude))));
83
120
  const tail = cs === undefined ? ' sT' : cs + typeByte(compression ?? DEFAULT_COMPRESSION);
84
- return table + base91(y, 4) + base91(x, 4) + f.symbol.code + tail;
121
+ const mode = digitsMode(f);
122
+ return {
123
+ text: table + base91(y, 4) + base91(x, 4) + f.symbol.code + tail,
124
+ latExtra: digitsOf(90 - y / 380926, mode).extra,
125
+ lonExtra: digitsOf(x / 190463 - 180, mode).extra,
126
+ };
85
127
  }
86
128
  /**
87
129
  * A GGA altitude in the cs bytes: 1.002^cs feet, so the nearest value they hold (1 foot for 0 feet
@@ -90,12 +132,48 @@ function compressedPosition(f, cs, compression) {
90
132
  function ggaAltitude(feet) {
91
133
  if (!Number.isFinite(feet))
92
134
  refuse('altitude is not a number');
93
- const v = feet <= 1 ? 0 : Math.round(Math.log(feet) / Math.log(1.002));
135
+ const v = feet <= 1 ? 0 : nearest(Math.log(feet) / Math.log(1.002));
94
136
  if (v > 91 * 91 - 1)
95
137
  refuse('altitude is too high for the compressed format');
96
138
  const cs = String.fromCharCode(Math.floor(v / 91) + 33) + String.fromCharCode((v % 91) + 33);
97
139
  return { cs, exact: Math.abs(Math.pow(1.002, v) - feet) <= 1e-9 * Math.abs(feet) };
98
140
  }
141
+ /**
142
+ * A course or wind direction in the cs bytes: 4 degree steps, rounded to the nearest (halves away
143
+ * from zero), and one that rounds to 360 is c = 0, north (vectors rulings, E9).
144
+ */
145
+ function compressedDirection(degrees) {
146
+ return String.fromCharCode((nearest(degrees / 4) % 90) + 33);
147
+ }
148
+ /**
149
+ * A speed in knots in the cs bytes: 1.08^s - 1, the nearest s for any whole s, refused when that
150
+ * s is outside the byte (0-90; 90 is `{`), so -0.03 knots is 0 and -0.3 is refused (vectors
151
+ * rulings, G3).
152
+ */
153
+ function compressedSpeed(knots, what) {
154
+ if (!(knots > -1))
155
+ refuse(`${what} has no compressed form`);
156
+ const s = nearest(Math.log(knots + 1) / Math.log(1.08));
157
+ if (s < 0)
158
+ refuse(`${what} is negative`);
159
+ if (s > 90)
160
+ refuse(`${what} is too high for the compressed format`);
161
+ return String.fromCharCode(s + 33);
162
+ }
163
+ /**
164
+ * A range in the cs bytes: `{` and 2 x 1.08^s miles, the nearest s for any whole s, refused when
165
+ * that s is below 0, so 1.95 miles is 2 and 1.9 is refused (vectors rulings, G3).
166
+ */
167
+ function compressedRange(miles) {
168
+ if (!(miles > 0))
169
+ refuse('a compressed range is at least 2 miles');
170
+ const s = nearest(Math.log(miles / 2) / Math.log(1.08));
171
+ if (s < 0)
172
+ refuse('a compressed range is at least 2 miles');
173
+ if (s > 90)
174
+ refuse('range is out of range for the compressed format');
175
+ return '{' + String.fromCharCode(s + 33);
176
+ }
99
177
  // ---- data extensions and comment elements
100
178
  function courseSpeed(course, speed, what = 'course') {
101
179
  const c = course === undefined ? '...' : pad(whole(course, 0, 360, what), 3);
@@ -109,9 +187,11 @@ function rateChar(r) {
109
187
  const v = whole(r, 1, 35, 'beacons per hour');
110
188
  return v <= 9 ? String(v) : String.fromCharCode(55 + v);
111
189
  }
112
- function altitudeText(feet) {
113
- if (!Number.isInteger(feet))
114
- refuse('an /A= altitude is whole feet');
190
+ function altitudeText(given) {
191
+ if (!Number.isFinite(given))
192
+ refuse('altitude is not a number');
193
+ // Whole feet, the nearest (vectors rulings, F2).
194
+ const feet = nearest(given);
115
195
  if (feet < 0) {
116
196
  if (feet < -99999)
117
197
  refuse('altitude is out of range');
@@ -128,7 +208,7 @@ export function frequencyText(f) {
128
208
  let whole3;
129
209
  let fraction;
130
210
  const resolution = f.tenKhzResolution ? 100 : 1000;
131
- const scaled = Math.round(mhz * resolution);
211
+ const scaled = nearest(mhz * resolution);
132
212
  const intPart = Math.floor(scaled / resolution);
133
213
  const fracPart = scaled % resolution;
134
214
  fraction = pad(fracPart, f.tenKhzResolution ? 2 : 3);
@@ -190,7 +270,11 @@ export function telemetryText(t) {
190
270
  out += base91(whole(t.digital, 0, 255, 'telemetry bits'), 2);
191
271
  return out + '|';
192
272
  }
193
- function daoText(f, latRemainder, lonRemainder, applied) {
273
+ /**
274
+ * The `!DAO!`: its datum, and digits `latExtra` and `lonExtra` (from `digitsOf`), or zeros when
275
+ * `applied` is false (an ambiguous position, whose digits say nothing finer).
276
+ */
277
+ function daoText(f, latExtra, lonExtra, applied) {
194
278
  const dao = f.dao;
195
279
  const datum = dao.datum;
196
280
  if (/^[0-9]$/.test(datum)) {
@@ -204,20 +288,14 @@ function daoText(f, latRemainder, lonRemainder, applied) {
204
288
  switch (dao.precision) {
205
289
  case 'none':
206
290
  return `!${datum.toUpperCase()} !`;
207
- case 'thousandths': {
291
+ case 'thousandths':
208
292
  if (!applied)
209
293
  return `!${datum.toUpperCase()}00!`;
210
- const a = Math.min(9, Math.round(latRemainder * 1000));
211
- const o = Math.min(9, Math.round(lonRemainder * 1000));
212
- return `!${datum.toUpperCase()}${a}${o}!`;
213
- }
214
- case 'base91': {
294
+ return `!${datum.toUpperCase()}${latExtra}${lonExtra}!`;
295
+ case 'base91':
215
296
  if (!applied)
216
297
  return `!${datum.toLowerCase()}!!!`;
217
- const a = Math.min(90, Math.round(latRemainder * 100 * 91));
218
- const o = Math.min(90, Math.round(lonRemainder * 100 * 91));
219
- return `!${datum.toLowerCase()}${String.fromCharCode(a + 33)}${String.fromCharCode(o + 33)}!`;
220
- }
298
+ return `!${datum.toLowerCase()}${String.fromCharCode(latExtra + 33)}${String.fromCharCode(lonExtra + 33)}!`;
221
299
  }
222
300
  }
223
301
  // ---- weather
@@ -230,23 +308,23 @@ function weatherValue(v, width, min, max, what) {
230
308
  return pad(n, width);
231
309
  }
232
310
  /**
233
- * Snowfall written exactly in its three characters, with a decimal point where it needs one
234
- * (0.32 as `.32`), and refused when they cannot hold it.
311
+ * Snowfall in its three characters (vectors rulings, F3): rounded to hundredths if that leaves it
312
+ * under 1, else to tenths if that leaves it under 10, else to a whole number; then a whole number
313
+ * is three digits (`002`, 0.995 as `001`), one under 1 is `.` and two digits (`.50`), any other a
314
+ * digit, `.` and a digit (`2.5`). One that rounds past 999 is refused.
235
315
  */
236
316
  function snowfallText(inches) {
237
- if (!Number.isFinite(inches) || inches < 0)
317
+ if (!Number.isFinite(inches))
238
318
  refuse('snowfall is not a number of inches');
239
- let text;
240
- if (Number.isInteger(inches))
241
- text = pad(whole(inches, 0, 999, 'snowfall'), 3);
242
- else if (String(inches).length === 3)
243
- text = String(inches);
244
- else
245
- text = inches < 1 ? '.' + pad(Math.round(inches * 100), 2) : '';
246
- if (!/^[0-9.]{3}$/.test(text) || /\..*\./.test(text) || Math.abs(Number(text) - inches) > 1e-9) {
247
- refuse(`snowfall ${inches} does not fit in 3 characters`);
248
- }
249
- return text;
319
+ const hundredths = nearest(inches * 100);
320
+ if (hundredths < 0)
321
+ refuse('snowfall is negative');
322
+ if (hundredths < 100)
323
+ return hundredths % 100 === 0 ? '000' : '.' + pad(hundredths, 2);
324
+ const tenths = nearest(inches * 10);
325
+ if (tenths < 100)
326
+ return tenths % 10 === 0 ? pad(tenths / 10, 3) : `${Math.floor(tenths / 10)}.${tenths % 10}`;
327
+ return pad(whole(inches, 0, 999, 'snowfall'), 3);
250
328
  }
251
329
  /** Weather fields after the wind: gust, temperature, rain, humidity, pressure, and on. */
252
330
  function weatherFields(w, mandatory) {
@@ -326,7 +404,8 @@ function positionedBody(f, mode) {
326
404
  if (isWeather)
327
405
  return weatherBody(f);
328
406
  const exts = hasExtension(f);
329
- const hasCourse = f.courseDegrees !== undefined || f.speedKnots !== undefined;
407
+ // DF and storm data extend a course/speed, so they count as that extension (vectors rulings, F7).
408
+ const hasCourse = f.courseDegrees !== undefined || f.speedKnots !== undefined || (!f.compressed && (f.dfBearing !== undefined || f.storm !== undefined));
330
409
  if (exts.length + (hasCourse ? 1 : 0) > 1)
331
410
  refuse(`only one data extension can follow the symbol (${[hasCourse ? 'course/speed' : '', ...exts].filter(Boolean).join(', ')})`);
332
411
  if (f.dfBearing && !(f.symbol.table === '/' && f.symbol.code === '\\'))
@@ -340,8 +419,8 @@ function positionedBody(f, mode) {
340
419
  let out = '';
341
420
  let compression;
342
421
  let altitudeInCs = false;
343
- let latRem = 0;
344
- let lonRem = 0;
422
+ let latExtra = 0;
423
+ let lonExtra = 0;
345
424
  let daoApplied = false;
346
425
  if (f.compressed) {
347
426
  if (f.phg || f.dfs || f.area || f.dfBearing || f.storm)
@@ -349,29 +428,22 @@ function positionedBody(f, mode) {
349
428
  let cs;
350
429
  compression = f.compression;
351
430
  if (hasCourse) {
431
+ // The cs bytes hold both, so one alone would add a value the data does not have; a course of
432
+ // 0 (after rounding) would read back as 360 (vectors rulings, F1, F5).
352
433
  if (f.courseDegrees === undefined)
353
434
  refuse('a compressed course/speed needs a course');
435
+ if (f.speedKnots === undefined)
436
+ refuse('a compressed course/speed needs a speed');
354
437
  if (compression?.source === 'gga')
355
438
  refuse('a GGA compression type means the cs bytes hold altitude');
356
- const c = whole(f.courseDegrees, 1, 360, 'course') % 360;
357
- if (c % 4 !== 0 && Math.abs(Math.round(c / 4) * 4 - f.courseDegrees) > 2)
358
- refuse('course is out of range');
359
- const speed = f.speedKnots ?? 0;
360
- if (speed < 0)
361
- refuse('speed is negative');
362
- const s = Math.round(Math.log(speed + 1) / Math.log(1.08));
363
- if (s > 90)
364
- refuse('speed is too high for the compressed format');
365
- cs = String.fromCharCode(Math.round(c / 4) % 90 + 33) + String.fromCharCode(s + 33);
439
+ whole(f.courseDegrees, 1, 360, 'course');
440
+ cs = compressedDirection(f.courseDegrees) + compressedSpeed(f.speedKnots, 'speed');
366
441
  compression = compression ?? DEFAULT_COMPRESSION;
367
442
  }
368
443
  else if (f.rangeMiles !== undefined) {
369
444
  if (compression?.source === 'gga')
370
445
  refuse('a GGA compression type means the cs bytes hold altitude');
371
- const s = Math.round(Math.log(f.rangeMiles / 2) / Math.log(1.08));
372
- if (!(s >= 0 && s <= 90))
373
- refuse('range is out of range for the compressed format');
374
- cs = '{' + String.fromCharCode(s + 33);
446
+ cs = compressedRange(f.rangeMiles);
375
447
  compression = compression ?? DEFAULT_COMPRESSION;
376
448
  }
377
449
  else if (f.altitudeFeet !== undefined && compression?.source === 'gga') {
@@ -382,17 +454,23 @@ function positionedBody(f, mode) {
382
454
  else if (compression !== undefined) {
383
455
  refuse('a compression type needs cs data (course/speed, range or altitude)');
384
456
  }
385
- out += compressedPosition(f, cs, compression);
457
+ const pos = compressedPosition(f, cs, compression);
458
+ out += pos.text;
459
+ latExtra = pos.latExtra;
460
+ lonExtra = pos.lonExtra;
461
+ daoApplied = true;
386
462
  }
387
463
  else {
388
464
  if (f.compression)
389
465
  refuse('an uncompressed position has no compression type');
390
- const pos = uncompressedPosition(f, f.dao !== undefined);
466
+ const pos = uncompressedPosition(f);
391
467
  out += pos.text;
392
- latRem = pos.latRemainder;
393
- lonRem = pos.lonRemainder;
468
+ latExtra = pos.latExtra;
469
+ lonExtra = pos.lonExtra;
394
470
  daoApplied = (f.ambiguity ?? 0) === 0;
395
471
  if (hasCourse) {
472
+ // DF or storm data with no course and speed follows `.../...`, which reads back as unknown
473
+ // (vectors rulings, F8).
396
474
  out += courseSpeed(f.courseDegrees, f.speedKnots);
397
475
  if (f.dfBearing) {
398
476
  const b = f.dfBearing;
@@ -401,9 +479,6 @@ function positionedBody(f, mode) {
401
479
  if (f.storm)
402
480
  out += stormText(f.storm);
403
481
  }
404
- else if (f.dfBearing || f.storm) {
405
- refuse('DF and storm data follow a course/speed extension');
406
- }
407
482
  else if (f.phg) {
408
483
  const p = f.phg;
409
484
  out += `PHG${whole(p.power, 0, 9, 'power code')}${heightChar(p.height)}${whole(p.gain, 0, 9, 'gain code')}${whole(p.directivity, 0, 9, 'directivity code')}`;
@@ -427,14 +502,15 @@ function positionedBody(f, mode) {
427
502
  out += `${shape}${pad(whole(a.latOffset, 0, 99, 'area offset'), 2)}${c}${pad(whole(a.lonOffset, 0, 99, 'area offset'), 2)}`;
428
503
  }
429
504
  }
430
- // Altitude, unless the compressed cs bytes carry it exactly.
431
- if (f.altitudeFeet !== undefined && !altitudeInCs)
432
- out += altitudeText(f.altitudeFeet);
433
- const extensionEnded = !f.compressed && (hasCourse || exts.length > 0) && f.altitudeFeet === undefined;
505
+ // After the data extension: the frequency with its fields, first, where radios read it (APRS12c
506
+ // ch. 18); the signpost or corridor braces; the altitude, unless the compressed cs bytes carry it
507
+ // exactly; the free text; base-91 telemetry; the !DAO! (vectors rulings, E3).
434
508
  let text = '';
435
- // A frequency straight after a 7-byte extension is separated from it by / (a PHGR already ends in one).
509
+ // A frequency straight after a 7-byte extension follows a / (a PHGR already ends in one); after a
510
+ // compressed position, or a position with no extension, it follows straight on.
511
+ const extended = !f.compressed && (hasCourse || exts.length > 0);
436
512
  if (f.frequency)
437
- text += (extensionEnded && !out.endsWith('/') ? '/' : '') + frequencyText(f.frequency);
513
+ text += (extended && !out.endsWith('/') ? '/' : '') + frequencyText(f.frequency);
438
514
  let braces = '';
439
515
  if (f.signpost !== undefined) {
440
516
  // Printable ASCII, a space included, other than the braces around it.
@@ -447,17 +523,21 @@ function positionedBody(f, mode) {
447
523
  refuse('only a line has a corridor');
448
524
  braces = `{${whole(f.area.corridorWidthMiles, 0, 999, 'corridor width')}}`;
449
525
  }
526
+ text += braces;
527
+ if (f.altitudeFeet !== undefined && !altitudeInCs)
528
+ text += altitudeText(f.altitudeFeet);
450
529
  const comment = f.comment ?? '';
451
530
  if (comment.length > 0 || mode === 'delimited') {
452
- if (text.length > 0 && mode !== 'joined')
531
+ // After a frequency (and anything after it) the free text follows a space, unless that would
532
+ // make it read as one of the frequency's fields.
533
+ if ((f.frequency && mode !== 'joined') || mode === 'spaced')
453
534
  text += ' ';
454
535
  text += (mode === 'delimited' ? '/' : '') + freeText(comment, 'the comment');
455
536
  }
456
- text = mode === 'braces-first' ? braces + text : text + braces;
457
537
  if (f.telemetry)
458
538
  text += telemetryText(f.telemetry);
459
539
  if (f.dao)
460
- text += daoText(f, latRem, lonRem, daoApplied);
540
+ text += daoText(f, latExtra, lonExtra, daoApplied);
461
541
  const body = { text: out + text };
462
542
  if (compression)
463
543
  body.compression = compression;
@@ -465,7 +545,8 @@ function positionedBody(f, mode) {
465
545
  }
466
546
  function stormText(s) {
467
547
  const type = s.type === 'tropical-storm' ? 'TS' : s.type === 'hurricane' ? 'HC' : s.type === 'tropical-depression' ? 'TD' : refuse('unknown storm type');
468
- const v = (n, width, what) => (n === undefined ? ' '.repeat(width) : pad(whole(n, 0, 10 ** width - 1, what), width));
548
+ // Each field is digits: one with no value has no form (vectors README, storm data).
549
+ const v = (n, width, what) => n === undefined ? refuse(`storm data needs its ${what}`) : pad(whole(n, 0, 10 ** width - 1, what), width);
469
550
  let out = `/${type}/${v(s.sustainedWindKnots, 3, 'sustained wind')}^${v(s.gustKnots, 3, 'gust')}/${v(s.centralPressureMbar, 4, 'pressure')}>${v(s.hurricaneRadiusNm, 3, 'radius')}&${v(s.tropicalStormRadiusNm, 3, 'radius')}`;
470
551
  if (s.wholeGaleRadiusNm !== undefined)
471
552
  out += `%${v(s.wholeGaleRadiusNm, 3, 'radius')}`;
@@ -485,8 +566,8 @@ function weatherBody(f) {
485
566
  refuse('comment telemetry goes in a comment, which a weather report does not have');
486
567
  let out = '';
487
568
  let compression;
488
- let latRem = 0;
489
- let lonRem = 0;
569
+ let latExtra = 0;
570
+ let lonExtra = 0;
490
571
  let daoApplied = false;
491
572
  let altitudeInCs = false;
492
573
  if (f.compressed) {
@@ -495,34 +576,31 @@ function weatherBody(f) {
495
576
  if (w.windDirectionDegrees !== undefined || w.windSpeedMph !== undefined) {
496
577
  if (w.windDirectionDegrees === undefined || w.windSpeedMph === undefined)
497
578
  refuse('compressed wind needs both direction and speed');
498
- const dir = whole(w.windDirectionDegrees, 0, 360, 'wind direction');
499
- const knots = w.windSpeedMph / KNOTS_TO_MPH;
500
- const s = Math.round(Math.log(knots + 1) / Math.log(1.08));
501
- if (s < 0 || s > 90)
502
- refuse('wind speed is out of range for the compressed format');
503
- cs = String.fromCharCode((Math.round(dir / 4) % 90) + 33) + String.fromCharCode(s + 33);
579
+ whole(w.windDirectionDegrees, 0, 360, 'wind direction');
580
+ cs = compressedDirection(w.windDirectionDegrees) + compressedSpeed(w.windSpeedMph / KNOTS_TO_MPH, 'wind speed');
504
581
  compression = compression ?? DEFAULT_COMPRESSION;
505
582
  if (compression.source === 'gga')
506
583
  refuse('a GGA compression type means the cs bytes hold altitude');
507
584
  }
508
585
  else if (f.altitudeFeet !== undefined && compression?.source === 'gga') {
509
- const alt = ggaAltitude(f.altitudeFeet);
510
- cs = alt.cs;
511
- altitudeInCs = alt.exact;
586
+ // With no comment to hold a /A=, the nearest value the cs bytes hold (vectors rulings, F14).
587
+ cs = ggaAltitude(f.altitudeFeet).cs;
588
+ altitudeInCs = true;
512
589
  }
513
590
  else if (f.rangeMiles !== undefined) {
514
591
  if (compression?.source === 'gga')
515
592
  refuse('a GGA compression type means the cs bytes hold altitude');
516
- const s = Math.round(Math.log(f.rangeMiles / 2) / Math.log(1.08));
517
- if (!(s >= 0 && s <= 90))
518
- refuse('range is out of range for the compressed format');
519
- cs = '{' + String.fromCharCode(s + 33);
593
+ cs = compressedRange(f.rangeMiles);
520
594
  compression = compression ?? DEFAULT_COMPRESSION;
521
595
  }
522
596
  else if (compression !== undefined) {
523
597
  refuse('a compression type needs cs data');
524
598
  }
525
- out += compressedPosition(f, cs, compression);
599
+ const pos = compressedPosition(f, cs, compression);
600
+ out += pos.text;
601
+ latExtra = pos.latExtra;
602
+ lonExtra = pos.lonExtra;
603
+ daoApplied = true;
526
604
  if (f.altitudeFeet !== undefined && !altitudeInCs)
527
605
  refuse('a weather report has no altitude in its comment');
528
606
  out += weatherFields(w, true);
@@ -532,10 +610,10 @@ function weatherBody(f) {
532
610
  refuse('an uncompressed position has no compression type');
533
611
  if (f.altitudeFeet !== undefined)
534
612
  refuse('a weather report has no altitude in its comment');
535
- const pos = uncompressedPosition(f, f.dao !== undefined);
613
+ const pos = uncompressedPosition(f);
536
614
  out += pos.text;
537
- latRem = pos.latRemainder;
538
- lonRem = pos.lonRemainder;
615
+ latExtra = pos.latExtra;
616
+ lonExtra = pos.lonExtra;
539
617
  daoApplied = (f.ambiguity ?? 0) === 0;
540
618
  out += courseSpeed(w.windDirectionDegrees, w.windSpeedMph, 'wind direction');
541
619
  out += weatherFields(w, true);
@@ -543,7 +621,7 @@ function weatherBody(f) {
543
621
  if (f.telemetry)
544
622
  out += telemetryText(f.telemetry);
545
623
  if (f.dao)
546
- out += daoText(f, latRem, lonRem, daoApplied);
624
+ out += daoText(f, latExtra, lonExtra, daoApplied);
547
625
  const body = { text: out };
548
626
  if (compression)
549
627
  body.compression = compression;
@@ -633,8 +711,13 @@ function micEDestination(d, lat, lonDegrees) {
633
711
  }
634
712
  return out;
635
713
  }
714
+ /**
715
+ * Mic-E reports whose altitude was given in feet by a program, so it goes in a `/A=` and not the
716
+ * Mic-E altitude bytes, whose unit is the metre (vectors rulings, B2).
717
+ */
718
+ export const ALTITUDE_GIVEN_IN_FEET = new WeakSet();
636
719
  export function micEAltitude(feet) {
637
- const metres = Math.round(feet * METRES_PER_FOOT);
720
+ const metres = nearest(feet * METRES_PER_FOOT);
638
721
  if (Math.abs(metres / METRES_PER_FOOT - feet) > 1e-6)
639
722
  return undefined;
640
723
  const v = metres + 10000;
@@ -658,9 +741,9 @@ export function encodeMicE(d, mode) {
658
741
  const ambiguity = d.ambiguity ?? 0;
659
742
  if (!Number.isInteger(ambiguity) || ambiguity < 0 || ambiguity > 4)
660
743
  refuse('ambiguity is 0-4 digits');
661
- const truncate = d.dao !== undefined || ambiguity > 0;
662
- const lat = digitsOf(d.latitude, truncate);
663
- const lon = digitsOf(d.longitude, truncate);
744
+ const digits = digitsMode(d);
745
+ const lat = digitsOf(d.latitude, digits, ambiguity);
746
+ const lon = digitsOf(d.longitude, digits, ambiguity);
664
747
  if (lat.degrees > 90 || (lat.degrees === 90 && lat.hundredths > 0))
665
748
  refuse('latitude is out of range');
666
749
  if (lon.degrees > 179)
@@ -677,16 +760,28 @@ export function encodeMicE(d, mode) {
677
760
  dByte = deg + 8;
678
761
  else
679
762
  dByte = deg - 100 + 28;
680
- // With ambiguity the digits a decoder ignores are written as the centre it reports.
681
- const mValue = Math.floor(lon.hundredths / 100);
682
- const hundredths = lon.hundredths % 100;
763
+ // With ambiguity the digits a decoder ignores are written as the centre it reports (vectors
764
+ // README, Mic-E): 03.5x is 03.55, 03.xx 03.50, 0x.xx 05.00, xx.xx 30.00.
765
+ let lonHundredths = lon.hundredths;
766
+ if (ambiguity === 1)
767
+ lonHundredths = Math.floor(lonHundredths / 10) * 10 + 5;
768
+ else if (ambiguity === 2)
769
+ lonHundredths = Math.floor(lonHundredths / 100) * 100 + 50;
770
+ else if (ambiguity === 3)
771
+ lonHundredths = Math.floor(lonHundredths / 1000) * 1000 + 500;
772
+ else if (ambiguity === 4)
773
+ lonHundredths = 3000;
774
+ const mValue = Math.floor(lonHundredths / 100);
775
+ const hundredths = lonHundredths % 100;
683
776
  const mByte = mValue < 10 ? mValue + 88 : mValue + 28;
684
777
  const hByte = hundredths + 28;
685
- // Speed and course: speed tens + 80 (so values under 200 knots are printable), course + 400.
778
+ // Speed and course in their printable forms (APRS12c ch. 10): speed tens + 80 below 190 knots,
779
+ // and as they are from 190 on (190-199 is /, not DEL: vectors rulings, E5); course + 400.
686
780
  const speed = d.speedKnots === undefined ? 0 : whole(d.speedKnots, 0, 799, 'speed');
687
- const course = d.courseDegrees === undefined ? 0 : whole(d.courseDegrees, 0, 360, 'course');
781
+ // A course of 0 (after rounding) reads back as no course, so it is refused (vectors rulings, F1).
782
+ const course = d.courseDegrees === undefined ? 0 : whole(d.courseDegrees, 1, 360, 'course');
688
783
  const tens = Math.floor(speed / 10);
689
- const spByte = tens < 20 ? tens + 80 + 28 : tens + 28;
784
+ const spByte = tens < 19 ? tens + 80 + 28 : tens + 28;
690
785
  const dcByte = (speed % 10) * 10 + Math.floor(course / 100) + 4 + 28;
691
786
  const seByte = (course % 100) + 28;
692
787
  const dti = d.oldData ? "'" : '`';
@@ -707,7 +802,7 @@ export function encodeMicE(d, mode) {
707
802
  }
708
803
  let altitudeInComment;
709
804
  if (d.altitudeFeet !== undefined) {
710
- const alt = micEAltitude(d.altitudeFeet);
805
+ const alt = ALTITUDE_GIVEN_IN_FEET.has(d) ? undefined : micEAltitude(d.altitudeFeet);
711
806
  if (alt !== undefined)
712
807
  text += alt;
713
808
  else
@@ -737,15 +832,18 @@ export function encodeMicE(d, mode) {
737
832
  rest += (exts > 0 && altitudeInComment === undefined && !after.endsWith('/') ? '/' : '') + frequencyText(d.frequency);
738
833
  const comment = d.comment ?? '';
739
834
  if (comment.length > 0 || mode === 'delimited') {
740
- if (rest.length > 0 && mode !== 'joined')
835
+ if ((rest.length > 0 && mode !== 'joined') || mode === 'spaced')
741
836
  rest += ' ';
742
837
  rest += (mode === 'delimited' ? '/' : '') + freeText(comment, 'the comment');
743
838
  }
744
839
  after += rest;
840
+ // Telemetry and the !DAO! are lifted before a locator is read, so they follow it straight on
841
+ // (vectors rulings, F16).
842
+ let lifted = '';
745
843
  if (d.telemetry)
746
- after += telemetryText(d.telemetry);
844
+ lifted += telemetryText(d.telemetry);
747
845
  if (d.dao)
748
- after += daoText(d, lat.remainder, lon.remainder, ambiguity === 0);
846
+ lifted += daoText(d, lat.extra, lon.extra, ambiguity === 0);
749
847
  if (d.locator !== undefined) {
750
848
  if (!/^[A-R]{2}[0-9]{2}(?:[A-X]{2})?$/.test(d.locator))
751
849
  refuse('the locator is 4 or 6 characters, upper case');
@@ -753,7 +851,7 @@ export function encodeMicE(d, mode) {
753
851
  if (after.length > 0)
754
852
  text += ' ';
755
853
  }
756
- text += after;
854
+ text += after + lifted;
757
855
  if (d.deviceSuffix !== undefined) {
758
856
  if (d.typeCode === undefined)
759
857
  refuse('a Mic-E device suffix needs a type code');