@packet-net/pdn-aprs 0.1.1 → 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 (81) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +9 -5
  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/bytes.d.ts.map +1 -1
  8. package/dist/bytes.js +2 -1
  9. package/dist/bytes.js.map +1 -1
  10. package/dist/codes.js +8 -8
  11. package/dist/codes.js.map +1 -1
  12. package/dist/decode/index.d.ts.map +1 -1
  13. package/dist/decode/index.js +8 -5
  14. package/dist/decode/index.js.map +1 -1
  15. package/dist/decode/message.d.ts +2 -0
  16. package/dist/decode/message.d.ts.map +1 -1
  17. package/dist/decode/message.js +59 -33
  18. package/dist/decode/message.js.map +1 -1
  19. package/dist/decode/other.d.ts +20 -2
  20. package/dist/decode/other.d.ts.map +1 -1
  21. package/dist/decode/other.js +110 -48
  22. package/dist/decode/other.js.map +1 -1
  23. package/dist/decode/positioned.d.ts +26 -2
  24. package/dist/decode/positioned.d.ts.map +1 -1
  25. package/dist/decode/positioned.js +145 -79
  26. package/dist/decode/positioned.js.map +1 -1
  27. package/dist/decode/reports.d.ts.map +1 -1
  28. package/dist/decode/reports.js +47 -5
  29. package/dist/decode/reports.js.map +1 -1
  30. package/dist/decoder.d.ts.map +1 -1
  31. package/dist/decoder.js +2 -4
  32. package/dist/decoder.js.map +1 -1
  33. package/dist/deviceid.d.ts +8 -2
  34. package/dist/deviceid.d.ts.map +1 -1
  35. package/dist/deviceid.js +28 -6
  36. package/dist/deviceid.js.map +1 -1
  37. package/dist/encode/common.d.ts +13 -2
  38. package/dist/encode/common.d.ts.map +1 -1
  39. package/dist/encode/common.js +36 -4
  40. package/dist/encode/common.js.map +1 -1
  41. package/dist/encode/index.d.ts.map +1 -1
  42. package/dist/encode/index.js +59 -22
  43. package/dist/encode/index.js.map +1 -1
  44. package/dist/encode/other.d.ts.map +1 -1
  45. package/dist/encode/other.js +45 -17
  46. package/dist/encode/other.js.map +1 -1
  47. package/dist/encode/position.d.ts +9 -3
  48. package/dist/encode/position.d.ts.map +1 -1
  49. package/dist/encode/position.js +258 -124
  50. package/dist/encode/position.js.map +1 -1
  51. package/dist/header.d.ts +5 -2
  52. package/dist/header.d.ts.map +1 -1
  53. package/dist/header.js +8 -3
  54. package/dist/header.js.map +1 -1
  55. package/dist/index.d.ts +1 -1
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/neutral.d.ts.map +1 -1
  59. package/dist/neutral.js +22 -3
  60. package/dist/neutral.js.map +1 -1
  61. package/dist/types.d.ts +19 -9
  62. package/dist/types.d.ts.map +1 -1
  63. package/package.json +1 -1
  64. package/src/builder.ts +77 -14
  65. package/src/bytes.ts +2 -1
  66. package/src/codes.ts +8 -8
  67. package/src/decode/index.ts +7 -4
  68. package/src/decode/message.ts +54 -37
  69. package/src/decode/other.ts +116 -44
  70. package/src/decode/positioned.ts +159 -90
  71. package/src/decode/reports.ts +47 -4
  72. package/src/decoder.ts +2 -5
  73. package/src/deviceid.ts +29 -5
  74. package/src/encode/common.ts +34 -4
  75. package/src/encode/index.ts +57 -20
  76. package/src/encode/other.ts +39 -13
  77. package/src/encode/position.ts +261 -119
  78. package/src/header.ts +9 -2
  79. package/src/index.ts +1 -0
  80. package/src/neutral.ts +24 -3
  81. package/src/types.ts +20 -5
@@ -1,6 +1,7 @@
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
 
5
6
  import { AREA_COLORS, AREA_SHAPES, KNOTS_TO_MPH, METRES_PER_FOOT, MICROWAVE_BASE } from '../decode/positioned.js';
6
7
  import type {
@@ -17,9 +18,10 @@ import type {
17
18
  Weather,
18
19
  WeatherReport,
19
20
  } from '../types.js';
20
- import { base91, checkSymbol, freeText, isValidTimestamp, pad, refuse, timestamp7, whole } from './common.js';
21
+ import { base91, checkSymbol, freeText, isValidTimestamp, nearest, pad, refuse, timestamp7, whole } from './common.js';
21
22
 
22
23
  const EPS = 1e-9;
24
+ const CUT_TOLERANCE = 1e-6;
23
25
 
24
26
  /** Negative, or negative zero: a zero coordinate keeps its hemisphere as the sign of zero. */
25
27
  export function isNegative(v: number): boolean {
@@ -32,23 +34,62 @@ interface Digits {
32
34
  degrees: number;
33
35
  /** Hundredths of a minute. */
34
36
  hundredths: number;
35
- /** The remainder below a hundredth, in minutes (for a !DAO!). */
36
- remainder: number;
37
+ /** The `!DAO!` digit below the hundredths: 0-9 in thousandths, 0-90 in ninety-firsts. */
38
+ extra: number;
37
39
  }
38
40
 
39
- /** Degrees and hundredths of a minute; truncated when a !DAO! or ambiguity follows, else rounded. */
40
- function digitsOf(value: number, truncate: boolean): Digits {
41
+ /**
42
+ * How a coordinate's digits are found: rounded to the nearest hundredth of a minute, truncated to
43
+ * one (ambiguity, which reports the centre of the box the digits left give), or rounded to the
44
+ * nearest step of a `!DAO!` of that precision (thousandths, or ninety-firsts of a hundredth, vectors
45
+ * interpretations.md), whose last digit is the `!DAO!` one.
46
+ */
47
+ type DigitsMode = 'round' | 'truncate' | 'thousandths' | 'base91';
48
+
49
+ /** Degrees and hundredths of a minute, and the `!DAO!` digit below them. */
50
+ function digitsOf(value: number, mode: DigitsMode, ambiguity = 0): Digits {
41
51
  const abs = Math.abs(value);
42
- let degrees = Math.floor(abs + EPS);
43
- let minutes = (abs - degrees) * 60;
44
- if (minutes < 0) minutes = 0;
45
- let hundredths = truncate ? Math.floor(minutes * 100 + EPS) : Math.round(minutes * 100);
52
+ // The ambiguity cut takes F2's tolerance: a value within 1e-6 of the box edge above it, in the
53
+ // box's units, is on that edge (vectors rulings, G2).
54
+ let degrees = Math.floor(abs + (mode === 'truncate' && ambiguity >= 4 ? CUT_TOLERANCE : EPS));
55
+ const minutes = Math.max(0, (abs - degrees) * 60);
56
+ let hundredths: number;
57
+ let extra = 0;
58
+ switch (mode) {
59
+ case 'round':
60
+ hundredths = nearest(minutes * 100);
61
+ break;
62
+ case 'truncate': {
63
+ // Hundredths of a minute in each box: a tenth, a minute, ten minutes, or a whole degree.
64
+ const box = ambiguity >= 4 ? 6000 : ambiguity === 3 ? 1000 : ambiguity === 2 ? 100 : ambiguity === 1 ? 10 : 1;
65
+ hundredths = ambiguity >= 4 ? 0 : Math.floor((minutes * 100) / box + (ambiguity > 0 ? CUT_TOLERANCE : EPS)) * box;
66
+ break;
67
+ }
68
+ case 'thousandths': {
69
+ const steps = nearest(minutes * 1000);
70
+ hundredths = Math.floor(steps / 10);
71
+ extra = steps % 10;
72
+ break;
73
+ }
74
+ case 'base91': {
75
+ const steps = nearest(minutes * 9100);
76
+ hundredths = Math.floor(steps / 91);
77
+ extra = steps % 91;
78
+ break;
79
+ }
80
+ }
46
81
  if (hundredths >= 6000) {
47
82
  degrees += 1;
48
83
  hundredths -= 6000;
49
84
  }
50
- const remainder = Math.max(0, minutes - hundredths / 100);
51
- return { degrees, hundredths, remainder };
85
+ return { degrees, hundredths, extra };
86
+ }
87
+
88
+ /** How a position's digits are found, given its ambiguity and `!DAO!`. */
89
+ function digitsMode(f: PositionedFields): DigitsMode {
90
+ if ((f.ambiguity ?? 0) > 0) return 'truncate';
91
+ const precision = f.dao?.precision;
92
+ return precision === 'thousandths' || precision === 'base91' ? precision : 'round';
52
93
  }
53
94
 
54
95
  function blank(text: string, positions: readonly number[], ambiguity: number): string {
@@ -57,18 +98,16 @@ function blank(text: string, positions: readonly number[], ambiguity: number): s
57
98
  return chars.join('');
58
99
  }
59
100
 
60
- /** `ddmm.hhN/dddmm.hhW$`, with the DAO remainders. */
61
- function uncompressedPosition(
62
- f: PositionedFields,
63
- truncate: boolean,
64
- ): { text: string; latRemainder: number; lonRemainder: number } {
101
+ /** `ddmm.hhN/dddmm.hhW$`, with the `!DAO!` digits. */
102
+ function uncompressedPosition(f: PositionedFields): { text: string; latExtra: number; lonExtra: number } {
65
103
  if (!(f.latitude >= -90 && f.latitude <= 90)) refuse(`latitude ${f.latitude} is out of range`);
66
104
  if (!(f.longitude >= -180 && f.longitude <= 180)) refuse(`longitude ${f.longitude} is out of range`);
67
105
  const ambiguity = f.ambiguity ?? 0;
68
106
  if (!Number.isInteger(ambiguity) || ambiguity < 0 || ambiguity > 4) refuse('ambiguity is 0-4 digits');
69
107
  checkSymbol(f.symbol);
70
- const lat = digitsOf(f.latitude, truncate || ambiguity > 0);
71
- const lon = digitsOf(f.longitude, truncate || ambiguity > 0);
108
+ const mode = digitsMode(f);
109
+ const lat = digitsOf(f.latitude, mode, ambiguity);
110
+ const lon = digitsOf(f.longitude, mode, ambiguity);
72
111
  if (lat.degrees > 90 || (lat.degrees === 90 && lat.hundredths > 0)) refuse('latitude is out of range');
73
112
  if (lon.degrees > 180 || (lon.degrees === 180 && lon.hundredths > 0)) refuse('longitude is out of range');
74
113
  const latMin = pad(Math.floor(lat.hundredths / 100), 2) + '.' + pad(lat.hundredths % 100, 2);
@@ -77,8 +116,8 @@ function uncompressedPosition(
77
116
  const lonText = blank(pad(lon.degrees, 3) + lonMin, [3, 4, 6, 7], ambiguity) + (isNegative(f.longitude) ? 'W' : 'E');
78
117
  return {
79
118
  text: latText + f.symbol.table + lonText + f.symbol.code,
80
- latRemainder: lat.remainder,
81
- lonRemainder: lon.remainder,
119
+ latExtra: lat.extra,
120
+ lonExtra: lon.extra,
82
121
  };
83
122
  }
84
123
 
@@ -94,8 +133,16 @@ function typeByte(c: CompressionType): string {
94
133
  return String.fromCharCode(((c.fix === 'current' ? 1 : 0) << 5) + (source << 3) + origin + 33);
95
134
  }
96
135
 
97
- /** The compressed position; `cs` is the two cs bytes or `undefined` for none. */
98
- function compressedPosition(f: PositionedFields, cs: string | undefined, compression: CompressionType | undefined): string {
136
+ /**
137
+ * The compressed position; `cs` is the two cs bytes or `undefined` for none. Also gives the
138
+ * `!DAO!` digits for it: a decoder does not apply them, so they are the ones a `!DAO!` of that
139
+ * precision gives the position the bytes report, written uncompressed (vectors rulings, E2).
140
+ */
141
+ function compressedPosition(
142
+ f: PositionedFields,
143
+ cs: string | undefined,
144
+ compression: CompressionType | undefined,
145
+ ): { text: string; latExtra: number; lonExtra: number } {
99
146
  if (!(f.latitude >= -90 && f.latitude <= 90)) refuse(`latitude ${f.latitude} is out of range`);
100
147
  if (!(f.longitude >= -180 && f.longitude <= 180)) refuse(`longitude ${f.longitude} is out of range`);
101
148
  if ((f.ambiguity ?? 0) !== 0) refuse('a compressed position cannot be ambiguous');
@@ -103,10 +150,60 @@ function compressedPosition(f: PositionedFields, cs: string | undefined, compres
103
150
  let table = f.symbol.table;
104
151
  if (table >= '0' && table <= '9') table = String.fromCharCode(table.charCodeAt(0) + 49); // 0-9 -> a-j
105
152
  const max = 91 ** 4 - 1;
106
- const y = Math.min(max, Math.max(0, Math.round(380926 * (90 - f.latitude))));
107
- const x = Math.min(max, Math.max(0, Math.round(190463 * (180 + f.longitude))));
153
+ const y = Math.min(max, Math.max(0, nearest(380926 * (90 - f.latitude))));
154
+ const x = Math.min(max, Math.max(0, nearest(190463 * (180 + f.longitude))));
108
155
  const tail = cs === undefined ? ' sT' : cs + typeByte(compression ?? DEFAULT_COMPRESSION);
109
- return table + base91(y, 4) + base91(x, 4) + f.symbol.code + tail;
156
+ const mode = digitsMode(f);
157
+ return {
158
+ text: table + base91(y, 4) + base91(x, 4) + f.symbol.code + tail,
159
+ latExtra: digitsOf(90 - y / 380926, mode).extra,
160
+ lonExtra: digitsOf(x / 190463 - 180, mode).extra,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * A GGA altitude in the cs bytes: 1.002^cs feet, so the nearest value they hold (1 foot for 0 feet
166
+ * or below), and whether that is exact. When it is not, a `/A=` altitude carries it and wins.
167
+ */
168
+ function ggaAltitude(feet: number): { cs: string; exact: boolean } {
169
+ if (!Number.isFinite(feet)) refuse('altitude is not a number');
170
+ const v = feet <= 1 ? 0 : nearest(Math.log(feet) / Math.log(1.002));
171
+ if (v > 91 * 91 - 1) refuse('altitude is too high for the compressed format');
172
+ const cs = String.fromCharCode(Math.floor(v / 91) + 33) + String.fromCharCode((v % 91) + 33);
173
+ return { cs, exact: Math.abs(Math.pow(1.002, v) - feet) <= 1e-9 * Math.abs(feet) };
174
+ }
175
+
176
+ /**
177
+ * A course or wind direction in the cs bytes: 4 degree steps, rounded to the nearest (halves away
178
+ * from zero), and one that rounds to 360 is c = 0, north (vectors rulings, E9).
179
+ */
180
+ function compressedDirection(degrees: number): string {
181
+ return String.fromCharCode((nearest(degrees / 4) % 90) + 33);
182
+ }
183
+
184
+ /**
185
+ * A speed in knots in the cs bytes: 1.08^s - 1, the nearest s for any whole s, refused when that
186
+ * s is outside the byte (0-90; 90 is `{`), so -0.03 knots is 0 and -0.3 is refused (vectors
187
+ * rulings, G3).
188
+ */
189
+ function compressedSpeed(knots: number, what: string): string {
190
+ if (!(knots > -1)) refuse(`${what} has no compressed form`);
191
+ const s = nearest(Math.log(knots + 1) / Math.log(1.08));
192
+ if (s < 0) refuse(`${what} is negative`);
193
+ if (s > 90) refuse(`${what} is too high for the compressed format`);
194
+ return String.fromCharCode(s + 33);
195
+ }
196
+
197
+ /**
198
+ * A range in the cs bytes: `{` and 2 x 1.08^s miles, the nearest s for any whole s, refused when
199
+ * that s is below 0, so 1.95 miles is 2 and 1.9 is refused (vectors rulings, G3).
200
+ */
201
+ function compressedRange(miles: number): string {
202
+ if (!(miles > 0)) refuse('a compressed range is at least 2 miles');
203
+ const s = nearest(Math.log(miles / 2) / Math.log(1.08));
204
+ if (s < 0) refuse('a compressed range is at least 2 miles');
205
+ if (s > 90) refuse('range is out of range for the compressed format');
206
+ return '{' + String.fromCharCode(s + 33);
110
207
  }
111
208
 
112
209
  // ---- data extensions and comment elements
@@ -126,8 +223,10 @@ function rateChar(r: number): string {
126
223
  return v <= 9 ? String(v) : String.fromCharCode(55 + v);
127
224
  }
128
225
 
129
- function altitudeText(feet: number): string {
130
- if (!Number.isInteger(feet)) refuse('an /A= altitude is whole feet');
226
+ function altitudeText(given: number): string {
227
+ if (!Number.isFinite(given)) refuse('altitude is not a number');
228
+ // Whole feet, the nearest (vectors rulings, F2).
229
+ const feet = nearest(given);
131
230
  if (feet < 0) {
132
231
  if (feet < -99999) refuse('altitude is out of range');
133
232
  return `/A=-${pad(-feet, 5)}`;
@@ -142,7 +241,7 @@ export function frequencyText(f: VoiceFrequency): string {
142
241
  let whole3: string;
143
242
  let fraction: string;
144
243
  const resolution = f.tenKhzResolution ? 100 : 1000;
145
- const scaled = Math.round(mhz * resolution);
244
+ const scaled = nearest(mhz * resolution);
146
245
  const intPart = Math.floor(scaled / resolution);
147
246
  const fracPart = scaled % resolution;
148
247
  fraction = pad(fracPart, f.tenKhzResolution ? 2 : 3);
@@ -191,29 +290,33 @@ export function telemetryText(t: CommentTelemetry): string {
191
290
  const v = (n: number, what: string): string => base91(whole(n, 0, 8280, what), 2);
192
291
  let out = '|' + v(t.sequence, 'telemetry sequence');
193
292
  for (const a of t.analog) out += v(a, 'telemetry value');
194
- if (t.digital !== undefined) out += v(t.digital, 'telemetry bits');
293
+ // Eight binary channels; bits 9-13 are reserved.
294
+ if (t.digital !== undefined) out += base91(whole(t.digital, 0, 255, 'telemetry bits'), 2);
195
295
  return out + '|';
196
296
  }
197
297
 
198
- function daoText(f: PositionedFields, latRemainder: number, lonRemainder: number, applied: boolean): string {
298
+ /**
299
+ * The `!DAO!`: its datum, and digits `latExtra` and `lonExtra` (from `digitsOf`), or zeros when
300
+ * `applied` is false (an ambiguous position, whose digits say nothing finer).
301
+ */
302
+ function daoText(f: PositionedFields, latExtra: number, lonExtra: number, applied: boolean): string {
199
303
  const dao = f.dao!;
200
304
  const datum = dao.datum;
201
- if (!/^[A-Za-z]$/.test(datum)) refuse('the !DAO! datum is a letter');
305
+ if (/^[0-9]$/.test(datum)) {
306
+ // A local datum digit has no case to give a precision, so it only goes with spaces.
307
+ if (dao.precision !== 'none') refuse('a !DAO! datum digit carries no added precision');
308
+ return `!${datum} !`;
309
+ }
310
+ if (!/^[A-Za-z]$/.test(datum)) refuse('the !DAO! datum is a letter or a digit');
202
311
  switch (dao.precision) {
203
312
  case 'none':
204
313
  return `!${datum.toUpperCase()} !`;
205
- case 'thousandths': {
314
+ case 'thousandths':
206
315
  if (!applied) return `!${datum.toUpperCase()}00!`;
207
- const a = Math.min(9, Math.round(latRemainder * 1000));
208
- const o = Math.min(9, Math.round(lonRemainder * 1000));
209
- return `!${datum.toUpperCase()}${a}${o}!`;
210
- }
211
- case 'base91': {
316
+ return `!${datum.toUpperCase()}${latExtra}${lonExtra}!`;
317
+ case 'base91':
212
318
  if (!applied) return `!${datum.toLowerCase()}!!!`;
213
- const a = Math.min(90, Math.round(latRemainder * 100 * 91));
214
- const o = Math.min(90, Math.round(lonRemainder * 100 * 91));
215
- return `!${datum.toLowerCase()}${String.fromCharCode(a + 33)}${String.fromCharCode(o + 33)}!`;
216
- }
319
+ return `!${datum.toLowerCase()}${String.fromCharCode(latExtra + 33)}${String.fromCharCode(lonExtra + 33)}!`;
217
320
  }
218
321
  }
219
322
 
@@ -226,6 +329,22 @@ function weatherValue(v: number | undefined, width: number, min: number, max: nu
226
329
  return pad(n, width);
227
330
  }
228
331
 
332
+ /**
333
+ * Snowfall in its three characters (vectors rulings, F3): rounded to hundredths if that leaves it
334
+ * under 1, else to tenths if that leaves it under 10, else to a whole number; then a whole number
335
+ * is three digits (`002`, 0.995 as `001`), one under 1 is `.` and two digits (`.50`), any other a
336
+ * digit, `.` and a digit (`2.5`). One that rounds past 999 is refused.
337
+ */
338
+ function snowfallText(inches: number): string {
339
+ if (!Number.isFinite(inches)) refuse('snowfall is not a number of inches');
340
+ const hundredths = nearest(inches * 100);
341
+ if (hundredths < 0) refuse('snowfall is negative');
342
+ if (hundredths < 100) return hundredths % 100 === 0 ? '000' : '.' + pad(hundredths, 2);
343
+ const tenths = nearest(inches * 10);
344
+ if (tenths < 100) return tenths % 10 === 0 ? pad(tenths / 10, 3) : `${Math.floor(tenths / 10)}.${tenths % 10}`;
345
+ return pad(whole(inches, 0, 999, 'snowfall'), 3);
346
+ }
347
+
229
348
  /** Weather fields after the wind: gust, temperature, rain, humidity, pressure, and on. */
230
349
  function weatherFields(w: Weather, mandatory: boolean): string {
231
350
  let out = '';
@@ -243,12 +362,7 @@ function weatherFields(w: Weather, mandatory: boolean): string {
243
362
  const l = whole(w.luminosityWM2, 0, 1999, 'luminosity');
244
363
  out += l < 1000 ? 'L' + pad(l, 3) : 'l' + pad(l - 1000, 3);
245
364
  }
246
- if (w.snow24hIn !== undefined) {
247
- const s = w.snow24hIn;
248
- let text = Number.isInteger(s) ? pad(whole(s, 0, 999, 'snowfall'), 3) : String(s);
249
- if (text.length !== 3 || !/^[0-9.]{3}$/.test(text)) text = refuse('snowfall is 3 characters');
250
- out += 's' + text;
251
- }
365
+ if (w.snow24hIn !== undefined) out += 's' + snowfallText(w.snow24hIn);
252
366
  if (w.rainRaw !== undefined) out += '#' + pad(whole(w.rainRaw, 0, 999, 'raw rain counter'), 3);
253
367
  for (const e of w.extra ?? []) {
254
368
  if (!/^[A-Za-z]$/.test(e.letter) || 'csgtrpPhbLl'.includes(e.letter)) refuse(`${e.letter} is not an extra weather field letter`);
@@ -295,10 +409,11 @@ function hasExtension(f: PositionedFields): string[] {
295
409
 
296
410
  /**
297
411
  * How the comment is joined on: `plain` (after a space when it follows a frequency), `joined`
298
- * (straight after a frequency, with no space) or `delimited` (after a `/`), for when it would
299
- * otherwise read as something else.
412
+ * (straight after a frequency, with no space), `delimited` (after a `/`) or `spaced` (after a
413
+ * space), for when it would otherwise read as something else: a space keeps a Mic-E comment from
414
+ * joining a PHG's digits into an altitude, where a `/` would not (vectors README, Encoding).
300
415
  */
301
- export type CommentMode = 'plain' | 'joined' | 'delimited';
416
+ export type CommentMode = 'plain' | 'joined' | 'delimited' | 'spaced';
302
417
 
303
418
  /** Encodes a position and what follows it. */
304
419
  function positionedBody(f: PositionedFields, mode: CommentMode): Body {
@@ -307,7 +422,8 @@ function positionedBody(f: PositionedFields, mode: CommentMode): Body {
307
422
  if (isWeather && !f.weather) refuse('the weather station symbol (_) needs weather data');
308
423
  if (isWeather) return weatherBody(f);
309
424
  const exts = hasExtension(f);
310
- const hasCourse = f.courseDegrees !== undefined || f.speedKnots !== undefined;
425
+ // DF and storm data extend a course/speed, so they count as that extension (vectors rulings, F7).
426
+ const hasCourse = f.courseDegrees !== undefined || f.speedKnots !== undefined || (!f.compressed && (f.dfBearing !== undefined || f.storm !== undefined));
311
427
  if (exts.length + (hasCourse ? 1 : 0) > 1) refuse(`only one data extension can follow the symbol (${[hasCourse ? 'course/speed' : '', ...exts].filter(Boolean).join(', ')})`);
312
428
  if (f.dfBearing && !(f.symbol.table === '/' && f.symbol.code === '\\')) refuse('a DF bearing needs the DF symbol (/\\)');
313
429
  if (f.storm && !(f.symbol.code === '@' && (f.symbol.table === '/' || f.symbol.table === '\\'))) refuse('storm data needs a hurricane symbol (/@ or \\@)');
@@ -317,56 +433,54 @@ function positionedBody(f: PositionedFields, mode: CommentMode): Body {
317
433
  let out = '';
318
434
  let compression: CompressionType | undefined;
319
435
  let altitudeInCs = false;
320
- let latRem = 0;
321
- let lonRem = 0;
436
+ let latExtra = 0;
437
+ let lonExtra = 0;
322
438
  let daoApplied = false;
323
439
  if (f.compressed) {
324
440
  if (f.phg || f.dfs || f.area || f.dfBearing || f.storm) refuse('a compressed report carries only course/speed, range or altitude');
325
441
  let cs: string | undefined;
326
442
  compression = f.compression;
327
443
  if (hasCourse) {
444
+ // The cs bytes hold both, so one alone would add a value the data does not have; a course of
445
+ // 0 (after rounding) would read back as 360 (vectors rulings, F1, F5).
328
446
  if (f.courseDegrees === undefined) refuse('a compressed course/speed needs a course');
447
+ if (f.speedKnots === undefined) refuse('a compressed course/speed needs a speed');
329
448
  if (compression?.source === 'gga') refuse('a GGA compression type means the cs bytes hold altitude');
330
- const c = whole(f.courseDegrees, 1, 360, 'course') % 360;
331
- if (c % 4 !== 0 && Math.abs(Math.round(c / 4) * 4 - f.courseDegrees) > 2) refuse('course is out of range');
332
- const speed = f.speedKnots ?? 0;
333
- if (speed < 0) refuse('speed is negative');
334
- const s = Math.round(Math.log(speed + 1) / Math.log(1.08));
335
- if (s > 90) refuse('speed is too high for the compressed format');
336
- cs = String.fromCharCode(Math.round(c / 4) % 90 + 33) + String.fromCharCode(s + 33);
449
+ whole(f.courseDegrees, 1, 360, 'course');
450
+ cs = compressedDirection(f.courseDegrees) + compressedSpeed(f.speedKnots, 'speed');
337
451
  compression = compression ?? DEFAULT_COMPRESSION;
338
452
  } else if (f.rangeMiles !== undefined) {
339
453
  if (compression?.source === 'gga') refuse('a GGA compression type means the cs bytes hold altitude');
340
- const s = Math.round(Math.log(f.rangeMiles / 2) / Math.log(1.08));
341
- if (!(s >= 0 && s <= 90)) refuse('range is out of range for the compressed format');
342
- cs = '{' + String.fromCharCode(s + 33);
454
+ cs = compressedRange(f.rangeMiles);
343
455
  compression = compression ?? DEFAULT_COMPRESSION;
344
456
  } else if (f.altitudeFeet !== undefined && compression?.source === 'gga') {
345
- if (f.altitudeFeet < 1) refuse('a compressed altitude is at least 1 foot');
346
- const v = Math.round(Math.log(f.altitudeFeet) / Math.log(1.002));
347
- if (v > 91 * 91 - 1) refuse('altitude is too high for the compressed format');
348
- cs = String.fromCharCode(Math.floor(v / 91) + 33) + String.fromCharCode((v % 91) + 33);
349
- altitudeInCs = Math.abs(Math.pow(1.002, v) - f.altitudeFeet) <= 1e-9 * f.altitudeFeet;
457
+ const alt = ggaAltitude(f.altitudeFeet);
458
+ cs = alt.cs;
459
+ altitudeInCs = alt.exact;
350
460
  } else if (compression !== undefined) {
351
461
  refuse('a compression type needs cs data (course/speed, range or altitude)');
352
462
  }
353
- out += compressedPosition(f, cs, compression);
463
+ const pos = compressedPosition(f, cs, compression);
464
+ out += pos.text;
465
+ latExtra = pos.latExtra;
466
+ lonExtra = pos.lonExtra;
467
+ daoApplied = true;
354
468
  } else {
355
469
  if (f.compression) refuse('an uncompressed position has no compression type');
356
- const pos = uncompressedPosition(f, f.dao !== undefined);
470
+ const pos = uncompressedPosition(f);
357
471
  out += pos.text;
358
- latRem = pos.latRemainder;
359
- lonRem = pos.lonRemainder;
472
+ latExtra = pos.latExtra;
473
+ lonExtra = pos.lonExtra;
360
474
  daoApplied = (f.ambiguity ?? 0) === 0;
361
475
  if (hasCourse) {
476
+ // DF or storm data with no course and speed follows `.../...`, which reads back as unknown
477
+ // (vectors rulings, F8).
362
478
  out += courseSpeed(f.courseDegrees, f.speedKnots);
363
479
  if (f.dfBearing) {
364
480
  const b = f.dfBearing;
365
481
  out += `/${pad(whole(b.bearingDegrees, 0, 360, 'bearing'), 3)}/${whole(b.number, 0, 9, 'N')}${whole(b.range, 0, 9, 'R')}${whole(b.quality, 0, 9, 'Q')}`;
366
482
  }
367
483
  if (f.storm) out += stormText(f.storm);
368
- } else if (f.dfBearing || f.storm) {
369
- refuse('DF and storm data follow a course/speed extension');
370
484
  } else if (f.phg) {
371
485
  const p = f.phg;
372
486
  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')}`;
@@ -385,29 +499,35 @@ function positionedBody(f: PositionedFields, mode: CommentMode): Body {
385
499
  out += `${shape}${pad(whole(a.latOffset, 0, 99, 'area offset'), 2)}${c}${pad(whole(a.lonOffset, 0, 99, 'area offset'), 2)}`;
386
500
  }
387
501
  }
388
- // Altitude, unless the compressed cs bytes carry it exactly.
389
- if (f.altitudeFeet !== undefined && !altitudeInCs) out += altitudeText(f.altitudeFeet);
390
- const extensionEnded = !f.compressed && (hasCourse || exts.length > 0) && f.altitudeFeet === undefined;
502
+ // After the data extension: the frequency with its fields, first, where radios read it (APRS12c
503
+ // ch. 18); the signpost or corridor braces; the altitude, unless the compressed cs bytes carry it
504
+ // exactly; the free text; base-91 telemetry; the !DAO! (vectors rulings, E3).
391
505
  let text = '';
392
- // A frequency straight after a 7-byte extension is separated from it by / (a PHGR already ends in one).
393
- if (f.frequency) text += (extensionEnded && !out.endsWith('/') ? '/' : '') + frequencyText(f.frequency);
506
+ // A frequency straight after a 7-byte extension follows a / (a PHGR already ends in one); after a
507
+ // compressed position, or a position with no extension, it follows straight on.
508
+ const extended = !f.compressed && (hasCourse || exts.length > 0);
509
+ if (f.frequency) text += (extended && !out.endsWith('/') ? '/' : '') + frequencyText(f.frequency);
394
510
  let braces = '';
395
511
  if (f.signpost !== undefined) {
396
- if (!/^[\x21-\x7c\x7e]{1,3}$/.test(f.signpost) || f.signpost.includes('{') || f.signpost.includes('}')) refuse('a signpost is 1-3 printable characters');
512
+ // Printable ASCII, a space included, other than the braces around it.
513
+ if (!/^[\x20-\x7a\x7c\x7e]{1,3}$/.test(f.signpost)) refuse('a signpost is 1-3 printable ASCII characters other than { and }');
397
514
  braces = `{${f.signpost}}`;
398
515
  }
399
516
  if (f.area?.corridorWidthMiles !== undefined) {
400
517
  if (f.area.shape !== 'line-down-right' && f.area.shape !== 'line-down-left') refuse('only a line has a corridor');
401
518
  braces = `{${whole(f.area.corridorWidthMiles, 0, 999, 'corridor width')}}`;
402
519
  }
520
+ text += braces;
521
+ if (f.altitudeFeet !== undefined && !altitudeInCs) text += altitudeText(f.altitudeFeet);
403
522
  const comment = f.comment ?? '';
404
523
  if (comment.length > 0 || mode === 'delimited') {
405
- if (text.length > 0 && mode !== 'joined') text += ' ';
524
+ // After a frequency (and anything after it) the free text follows a space, unless that would
525
+ // make it read as one of the frequency's fields.
526
+ if ((f.frequency && mode !== 'joined') || mode === 'spaced') text += ' ';
406
527
  text += (mode === 'delimited' ? '/' : '') + freeText(comment, 'the comment');
407
528
  }
408
- text += braces;
409
529
  if (f.telemetry) text += telemetryText(f.telemetry);
410
- if (f.dao) text += daoText(f, latRem, lonRem, daoApplied);
530
+ if (f.dao) text += daoText(f, latExtra, lonExtra, daoApplied);
411
531
  const body: Body = { text: out + text };
412
532
  if (compression) body.compression = compression;
413
533
  return body;
@@ -415,7 +535,9 @@ function positionedBody(f: PositionedFields, mode: CommentMode): Body {
415
535
 
416
536
  function stormText(s: NonNullable<PositionedFields['storm']>): string {
417
537
  const type = s.type === 'tropical-storm' ? 'TS' : s.type === 'hurricane' ? 'HC' : s.type === 'tropical-depression' ? 'TD' : refuse('unknown storm type');
418
- const v = (n: number | undefined, width: number, what: string): string => (n === undefined ? ' '.repeat(width) : pad(whole(n, 0, 10 ** width - 1, what), width));
538
+ // Each field is digits: one with no value has no form (vectors README, storm data).
539
+ const v = (n: number | undefined, width: number, what: string): string =>
540
+ n === undefined ? refuse(`storm data needs its ${what}`) : pad(whole(n, 0, 10 ** width - 1, what), width);
419
541
  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')}`;
420
542
  if (s.wholeGaleRadiusNm !== undefined) out += `%${v(s.wholeGaleRadiusNm, 3, 'radius')}`;
421
543
  return out;
@@ -431,8 +553,8 @@ function weatherBody(f: PositionedFields): Body {
431
553
  if (f.telemetry) refuse('comment telemetry goes in a comment, which a weather report does not have');
432
554
  let out = '';
433
555
  let compression: CompressionType | undefined;
434
- let latRem = 0;
435
- let lonRem = 0;
556
+ let latExtra = 0;
557
+ let lonExtra = 0;
436
558
  let daoApplied = false;
437
559
  let altitudeInCs = false;
438
560
  if (f.compressed) {
@@ -440,42 +562,41 @@ function weatherBody(f: PositionedFields): Body {
440
562
  let cs: string | undefined;
441
563
  if (w.windDirectionDegrees !== undefined || w.windSpeedMph !== undefined) {
442
564
  if (w.windDirectionDegrees === undefined || w.windSpeedMph === undefined) refuse('compressed wind needs both direction and speed');
443
- const dir = whole(w.windDirectionDegrees, 0, 360, 'wind direction');
444
- const knots = w.windSpeedMph / KNOTS_TO_MPH;
445
- const s = Math.round(Math.log(knots + 1) / Math.log(1.08));
446
- if (s < 0 || s > 90) refuse('wind speed is out of range for the compressed format');
447
- cs = String.fromCharCode((Math.round(dir / 4) % 90) + 33) + String.fromCharCode(s + 33);
565
+ whole(w.windDirectionDegrees, 0, 360, 'wind direction');
566
+ cs = compressedDirection(w.windDirectionDegrees) + compressedSpeed(w.windSpeedMph / KNOTS_TO_MPH, 'wind speed');
448
567
  compression = compression ?? DEFAULT_COMPRESSION;
449
568
  if (compression.source === 'gga') refuse('a GGA compression type means the cs bytes hold altitude');
450
569
  } else if (f.altitudeFeet !== undefined && compression?.source === 'gga') {
451
- const v = Math.round(Math.log(f.altitudeFeet) / Math.log(1.002));
452
- cs = String.fromCharCode(Math.floor(v / 91) + 33) + String.fromCharCode((v % 91) + 33);
453
- altitudeInCs = Math.abs(Math.pow(1.002, v) - f.altitudeFeet) <= 1e-9 * f.altitudeFeet;
570
+ // With no comment to hold a /A=, the nearest value the cs bytes hold (vectors rulings, F14).
571
+ cs = ggaAltitude(f.altitudeFeet).cs;
572
+ altitudeInCs = true;
454
573
  } else if (f.rangeMiles !== undefined) {
455
574
  if (compression?.source === 'gga') refuse('a GGA compression type means the cs bytes hold altitude');
456
- const s = Math.round(Math.log(f.rangeMiles / 2) / Math.log(1.08));
457
- if (!(s >= 0 && s <= 90)) refuse('range is out of range for the compressed format');
458
- cs = '{' + String.fromCharCode(s + 33);
575
+ cs = compressedRange(f.rangeMiles);
459
576
  compression = compression ?? DEFAULT_COMPRESSION;
460
577
  } else if (compression !== undefined) {
461
578
  refuse('a compression type needs cs data');
462
579
  }
463
- out += compressedPosition(f, cs, compression);
580
+ const pos = compressedPosition(f, cs, compression);
581
+ out += pos.text;
582
+ latExtra = pos.latExtra;
583
+ lonExtra = pos.lonExtra;
584
+ daoApplied = true;
464
585
  if (f.altitudeFeet !== undefined && !altitudeInCs) refuse('a weather report has no altitude in its comment');
465
586
  out += weatherFields(w, true);
466
587
  } else {
467
588
  if (f.compression) refuse('an uncompressed position has no compression type');
468
589
  if (f.altitudeFeet !== undefined) refuse('a weather report has no altitude in its comment');
469
- const pos = uncompressedPosition(f, f.dao !== undefined);
590
+ const pos = uncompressedPosition(f);
470
591
  out += pos.text;
471
- latRem = pos.latRemainder;
472
- lonRem = pos.lonRemainder;
592
+ latExtra = pos.latExtra;
593
+ lonExtra = pos.lonExtra;
473
594
  daoApplied = (f.ambiguity ?? 0) === 0;
474
595
  out += courseSpeed(w.windDirectionDegrees, w.windSpeedMph, 'wind direction');
475
596
  out += weatherFields(w, true);
476
597
  }
477
598
  if (f.telemetry) out += telemetryText(f.telemetry);
478
- if (f.dao) out += daoText(f, latRem, lonRem, daoApplied);
599
+ if (f.dao) out += daoText(f, latExtra, lonExtra, daoApplied);
479
600
  const body: Body = { text: out };
480
601
  if (compression) body.compression = compression;
481
602
  return body;
@@ -556,8 +677,14 @@ function micEDestination(d: MicEReport, lat: Digits, lonDegrees: number): string
556
677
  return out;
557
678
  }
558
679
 
680
+ /**
681
+ * Mic-E reports whose altitude was given in feet by a program, so it goes in a `/A=` and not the
682
+ * Mic-E altitude bytes, whose unit is the metre (vectors rulings, B2).
683
+ */
684
+ export const ALTITUDE_GIVEN_IN_FEET = new WeakSet<object>();
685
+
559
686
  export function micEAltitude(feet: number): string | undefined {
560
- const metres = Math.round(feet * METRES_PER_FOOT);
687
+ const metres = nearest(feet * METRES_PER_FOOT);
561
688
  if (Math.abs(metres / METRES_PER_FOOT - feet) > 1e-6) return undefined;
562
689
  const v = metres + 10000;
563
690
  if (v < 0 || v > 91 ** 3 - 1) return undefined;
@@ -574,9 +701,9 @@ export function encodeMicE(d: MicEReport, mode: CommentMode): { text: string; de
574
701
  if (d.dfBearing || d.storm || d.area || d.signpost !== undefined) refuse('a Mic-E report cannot carry that data');
575
702
  const ambiguity = d.ambiguity ?? 0;
576
703
  if (!Number.isInteger(ambiguity) || ambiguity < 0 || ambiguity > 4) refuse('ambiguity is 0-4 digits');
577
- const truncate = d.dao !== undefined || ambiguity > 0;
578
- const lat = digitsOf(d.latitude, truncate);
579
- const lon = digitsOf(d.longitude, truncate);
704
+ const digits = digitsMode(d);
705
+ const lat = digitsOf(d.latitude, digits, ambiguity);
706
+ const lon = digitsOf(d.longitude, digits, ambiguity);
580
707
  if (lat.degrees > 90 || (lat.degrees === 90 && lat.hundredths > 0)) refuse('latitude is out of range');
581
708
  if (lon.degrees > 179) refuse('Mic-E longitude is below 180 degrees');
582
709
  const destination = micEDestination(d, lat, lon.degrees);
@@ -587,16 +714,24 @@ export function encodeMicE(d: MicEReport, mode: CommentMode): { text: string; de
587
714
  else if (deg <= 99) dByte = deg + 28;
588
715
  else if (deg <= 109) dByte = deg + 8;
589
716
  else dByte = deg - 100 + 28;
590
- // With ambiguity the digits a decoder ignores are written as the centre it reports.
591
- const mValue = Math.floor(lon.hundredths / 100);
592
- const hundredths = lon.hundredths % 100;
717
+ // With ambiguity the digits a decoder ignores are written as the centre it reports (vectors
718
+ // README, Mic-E): 03.5x is 03.55, 03.xx 03.50, 0x.xx 05.00, xx.xx 30.00.
719
+ let lonHundredths = lon.hundredths;
720
+ if (ambiguity === 1) lonHundredths = Math.floor(lonHundredths / 10) * 10 + 5;
721
+ else if (ambiguity === 2) lonHundredths = Math.floor(lonHundredths / 100) * 100 + 50;
722
+ else if (ambiguity === 3) lonHundredths = Math.floor(lonHundredths / 1000) * 1000 + 500;
723
+ else if (ambiguity === 4) lonHundredths = 3000;
724
+ const mValue = Math.floor(lonHundredths / 100);
725
+ const hundredths = lonHundredths % 100;
593
726
  const mByte = mValue < 10 ? mValue + 88 : mValue + 28;
594
727
  const hByte = hundredths + 28;
595
- // Speed and course: speed tens + 80 (so values under 200 knots are printable), course + 400.
728
+ // Speed and course in their printable forms (APRS12c ch. 10): speed tens + 80 below 190 knots,
729
+ // and as they are from 190 on (190-199 is /, not DEL: vectors rulings, E5); course + 400.
596
730
  const speed = d.speedKnots === undefined ? 0 : whole(d.speedKnots, 0, 799, 'speed');
597
- const course = d.courseDegrees === undefined ? 0 : whole(d.courseDegrees, 0, 360, 'course');
731
+ // A course of 0 (after rounding) reads back as no course, so it is refused (vectors rulings, F1).
732
+ const course = d.courseDegrees === undefined ? 0 : whole(d.courseDegrees, 1, 360, 'course');
598
733
  const tens = Math.floor(speed / 10);
599
- const spByte = tens < 20 ? tens + 80 + 28 : tens + 28;
734
+ const spByte = tens < 19 ? tens + 80 + 28 : tens + 28;
600
735
  const dcByte = (speed % 10) * 10 + Math.floor(course / 100) + 4 + 28;
601
736
  const seByte = (course % 100) + 28;
602
737
  const dti = d.oldData ? "'" : '`';
@@ -618,7 +753,7 @@ export function encodeMicE(d: MicEReport, mode: CommentMode): { text: string; de
618
753
  }
619
754
  let altitudeInComment: number | undefined;
620
755
  if (d.altitudeFeet !== undefined) {
621
- const alt = micEAltitude(d.altitudeFeet);
756
+ const alt = ALTITUDE_GIVEN_IN_FEET.has(d) ? undefined : micEAltitude(d.altitudeFeet);
622
757
  if (alt !== undefined) text += alt;
623
758
  else altitudeInComment = d.altitudeFeet;
624
759
  }
@@ -640,22 +775,29 @@ export function encodeMicE(d: MicEReport, mode: CommentMode): { text: string; de
640
775
  if (d.frequency) rest += (exts > 0 && altitudeInComment === undefined && !after.endsWith('/') ? '/' : '') + frequencyText(d.frequency);
641
776
  const comment = d.comment ?? '';
642
777
  if (comment.length > 0 || mode === 'delimited') {
643
- if (rest.length > 0 && mode !== 'joined') rest += ' ';
778
+ if ((rest.length > 0 && mode !== 'joined') || mode === 'spaced') rest += ' ';
644
779
  rest += (mode === 'delimited' ? '/' : '') + freeText(comment, 'the comment');
645
780
  }
646
781
  after += rest;
647
- if (d.telemetry) after += telemetryText(d.telemetry);
648
- if (d.dao) after += daoText(d, lat.remainder, lon.remainder, ambiguity === 0);
782
+ // Telemetry and the !DAO! are lifted before a locator is read, so they follow it straight on
783
+ // (vectors rulings, F16).
784
+ let lifted = '';
785
+ if (d.telemetry) lifted += telemetryText(d.telemetry);
786
+ if (d.dao) lifted += daoText(d, lat.extra, lon.extra, ambiguity === 0);
649
787
  if (d.locator !== undefined) {
650
788
  if (!/^[A-R]{2}[0-9]{2}(?:[A-X]{2})?$/.test(d.locator)) refuse('the locator is 4 or 6 characters, upper case');
651
789
  text += `${d.locator}/G`;
652
790
  if (after.length > 0) text += ' ';
653
791
  }
654
- text += after;
792
+ text += after + lifted;
655
793
  if (d.deviceSuffix !== undefined) {
656
794
  if (d.typeCode === undefined) refuse('a Mic-E device suffix needs a type code');
657
795
  text += d.deviceSuffix;
658
796
  }
797
+ // Mic-E status text must not start with 0x1D, which would be taken for Rev 0 telemetry
798
+ // (APRS12c ch. 10), so a comment that would start it is written after a /.
799
+ const legacy = d.legacyTelemetry !== undefined && d.legacyTelemetry.length > 0;
800
+ if (!legacy && text.startsWith('\x1d') && mode !== 'delimited') return encodeMicE(d, 'delimited');
659
801
  info += text;
660
802
  return { text: info, destination };
661
803
  }