@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,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,15 @@ 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
+ };
110
162
  }
111
163
 
112
164
  /**
@@ -115,12 +167,45 @@ function compressedPosition(f: PositionedFields, cs: string | undefined, compres
115
167
  */
116
168
  function ggaAltitude(feet: number): { cs: string; exact: boolean } {
117
169
  if (!Number.isFinite(feet)) refuse('altitude is not a number');
118
- const v = feet <= 1 ? 0 : Math.round(Math.log(feet) / Math.log(1.002));
170
+ const v = feet <= 1 ? 0 : nearest(Math.log(feet) / Math.log(1.002));
119
171
  if (v > 91 * 91 - 1) refuse('altitude is too high for the compressed format');
120
172
  const cs = String.fromCharCode(Math.floor(v / 91) + 33) + String.fromCharCode((v % 91) + 33);
121
173
  return { cs, exact: Math.abs(Math.pow(1.002, v) - feet) <= 1e-9 * Math.abs(feet) };
122
174
  }
123
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);
207
+ }
208
+
124
209
  // ---- data extensions and comment elements
125
210
 
126
211
  function courseSpeed(course: number | undefined, speed: number | undefined, what = 'course'): string {
@@ -138,8 +223,10 @@ function rateChar(r: number): string {
138
223
  return v <= 9 ? String(v) : String.fromCharCode(55 + v);
139
224
  }
140
225
 
141
- function altitudeText(feet: number): string {
142
- 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);
143
230
  if (feet < 0) {
144
231
  if (feet < -99999) refuse('altitude is out of range');
145
232
  return `/A=-${pad(-feet, 5)}`;
@@ -154,7 +241,7 @@ export function frequencyText(f: VoiceFrequency): string {
154
241
  let whole3: string;
155
242
  let fraction: string;
156
243
  const resolution = f.tenKhzResolution ? 100 : 1000;
157
- const scaled = Math.round(mhz * resolution);
244
+ const scaled = nearest(mhz * resolution);
158
245
  const intPart = Math.floor(scaled / resolution);
159
246
  const fracPart = scaled % resolution;
160
247
  fraction = pad(fracPart, f.tenKhzResolution ? 2 : 3);
@@ -208,7 +295,11 @@ export function telemetryText(t: CommentTelemetry): string {
208
295
  return out + '|';
209
296
  }
210
297
 
211
- 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 {
212
303
  const dao = f.dao!;
213
304
  const datum = dao.datum;
214
305
  if (/^[0-9]$/.test(datum)) {
@@ -220,18 +311,12 @@ function daoText(f: PositionedFields, latRemainder: number, lonRemainder: number
220
311
  switch (dao.precision) {
221
312
  case 'none':
222
313
  return `!${datum.toUpperCase()} !`;
223
- case 'thousandths': {
314
+ case 'thousandths':
224
315
  if (!applied) return `!${datum.toUpperCase()}00!`;
225
- const a = Math.min(9, Math.round(latRemainder * 1000));
226
- const o = Math.min(9, Math.round(lonRemainder * 1000));
227
- return `!${datum.toUpperCase()}${a}${o}!`;
228
- }
229
- case 'base91': {
316
+ return `!${datum.toUpperCase()}${latExtra}${lonExtra}!`;
317
+ case 'base91':
230
318
  if (!applied) return `!${datum.toLowerCase()}!!!`;
231
- const a = Math.min(90, Math.round(latRemainder * 100 * 91));
232
- const o = Math.min(90, Math.round(lonRemainder * 100 * 91));
233
- return `!${datum.toLowerCase()}${String.fromCharCode(a + 33)}${String.fromCharCode(o + 33)}!`;
234
- }
319
+ return `!${datum.toLowerCase()}${String.fromCharCode(latExtra + 33)}${String.fromCharCode(lonExtra + 33)}!`;
235
320
  }
236
321
  }
237
322
 
@@ -245,19 +330,19 @@ function weatherValue(v: number | undefined, width: number, min: number, max: nu
245
330
  }
246
331
 
247
332
  /**
248
- * Snowfall written exactly in its three characters, with a decimal point where it needs one
249
- * (0.32 as `.32`), and refused when they cannot hold it.
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.
250
337
  */
251
338
  function snowfallText(inches: number): string {
252
- if (!Number.isFinite(inches) || inches < 0) refuse('snowfall is not a number of inches');
253
- let text: string;
254
- if (Number.isInteger(inches)) text = pad(whole(inches, 0, 999, 'snowfall'), 3);
255
- else if (String(inches).length === 3) text = String(inches);
256
- else text = inches < 1 ? '.' + pad(Math.round(inches * 100), 2) : '';
257
- if (!/^[0-9.]{3}$/.test(text) || /\..*\./.test(text) || Math.abs(Number(text) - inches) > 1e-9) {
258
- refuse(`snowfall ${inches} does not fit in 3 characters`);
259
- }
260
- return text;
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);
261
346
  }
262
347
 
263
348
  /** Weather fields after the wind: gust, temperature, rain, humidity, pressure, and on. */
@@ -324,12 +409,11 @@ function hasExtension(f: PositionedFields): string[] {
324
409
 
325
410
  /**
326
411
  * How the comment is joined on: `plain` (after a space when it follows a frequency), `joined`
327
- * (straight after a frequency, with no space) or `delimited` (after a `/`), for when it would
328
- * otherwise read as something else; or `braces-first`, as `plain` but with the signpost or
329
- * corridor braces written before the comment, for a comment holding braces that would be taken
330
- * for them (the first well-formed braces are the signpost).
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).
331
415
  */
332
- export type CommentMode = 'plain' | 'joined' | 'delimited' | 'braces-first';
416
+ export type CommentMode = 'plain' | 'joined' | 'delimited' | 'spaced';
333
417
 
334
418
  /** Encodes a position and what follows it. */
335
419
  function positionedBody(f: PositionedFields, mode: CommentMode): Body {
@@ -338,7 +422,8 @@ function positionedBody(f: PositionedFields, mode: CommentMode): Body {
338
422
  if (isWeather && !f.weather) refuse('the weather station symbol (_) needs weather data');
339
423
  if (isWeather) return weatherBody(f);
340
424
  const exts = hasExtension(f);
341
- 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));
342
427
  if (exts.length + (hasCourse ? 1 : 0) > 1) refuse(`only one data extension can follow the symbol (${[hasCourse ? 'course/speed' : '', ...exts].filter(Boolean).join(', ')})`);
343
428
  if (f.dfBearing && !(f.symbol.table === '/' && f.symbol.code === '\\')) refuse('a DF bearing needs the DF symbol (/\\)');
344
429
  if (f.storm && !(f.symbol.code === '@' && (f.symbol.table === '/' || f.symbol.table === '\\'))) refuse('storm data needs a hurricane symbol (/@ or \\@)');
@@ -348,29 +433,25 @@ function positionedBody(f: PositionedFields, mode: CommentMode): Body {
348
433
  let out = '';
349
434
  let compression: CompressionType | undefined;
350
435
  let altitudeInCs = false;
351
- let latRem = 0;
352
- let lonRem = 0;
436
+ let latExtra = 0;
437
+ let lonExtra = 0;
353
438
  let daoApplied = false;
354
439
  if (f.compressed) {
355
440
  if (f.phg || f.dfs || f.area || f.dfBearing || f.storm) refuse('a compressed report carries only course/speed, range or altitude');
356
441
  let cs: string | undefined;
357
442
  compression = f.compression;
358
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).
359
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');
360
448
  if (compression?.source === 'gga') refuse('a GGA compression type means the cs bytes hold altitude');
361
- const c = whole(f.courseDegrees, 1, 360, 'course') % 360;
362
- if (c % 4 !== 0 && Math.abs(Math.round(c / 4) * 4 - f.courseDegrees) > 2) refuse('course is out of range');
363
- const speed = f.speedKnots ?? 0;
364
- if (speed < 0) refuse('speed is negative');
365
- const s = Math.round(Math.log(speed + 1) / Math.log(1.08));
366
- if (s > 90) refuse('speed is too high for the compressed format');
367
- 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');
368
451
  compression = compression ?? DEFAULT_COMPRESSION;
369
452
  } else if (f.rangeMiles !== undefined) {
370
453
  if (compression?.source === 'gga') 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)) refuse('range is out of range for the compressed format');
373
- cs = '{' + String.fromCharCode(s + 33);
454
+ cs = compressedRange(f.rangeMiles);
374
455
  compression = compression ?? DEFAULT_COMPRESSION;
375
456
  } else if (f.altitudeFeet !== undefined && compression?.source === 'gga') {
376
457
  const alt = ggaAltitude(f.altitudeFeet);
@@ -379,23 +460,27 @@ function positionedBody(f: PositionedFields, mode: CommentMode): Body {
379
460
  } else if (compression !== undefined) {
380
461
  refuse('a compression type needs cs data (course/speed, range or altitude)');
381
462
  }
382
- 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;
383
468
  } else {
384
469
  if (f.compression) refuse('an uncompressed position has no compression type');
385
- const pos = uncompressedPosition(f, f.dao !== undefined);
470
+ const pos = uncompressedPosition(f);
386
471
  out += pos.text;
387
- latRem = pos.latRemainder;
388
- lonRem = pos.lonRemainder;
472
+ latExtra = pos.latExtra;
473
+ lonExtra = pos.lonExtra;
389
474
  daoApplied = (f.ambiguity ?? 0) === 0;
390
475
  if (hasCourse) {
476
+ // DF or storm data with no course and speed follows `.../...`, which reads back as unknown
477
+ // (vectors rulings, F8).
391
478
  out += courseSpeed(f.courseDegrees, f.speedKnots);
392
479
  if (f.dfBearing) {
393
480
  const b = f.dfBearing;
394
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')}`;
395
482
  }
396
483
  if (f.storm) out += stormText(f.storm);
397
- } else if (f.dfBearing || f.storm) {
398
- refuse('DF and storm data follow a course/speed extension');
399
484
  } else if (f.phg) {
400
485
  const p = f.phg;
401
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')}`;
@@ -414,12 +499,14 @@ function positionedBody(f: PositionedFields, mode: CommentMode): Body {
414
499
  out += `${shape}${pad(whole(a.latOffset, 0, 99, 'area offset'), 2)}${c}${pad(whole(a.lonOffset, 0, 99, 'area offset'), 2)}`;
415
500
  }
416
501
  }
417
- // Altitude, unless the compressed cs bytes carry it exactly.
418
- if (f.altitudeFeet !== undefined && !altitudeInCs) out += altitudeText(f.altitudeFeet);
419
- 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).
420
505
  let text = '';
421
- // A frequency straight after a 7-byte extension is separated from it by / (a PHGR already ends in one).
422
- 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);
423
510
  let braces = '';
424
511
  if (f.signpost !== undefined) {
425
512
  // Printable ASCII, a space included, other than the braces around it.
@@ -430,14 +517,17 @@ function positionedBody(f: PositionedFields, mode: CommentMode): Body {
430
517
  if (f.area.shape !== 'line-down-right' && f.area.shape !== 'line-down-left') refuse('only a line has a corridor');
431
518
  braces = `{${whole(f.area.corridorWidthMiles, 0, 999, 'corridor width')}}`;
432
519
  }
520
+ text += braces;
521
+ if (f.altitudeFeet !== undefined && !altitudeInCs) text += altitudeText(f.altitudeFeet);
433
522
  const comment = f.comment ?? '';
434
523
  if (comment.length > 0 || mode === 'delimited') {
435
- 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 += ' ';
436
527
  text += (mode === 'delimited' ? '/' : '') + freeText(comment, 'the comment');
437
528
  }
438
- text = mode === 'braces-first' ? braces + text : text + braces;
439
529
  if (f.telemetry) text += telemetryText(f.telemetry);
440
- if (f.dao) text += daoText(f, latRem, lonRem, daoApplied);
530
+ if (f.dao) text += daoText(f, latExtra, lonExtra, daoApplied);
441
531
  const body: Body = { text: out + text };
442
532
  if (compression) body.compression = compression;
443
533
  return body;
@@ -445,7 +535,9 @@ function positionedBody(f: PositionedFields, mode: CommentMode): Body {
445
535
 
446
536
  function stormText(s: NonNullable<PositionedFields['storm']>): string {
447
537
  const type = s.type === 'tropical-storm' ? 'TS' : s.type === 'hurricane' ? 'HC' : s.type === 'tropical-depression' ? 'TD' : refuse('unknown storm type');
448
- 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);
449
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')}`;
450
542
  if (s.wholeGaleRadiusNm !== undefined) out += `%${v(s.wholeGaleRadiusNm, 3, 'radius')}`;
451
543
  return out;
@@ -461,8 +553,8 @@ function weatherBody(f: PositionedFields): Body {
461
553
  if (f.telemetry) refuse('comment telemetry goes in a comment, which a weather report does not have');
462
554
  let out = '';
463
555
  let compression: CompressionType | undefined;
464
- let latRem = 0;
465
- let lonRem = 0;
556
+ let latExtra = 0;
557
+ let lonExtra = 0;
466
558
  let daoApplied = false;
467
559
  let altitudeInCs = false;
468
560
  if (f.compressed) {
@@ -470,42 +562,41 @@ function weatherBody(f: PositionedFields): Body {
470
562
  let cs: string | undefined;
471
563
  if (w.windDirectionDegrees !== undefined || w.windSpeedMph !== undefined) {
472
564
  if (w.windDirectionDegrees === undefined || w.windSpeedMph === undefined) refuse('compressed wind needs both direction and speed');
473
- const dir = whole(w.windDirectionDegrees, 0, 360, 'wind direction');
474
- const knots = w.windSpeedMph / KNOTS_TO_MPH;
475
- const s = Math.round(Math.log(knots + 1) / Math.log(1.08));
476
- if (s < 0 || s > 90) refuse('wind speed is out of range for the compressed format');
477
- 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');
478
567
  compression = compression ?? DEFAULT_COMPRESSION;
479
568
  if (compression.source === 'gga') refuse('a GGA compression type means the cs bytes hold altitude');
480
569
  } else if (f.altitudeFeet !== undefined && compression?.source === 'gga') {
481
- const alt = ggaAltitude(f.altitudeFeet);
482
- cs = alt.cs;
483
- altitudeInCs = alt.exact;
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;
484
573
  } else if (f.rangeMiles !== undefined) {
485
574
  if (compression?.source === 'gga') refuse('a GGA compression type means the cs bytes hold altitude');
486
- const s = Math.round(Math.log(f.rangeMiles / 2) / Math.log(1.08));
487
- if (!(s >= 0 && s <= 90)) refuse('range is out of range for the compressed format');
488
- cs = '{' + String.fromCharCode(s + 33);
575
+ cs = compressedRange(f.rangeMiles);
489
576
  compression = compression ?? DEFAULT_COMPRESSION;
490
577
  } else if (compression !== undefined) {
491
578
  refuse('a compression type needs cs data');
492
579
  }
493
- 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;
494
585
  if (f.altitudeFeet !== undefined && !altitudeInCs) refuse('a weather report has no altitude in its comment');
495
586
  out += weatherFields(w, true);
496
587
  } else {
497
588
  if (f.compression) refuse('an uncompressed position has no compression type');
498
589
  if (f.altitudeFeet !== undefined) refuse('a weather report has no altitude in its comment');
499
- const pos = uncompressedPosition(f, f.dao !== undefined);
590
+ const pos = uncompressedPosition(f);
500
591
  out += pos.text;
501
- latRem = pos.latRemainder;
502
- lonRem = pos.lonRemainder;
592
+ latExtra = pos.latExtra;
593
+ lonExtra = pos.lonExtra;
503
594
  daoApplied = (f.ambiguity ?? 0) === 0;
504
595
  out += courseSpeed(w.windDirectionDegrees, w.windSpeedMph, 'wind direction');
505
596
  out += weatherFields(w, true);
506
597
  }
507
598
  if (f.telemetry) out += telemetryText(f.telemetry);
508
- if (f.dao) out += daoText(f, latRem, lonRem, daoApplied);
599
+ if (f.dao) out += daoText(f, latExtra, lonExtra, daoApplied);
509
600
  const body: Body = { text: out };
510
601
  if (compression) body.compression = compression;
511
602
  return body;
@@ -586,8 +677,14 @@ function micEDestination(d: MicEReport, lat: Digits, lonDegrees: number): string
586
677
  return out;
587
678
  }
588
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
+
589
686
  export function micEAltitude(feet: number): string | undefined {
590
- const metres = Math.round(feet * METRES_PER_FOOT);
687
+ const metres = nearest(feet * METRES_PER_FOOT);
591
688
  if (Math.abs(metres / METRES_PER_FOOT - feet) > 1e-6) return undefined;
592
689
  const v = metres + 10000;
593
690
  if (v < 0 || v > 91 ** 3 - 1) return undefined;
@@ -604,9 +701,9 @@ export function encodeMicE(d: MicEReport, mode: CommentMode): { text: string; de
604
701
  if (d.dfBearing || d.storm || d.area || d.signpost !== undefined) refuse('a Mic-E report cannot carry that data');
605
702
  const ambiguity = d.ambiguity ?? 0;
606
703
  if (!Number.isInteger(ambiguity) || ambiguity < 0 || ambiguity > 4) refuse('ambiguity is 0-4 digits');
607
- const truncate = d.dao !== undefined || ambiguity > 0;
608
- const lat = digitsOf(d.latitude, truncate);
609
- 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);
610
707
  if (lat.degrees > 90 || (lat.degrees === 90 && lat.hundredths > 0)) refuse('latitude is out of range');
611
708
  if (lon.degrees > 179) refuse('Mic-E longitude is below 180 degrees');
612
709
  const destination = micEDestination(d, lat, lon.degrees);
@@ -617,16 +714,24 @@ export function encodeMicE(d: MicEReport, mode: CommentMode): { text: string; de
617
714
  else if (deg <= 99) dByte = deg + 28;
618
715
  else if (deg <= 109) dByte = deg + 8;
619
716
  else dByte = deg - 100 + 28;
620
- // With ambiguity the digits a decoder ignores are written as the centre it reports.
621
- const mValue = Math.floor(lon.hundredths / 100);
622
- 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;
623
726
  const mByte = mValue < 10 ? mValue + 88 : mValue + 28;
624
727
  const hByte = hundredths + 28;
625
- // 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.
626
730
  const speed = d.speedKnots === undefined ? 0 : whole(d.speedKnots, 0, 799, 'speed');
627
- 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');
628
733
  const tens = Math.floor(speed / 10);
629
- const spByte = tens < 20 ? tens + 80 + 28 : tens + 28;
734
+ const spByte = tens < 19 ? tens + 80 + 28 : tens + 28;
630
735
  const dcByte = (speed % 10) * 10 + Math.floor(course / 100) + 4 + 28;
631
736
  const seByte = (course % 100) + 28;
632
737
  const dti = d.oldData ? "'" : '`';
@@ -648,7 +753,7 @@ export function encodeMicE(d: MicEReport, mode: CommentMode): { text: string; de
648
753
  }
649
754
  let altitudeInComment: number | undefined;
650
755
  if (d.altitudeFeet !== undefined) {
651
- const alt = micEAltitude(d.altitudeFeet);
756
+ const alt = ALTITUDE_GIVEN_IN_FEET.has(d) ? undefined : micEAltitude(d.altitudeFeet);
652
757
  if (alt !== undefined) text += alt;
653
758
  else altitudeInComment = d.altitudeFeet;
654
759
  }
@@ -670,18 +775,21 @@ export function encodeMicE(d: MicEReport, mode: CommentMode): { text: string; de
670
775
  if (d.frequency) rest += (exts > 0 && altitudeInComment === undefined && !after.endsWith('/') ? '/' : '') + frequencyText(d.frequency);
671
776
  const comment = d.comment ?? '';
672
777
  if (comment.length > 0 || mode === 'delimited') {
673
- if (rest.length > 0 && mode !== 'joined') rest += ' ';
778
+ if ((rest.length > 0 && mode !== 'joined') || mode === 'spaced') rest += ' ';
674
779
  rest += (mode === 'delimited' ? '/' : '') + freeText(comment, 'the comment');
675
780
  }
676
781
  after += rest;
677
- if (d.telemetry) after += telemetryText(d.telemetry);
678
- 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);
679
787
  if (d.locator !== undefined) {
680
788
  if (!/^[A-R]{2}[0-9]{2}(?:[A-X]{2})?$/.test(d.locator)) refuse('the locator is 4 or 6 characters, upper case');
681
789
  text += `${d.locator}/G`;
682
790
  if (after.length > 0) text += ' ';
683
791
  }
684
- text += after;
792
+ text += after + lifted;
685
793
  if (d.deviceSuffix !== undefined) {
686
794
  if (d.typeCode === undefined) refuse('a Mic-E device suffix needs a type code');
687
795
  text += d.deviceSuffix;
package/src/index.ts CHANGED
@@ -54,4 +54,5 @@ export {
54
54
  DEFAULT_DESTINATION,
55
55
  timestampOf,
56
56
  type BuiltPacket,
57
+ type WeatherInput,
57
58
  } from './builder.js';