@squawk/mode-s 0.1.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 (89) hide show
  1. package/README.md +243 -0
  2. package/dist/acas.d.ts +14 -0
  3. package/dist/acas.d.ts.map +1 -0
  4. package/dist/acas.js +90 -0
  5. package/dist/altitude.d.ts +34 -0
  6. package/dist/altitude.d.ts.map +1 -0
  7. package/dist/altitude.js +99 -0
  8. package/dist/bits.d.ts +41 -0
  9. package/dist/bits.d.ts.map +1 -0
  10. package/dist/bits.js +55 -0
  11. package/dist/comm-b.d.ts +37 -0
  12. package/dist/comm-b.d.ts.map +1 -0
  13. package/dist/comm-b.js +245 -0
  14. package/dist/cpr.d.ts +71 -0
  15. package/dist/cpr.d.ts.map +1 -0
  16. package/dist/cpr.js +220 -0
  17. package/dist/emergency-status.d.ts +10 -0
  18. package/dist/emergency-status.d.ts.map +1 -0
  19. package/dist/emergency-status.js +21 -0
  20. package/dist/frame.d.ts +42 -0
  21. package/dist/frame.d.ts.map +1 -0
  22. package/dist/frame.js +68 -0
  23. package/dist/identification.d.ts +10 -0
  24. package/dist/identification.d.ts.map +1 -0
  25. package/dist/identification.js +64 -0
  26. package/dist/identity.d.ts +11 -0
  27. package/dist/identity.d.ts.map +1 -0
  28. package/dist/identity.js +20 -0
  29. package/dist/index.d.ts +32 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +21 -0
  32. package/dist/message.d.ts +40 -0
  33. package/dist/message.d.ts.map +1 -0
  34. package/dist/message.js +293 -0
  35. package/dist/mode-ac.d.ts +17 -0
  36. package/dist/mode-ac.d.ts.map +1 -0
  37. package/dist/mode-ac.js +86 -0
  38. package/dist/operational-status.d.ts +9 -0
  39. package/dist/operational-status.d.ts.map +1 -0
  40. package/dist/operational-status.js +23 -0
  41. package/dist/surface-movement.d.ts +9 -0
  42. package/dist/surface-movement.d.ts.map +1 -0
  43. package/dist/surface-movement.js +37 -0
  44. package/dist/target-state-status.d.ts +9 -0
  45. package/dist/target-state-status.d.ts.map +1 -0
  46. package/dist/target-state-status.js +34 -0
  47. package/dist/test-utils.d.ts +7 -0
  48. package/dist/test-utils.d.ts.map +1 -0
  49. package/dist/test-utils.js +17 -0
  50. package/dist/types/acas.d.ts +82 -0
  51. package/dist/types/acas.d.ts.map +1 -0
  52. package/dist/types/acas.js +1 -0
  53. package/dist/types/comm-b.d.ts +69 -0
  54. package/dist/types/comm-b.d.ts.map +1 -0
  55. package/dist/types/comm-b.js +1 -0
  56. package/dist/types/cpr.d.ts +25 -0
  57. package/dist/types/cpr.d.ts.map +1 -0
  58. package/dist/types/cpr.js +1 -0
  59. package/dist/types/emergency-status.d.ts +6 -0
  60. package/dist/types/emergency-status.d.ts.map +1 -0
  61. package/dist/types/emergency-status.js +1 -0
  62. package/dist/types/frame.d.ts +32 -0
  63. package/dist/types/frame.d.ts.map +1 -0
  64. package/dist/types/frame.js +1 -0
  65. package/dist/types/identification.d.ts +9 -0
  66. package/dist/types/identification.d.ts.map +1 -0
  67. package/dist/types/identification.js +1 -0
  68. package/dist/types/index.d.ts +12 -0
  69. package/dist/types/index.d.ts.map +1 -0
  70. package/dist/types/index.js +11 -0
  71. package/dist/types/message.d.ts +235 -0
  72. package/dist/types/message.d.ts.map +1 -0
  73. package/dist/types/message.js +1 -0
  74. package/dist/types/mode-ac.d.ts +16 -0
  75. package/dist/types/mode-ac.d.ts.map +1 -0
  76. package/dist/types/mode-ac.js +1 -0
  77. package/dist/types/operational-status.d.ts +28 -0
  78. package/dist/types/operational-status.d.ts.map +1 -0
  79. package/dist/types/operational-status.js +1 -0
  80. package/dist/types/target-state-status.d.ts +34 -0
  81. package/dist/types/target-state-status.d.ts.map +1 -0
  82. package/dist/types/target-state-status.js +1 -0
  83. package/dist/types/velocity.d.ts +35 -0
  84. package/dist/types/velocity.d.ts.map +1 -0
  85. package/dist/types/velocity.js +1 -0
  86. package/dist/velocity.d.ts +9 -0
  87. package/dist/velocity.d.ts.map +1 -0
  88. package/dist/velocity.js +73 -0
  89. package/package.json +61 -0
package/dist/comm-b.js ADDED
@@ -0,0 +1,245 @@
1
+ import { extractBits } from './bits.js';
2
+ /**
3
+ * Combines an unsigned magnitude and a separate sign bit into a signed
4
+ * value. Mode-S encodes sign and magnitude as separate bit fields (not
5
+ * two's complement) - sign=1, magnitude=0 represents `-2**width`, not `-0`.
6
+ */
7
+ function signed(value, width, sign) {
8
+ return sign === 1 ? value - (1 << width) : value;
9
+ }
10
+ /** Wraps an angle into the half-open interval [0, 360) - JavaScript's `%` follows the dividend's sign for negative input, unlike the floor-mod this needs. */
11
+ function normalizeAngleDeg(deg) {
12
+ return ((deg % 360) + 360) % 360;
13
+ }
14
+ /**
15
+ * Returns true if a status-gated value field is inconsistent: BDS registers
16
+ * with status-bit gates encode each field as a status bit followed by a
17
+ * fixed-width value, and when the status is 0 the entire value field
18
+ * (including any sign bit) must also be 0. A nonzero value with status=0
19
+ * indicates either a corrupt message or a different BDS register's payload
20
+ * accidentally passing this one's checks.
21
+ */
22
+ function wrongStatus(mb, statusBit, valueStart, valueWidth) {
23
+ if (extractBits(mb, statusBit, 1) !== 0) {
24
+ return false;
25
+ }
26
+ return extractBits(mb, valueStart, valueWidth) !== 0;
27
+ }
28
+ function decodeTargetAltitudeSource(raw) {
29
+ switch (raw) {
30
+ case 1:
31
+ return 'aircraftAltitude';
32
+ case 2:
33
+ return 'mcpFcu';
34
+ case 3:
35
+ return 'fms';
36
+ default:
37
+ return 'unknown';
38
+ }
39
+ }
40
+ /** Whether `mb` is a structurally plausible BDS 4,0 (Selected Vertical Intention) register - status-bit/value consistency and reserved-bits-must-be-zero checks. Has no format-identifier byte, so this is a heuristic, not a certainty. */
41
+ function isSelectedVerticalIntention(mb) {
42
+ if (mb.every((byte) => byte === 0)) {
43
+ return false;
44
+ }
45
+ if (wrongStatus(mb, 0, 1, 12)) {
46
+ return false;
47
+ }
48
+ if (wrongStatus(mb, 13, 14, 12)) {
49
+ return false;
50
+ }
51
+ if (wrongStatus(mb, 26, 27, 12)) {
52
+ return false;
53
+ }
54
+ if (wrongStatus(mb, 47, 48, 3)) {
55
+ return false;
56
+ }
57
+ if (wrongStatus(mb, 53, 54, 2)) {
58
+ return false;
59
+ }
60
+ if (extractBits(mb, 39, 8) !== 0) {
61
+ return false;
62
+ }
63
+ return extractBits(mb, 51, 2) === 0;
64
+ }
65
+ /**
66
+ * Decodes a BDS 4,0 (Selected Vertical Intention) register.
67
+ *
68
+ * @param mb - The 7-byte MB field of a DF20/21 Comm-B reply, already known (or inferred via {@link inferCommBRegisters}) to be BDS 4,0.
69
+ * @returns The decoded selected vertical intention.
70
+ */
71
+ export function decodeSelectedVerticalIntention(mb) {
72
+ const mcpStatus = extractBits(mb, 0, 1);
73
+ const fmsStatus = extractBits(mb, 13, 1);
74
+ const baroStatus = extractBits(mb, 26, 1);
75
+ const modeStatus = extractBits(mb, 47, 1);
76
+ const sourceStatus = extractBits(mb, 53, 1);
77
+ return {
78
+ bdsCode: '4,0',
79
+ mcpFcuSelectedAltitudeFt: mcpStatus === 1 ? extractBits(mb, 1, 12) * 16 : undefined,
80
+ fmsSelectedAltitudeFt: fmsStatus === 1 ? extractBits(mb, 14, 12) * 16 : undefined,
81
+ baroPressureSettingMb: baroStatus === 1 ? extractBits(mb, 27, 12) * 0.1 + 800 : undefined,
82
+ vnavModeActive: modeStatus === 1 ? extractBits(mb, 48, 1) === 1 : undefined,
83
+ altitudeHoldModeActive: modeStatus === 1 ? extractBits(mb, 49, 1) === 1 : undefined,
84
+ approachModeActive: modeStatus === 1 ? extractBits(mb, 50, 1) === 1 : undefined,
85
+ targetAltitudeSource: sourceStatus === 1 ? decodeTargetAltitudeSource(extractBits(mb, 54, 2)) : undefined,
86
+ };
87
+ }
88
+ /** Whether `mb` is a structurally plausible BDS 5,0 (Track and Turn Report) register - status-bit consistency plus physically-plausible range checks (e.g. |roll| <= 35deg). Has no format-identifier byte, so this is a heuristic, not a certainty - a payload can pass both this and {@link isHeadingAndSpeedReport}. */
89
+ function isTrackAndTurnReport(mb) {
90
+ if (mb.every((byte) => byte === 0)) {
91
+ return false;
92
+ }
93
+ if (wrongStatus(mb, 0, 1, 10)) {
94
+ return false;
95
+ }
96
+ if (wrongStatus(mb, 11, 12, 11)) {
97
+ return false;
98
+ }
99
+ if (wrongStatus(mb, 23, 24, 10)) {
100
+ return false;
101
+ }
102
+ if (wrongStatus(mb, 34, 35, 10)) {
103
+ return false;
104
+ }
105
+ if (wrongStatus(mb, 45, 46, 10)) {
106
+ return false;
107
+ }
108
+ if (extractBits(mb, 0, 1) === 1) {
109
+ const rollDeg = (signed(extractBits(mb, 2, 9), 9, extractBits(mb, 1, 1)) * 45) / 256;
110
+ if (Math.abs(rollDeg) > 35) {
111
+ return false;
112
+ }
113
+ }
114
+ const gsStatus = extractBits(mb, 23, 1);
115
+ const gsRaw = extractBits(mb, 24, 10);
116
+ if (gsStatus === 1 && gsRaw * 2 > 600) {
117
+ return false;
118
+ }
119
+ const tasStatus = extractBits(mb, 45, 1);
120
+ const tasRaw = extractBits(mb, 46, 10);
121
+ if (tasStatus === 1 && tasRaw * 2 > 600) {
122
+ return false;
123
+ }
124
+ return !(gsStatus === 1 && tasStatus === 1 && Math.abs(tasRaw * 2 - gsRaw * 2) > 200);
125
+ }
126
+ /**
127
+ * Decodes a BDS 5,0 (Track and Turn Report) register.
128
+ *
129
+ * @param mb - The 7-byte MB field of a DF20/21 Comm-B reply, already known (or inferred via {@link inferCommBRegisters}) to be BDS 5,0.
130
+ * @returns The decoded track and turn report.
131
+ */
132
+ export function decodeTrackAndTurnReport(mb) {
133
+ const rollStatus = extractBits(mb, 0, 1);
134
+ const trackStatus = extractBits(mb, 11, 1);
135
+ const gsStatus = extractBits(mb, 23, 1);
136
+ const trackRateStatus = extractBits(mb, 34, 1);
137
+ const tasStatus = extractBits(mb, 45, 1);
138
+ return {
139
+ bdsCode: '5,0',
140
+ rollAngleDeg: rollStatus === 1
141
+ ? (signed(extractBits(mb, 2, 9), 9, extractBits(mb, 1, 1)) * 45) / 256
142
+ : undefined,
143
+ trueTrackDeg: trackStatus === 1
144
+ ? normalizeAngleDeg((signed(extractBits(mb, 13, 10), 10, extractBits(mb, 12, 1)) * 90) / 512)
145
+ : undefined,
146
+ groundSpeedKt: gsStatus === 1 ? extractBits(mb, 24, 10) * 2 : undefined,
147
+ trackAngleRateDegPerSec: trackRateStatus === 1
148
+ ? (signed(extractBits(mb, 36, 9), 9, extractBits(mb, 35, 1)) * 8) / 256
149
+ : undefined,
150
+ trueAirspeedKt: tasStatus === 1 ? extractBits(mb, 46, 10) * 2 : undefined,
151
+ };
152
+ }
153
+ /** Whether `mb` is a structurally plausible BDS 6,0 (Heading and Speed Report) register - status-bit consistency plus physically-plausible range checks (e.g. Mach <= 1). Has no format-identifier byte, so this is a heuristic, not a certainty - a payload can pass both this and {@link isTrackAndTurnReport}. */
154
+ function isHeadingAndSpeedReport(mb) {
155
+ if (mb.every((byte) => byte === 0)) {
156
+ return false;
157
+ }
158
+ if (wrongStatus(mb, 0, 1, 11)) {
159
+ return false;
160
+ }
161
+ if (wrongStatus(mb, 12, 13, 10)) {
162
+ return false;
163
+ }
164
+ if (wrongStatus(mb, 23, 24, 10)) {
165
+ return false;
166
+ }
167
+ if (wrongStatus(mb, 34, 35, 10)) {
168
+ return false;
169
+ }
170
+ if (wrongStatus(mb, 45, 46, 10)) {
171
+ return false;
172
+ }
173
+ const iasStatus = extractBits(mb, 12, 1);
174
+ if (iasStatus === 1 && extractBits(mb, 13, 10) > 500) {
175
+ return false;
176
+ }
177
+ const machStatus = extractBits(mb, 23, 1);
178
+ if (machStatus === 1 && (extractBits(mb, 24, 10) * 2.048) / 512 > 1) {
179
+ return false;
180
+ }
181
+ const vrbStatus = extractBits(mb, 34, 1);
182
+ if (vrbStatus === 1) {
183
+ const vrb = signed(extractBits(mb, 36, 9), 9, extractBits(mb, 35, 1)) * 32;
184
+ if (Math.abs(vrb) > 6000) {
185
+ return false;
186
+ }
187
+ }
188
+ const vriStatus = extractBits(mb, 45, 1);
189
+ if (vriStatus === 1) {
190
+ const vri = signed(extractBits(mb, 47, 9), 9, extractBits(mb, 46, 1)) * 32;
191
+ if (Math.abs(vri) > 6000) {
192
+ return false;
193
+ }
194
+ }
195
+ return true;
196
+ }
197
+ /**
198
+ * Decodes a BDS 6,0 (Heading and Speed Report) register.
199
+ *
200
+ * @param mb - The 7-byte MB field of a DF20/21 Comm-B reply, already known (or inferred via {@link inferCommBRegisters}) to be BDS 6,0.
201
+ * @returns The decoded heading and speed report.
202
+ */
203
+ export function decodeHeadingAndSpeedReport(mb) {
204
+ const headingStatus = extractBits(mb, 0, 1);
205
+ const iasStatus = extractBits(mb, 12, 1);
206
+ const machStatus = extractBits(mb, 23, 1);
207
+ const vrbStatus = extractBits(mb, 34, 1);
208
+ const vriStatus = extractBits(mb, 45, 1);
209
+ return {
210
+ bdsCode: '6,0',
211
+ magneticHeadingDeg: headingStatus === 1
212
+ ? normalizeAngleDeg((signed(extractBits(mb, 2, 10), 10, extractBits(mb, 1, 1)) * 90) / 512)
213
+ : undefined,
214
+ indicatedAirspeedKt: iasStatus === 1 ? extractBits(mb, 13, 10) : undefined,
215
+ mach: machStatus === 1 ? (extractBits(mb, 24, 10) * 2.048) / 512 : undefined,
216
+ baroVerticalRateFtPerMin: vrbStatus === 1 ? signed(extractBits(mb, 36, 9), 9, extractBits(mb, 35, 1)) * 32 : undefined,
217
+ inertialVerticalRateFtPerMin: vriStatus === 1 ? signed(extractBits(mb, 47, 9), 9, extractBits(mb, 46, 1)) * 32 : undefined,
218
+ };
219
+ }
220
+ /**
221
+ * Infers which "Enhanced Surveillance" Comm-B register(s) a DF20/21 MB
222
+ * field plausibly holds, and decodes each. Unlike DF17/18's type-code-coded
223
+ * ME field, a Comm-B MB field carries no self-declared register identifier
224
+ * for BDS 4,0/5,0/6,0 - this package would rather report every plausible
225
+ * candidate (via structural and range validation, matching pyModeS's own
226
+ * approach to this exact ambiguity) than silently guess a single wrong
227
+ * answer. In practice this is usually unambiguous; BDS 5,0 and 6,0 are the
228
+ * pair most likely to both pass validation for the same bytes.
229
+ *
230
+ * @param mb - The 7-byte MB field of a DF20/21 Comm-B reply.
231
+ * @returns Every register this MB field plausibly holds - empty if none, one if unambiguous, more than one if genuinely ambiguous.
232
+ */
233
+ export function inferCommBRegisters(mb) {
234
+ const candidates = [];
235
+ if (isSelectedVerticalIntention(mb)) {
236
+ candidates.push(decodeSelectedVerticalIntention(mb));
237
+ }
238
+ if (isTrackAndTurnReport(mb)) {
239
+ candidates.push(decodeTrackAndTurnReport(mb));
240
+ }
241
+ if (isHeadingAndSpeedReport(mb)) {
242
+ candidates.push(decodeHeadingAndSpeedReport(mb));
243
+ }
244
+ return candidates;
245
+ }
package/dist/cpr.d.ts ADDED
@@ -0,0 +1,71 @@
1
+ import type { Position } from '@squawk/types';
2
+ import type { CprPosition, CprReference } from './types/index.js';
3
+ /**
4
+ * Returns the number of CPR longitude zones (NL) at a given latitude, per
5
+ * ICAO Annex 10 Vol. IV / RTCA DO-260B SS A.1.7.2. NL is 1-59, monotone
6
+ * non-increasing in absolute latitude: 59 at the equator, 2 at +-87 deg, 1
7
+ * beyond +-87 deg.
8
+ *
9
+ * @param latDeg - Latitude in decimal degrees.
10
+ * @returns The number of longitude zones, 1-59.
11
+ */
12
+ export declare function cprNumLongitudeZones(latDeg: number): number;
13
+ /**
14
+ * Resolves an absolute position from a paired even and odd airborne CPR
15
+ * frame (ADS-B BDS 0,5 / 2,0 - type codes 9-18 and 20-22), per DO-260B SS
16
+ * A.1.7.3. The two frames must be close enough in time that the aircraft
17
+ * could not have crossed a latitude zone boundary - in practice, no more
18
+ * than about 10 seconds apart, since airborne position is broadcast at
19
+ * roughly 2 Hz. Enforcing that window is the caller's responsibility; this
20
+ * function only performs a zone-consistency check as a lightweight guard,
21
+ * which cannot by itself detect a same-zone false positive from a pair
22
+ * spanning too wide a gap.
23
+ *
24
+ * @param even - Raw CPR fields from the even-format (F=0) frame.
25
+ * @param odd - Raw CPR fields from the odd-format (F=1) frame.
26
+ * @param newerFormat - Which frame is more recent - determines which frame's latitude zone the result is reported in.
27
+ * @returns The resolved position, or undefined if the two frames fall in different latitude zones (the pair cannot be combined) or the result is physically impossible.
28
+ */
29
+ export declare function decodeAirborneCprPair(even: CprPosition, odd: CprPosition, newerFormat: 'even' | 'odd'): Position | undefined;
30
+ /**
31
+ * Resolves an absolute position from a single airborne CPR frame plus a
32
+ * known nearby reference position, per DO-260B SS A.1.7.5. The reference
33
+ * must be within roughly 180 NM of the true position - typically the
34
+ * aircraft's own last known position, or the receiver's location as a
35
+ * fallback for the first message from a new aircraft.
36
+ *
37
+ * @param format - Whether `frame` is an even (F=0) or odd (F=1) format frame.
38
+ * @param frame - Raw CPR fields from the frame.
39
+ * @param reference - A position known to be near the frame's true position.
40
+ * @returns The resolved position.
41
+ */
42
+ export declare function decodeAirborneCprWithReference(format: 'even' | 'odd', frame: CprPosition, reference: CprReference): Position;
43
+ /**
44
+ * Resolves an absolute position from a paired even and odd surface CPR
45
+ * frame (ADS-B BDS 0,6 - type codes 5-8), per DO-260B SS A.1.7.4. Surface
46
+ * CPR spans a 90 degree latitude zone (versus 360 degree for airborne) and
47
+ * is ambiguous across four longitude quadrants, so unlike the airborne
48
+ * pair decode, a reference position is required to resolve the correct
49
+ * hemisphere and quadrant - the receiver's own location is normally used.
50
+ *
51
+ * @param even - Raw CPR fields from the even-format frame.
52
+ * @param odd - Raw CPR fields from the odd-format frame.
53
+ * @param newerFormat - Which frame is more recent.
54
+ * @param reference - A position known to be within about 45 NM of the true position (typically the receiver's location).
55
+ * @returns The resolved position, or undefined if the two frames fall in different latitude zones.
56
+ */
57
+ export declare function decodeSurfaceCprPair(even: CprPosition, odd: CprPosition, newerFormat: 'even' | 'odd', reference: CprReference): Position | undefined;
58
+ /**
59
+ * Resolves an absolute position from a single surface CPR frame plus a
60
+ * known nearby reference position, per DO-260B SS A.1.7.6. The reference
61
+ * must be within roughly 45 NM of the true position - typically the
62
+ * receiver's own location, since surface traffic is by definition close to
63
+ * an airport the receiver can see.
64
+ *
65
+ * @param format - Whether `frame` is an even (F=0) or odd (F=1) format frame.
66
+ * @param frame - Raw CPR fields from the frame.
67
+ * @param reference - A position known to be near the frame's true position.
68
+ * @returns The resolved position.
69
+ */
70
+ export declare function decodeSurfaceCprWithReference(format: 'even' | 'odd', frame: CprPosition, reference: CprReference): Position;
71
+ //# sourceMappingURL=cpr.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cpr.d.ts","sourceRoot":"","sources":["../src/cpr.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAE9C,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAuClE;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAU3D;AA6DD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,WAAW,EACjB,GAAG,EAAE,WAAW,EAChB,WAAW,EAAE,MAAM,GAAG,KAAK,GAC1B,QAAQ,GAAG,SAAS,CAuCtB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,8BAA8B,CAC5C,MAAM,EAAE,MAAM,GAAG,KAAK,EACtB,KAAK,EAAE,WAAW,EAClB,SAAS,EAAE,YAAY,GACtB,QAAQ,CAEV;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,WAAW,EACjB,GAAG,EAAE,WAAW,EAChB,WAAW,EAAE,MAAM,GAAG,KAAK,EAC3B,SAAS,EAAE,YAAY,GACtB,QAAQ,GAAG,SAAS,CA4CtB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,6BAA6B,CAC3C,MAAM,EAAE,MAAM,GAAG,KAAK,EACtB,KAAK,EAAE,WAAW,EAClB,SAAS,EAAE,YAAY,GACtB,QAAQ,CAEV"}
package/dist/cpr.js ADDED
@@ -0,0 +1,220 @@
1
+ /** Raw CPR fields are 17-bit, normalized to [0, 1) by dividing by 2^17. */
2
+ const CPR_DENOMINATOR = 131072;
3
+ /**
4
+ * Latitude boundaries (absolute degrees, ascending) where the number of CPR
5
+ * longitude zones (NL) steps down by one. Entry `i` is the latitude at
6
+ * which NL transitions from `59 - i` to `59 - i - 1`. Derived once from the
7
+ * closed-form NL(lat) trigonometric expression in ICAO Annex 10 Vol. IV /
8
+ * RTCA DO-260B SS A.1.7.2 with the fixed `nz = 15` zone count - stable
9
+ * across releases since the formula has no other free parameters. A static
10
+ * table avoids repeated trig evaluation on every position decode.
11
+ */
12
+ const NL_BOUNDARIES = [
13
+ 10.47047129996848, 14.828174368686794, 18.186263570713354, 21.029394926028463, 23.545044865570706,
14
+ 25.829247070587755, 27.938987101219045, 29.911356857318083, 31.77209707681077, 33.53993436298484,
15
+ 35.22899597796385, 36.85025107593526, 38.41241892412256, 39.922566843338615, 41.38651832260239,
16
+ 42.80914012243555, 44.194549514192744, 45.546267226602346, 46.867332524987454, 48.160391280966216,
17
+ 49.42776439255687, 50.67150165553835, 51.893424691687684, 53.09516152796003, 54.278174722729,
18
+ 55.44378444495043, 56.59318756205918, 57.72747353866114, 58.84763776148457, 59.954592766940294,
19
+ 61.04917774246351, 62.13216659210329, 63.20427479381928, 64.2661652256744, 65.31845309682089,
20
+ 66.36171008382617, 67.39646774084667, 68.4232202208333, 69.44242631144024, 70.454510749876,
21
+ 71.45986473028982, 72.45884544728945, 73.45177441667865, 74.43893415725137, 75.42056256653356,
22
+ 76.39684390794469, 77.36789461328188, 78.33374082922747, 79.29428225456925, 80.24923213280512,
23
+ 81.19801349271948, 82.13956980510606, 83.07199444719814, 83.99173562980565, 84.89166190702085,
24
+ 85.75541620944418, 86.535369975121, 87.0,
25
+ ];
26
+ /** True modulo (JS `%` can return a negative result; CPR math requires the mathematical modulo). */
27
+ function mod(a, b) {
28
+ return ((a % b) + b) % b;
29
+ }
30
+ /** Wraps a longitude in degrees into the canonical [-180, 180) range. Longitude is circular, so a raw interpolated value outside that range (e.g. 180.5) represents a valid position (-179.5), not an error. */
31
+ function normalizeLongitudeDeg(lonDeg) {
32
+ return mod(lonDeg + 180, 360) - 180;
33
+ }
34
+ /**
35
+ * Returns the number of CPR longitude zones (NL) at a given latitude, per
36
+ * ICAO Annex 10 Vol. IV / RTCA DO-260B SS A.1.7.2. NL is 1-59, monotone
37
+ * non-increasing in absolute latitude: 59 at the equator, 2 at +-87 deg, 1
38
+ * beyond +-87 deg.
39
+ *
40
+ * @param latDeg - Latitude in decimal degrees.
41
+ * @returns The number of longitude zones, 1-59.
42
+ */
43
+ export function cprNumLongitudeZones(latDeg) {
44
+ const absLat = Math.abs(latDeg);
45
+ if (absLat > 87) {
46
+ return 1;
47
+ }
48
+ if (absLat === 87) {
49
+ return 2;
50
+ }
51
+ const idx = NL_BOUNDARIES.findIndex((boundary) => boundary > absLat);
52
+ return 59 - idx;
53
+ }
54
+ /**
55
+ * Shared core of the even/odd pair decoders: checks the two frames land in
56
+ * consistent NL zones, then interpolates the longitude fraction within
57
+ * `latSpanDeg` (360 for airborne, 90 for surface). Both pair decoders
58
+ * resolve latitude differently before calling this (surface needs an extra
59
+ * hemisphere disambiguation step airborne doesn't), but the zone-check and
60
+ * longitude interpolation that follows is identical modulo the span.
61
+ */
62
+ function resolveCprPairLongitude(latSpanDeg, latEven, latOdd, cprLonEven, cprLonOdd, newerFormat) {
63
+ const nlEven = cprNumLongitudeZones(latEven);
64
+ const nlOdd = cprNumLongitudeZones(latOdd);
65
+ if (nlEven !== nlOdd) {
66
+ return undefined;
67
+ }
68
+ const lat = newerFormat === 'even' ? latEven : latOdd;
69
+ const nl = newerFormat === 'even' ? nlEven : nlOdd;
70
+ const ni = newerFormat === 'even' ? Math.max(nl, 1) : Math.max(nl - 1, 1);
71
+ const m = Math.floor(cprLonEven * (nl - 1) - cprLonOdd * nl + 0.5);
72
+ const cprLonForFormat = newerFormat === 'even' ? cprLonEven : cprLonOdd;
73
+ const lonFraction = (latSpanDeg / ni) * (mod(m, ni) + cprLonForFormat);
74
+ return { lat, lonFraction };
75
+ }
76
+ /**
77
+ * Shared core of the single-frame-plus-reference decoders: resolves
78
+ * latitude and longitude within `latSpanDeg` (360 for airborne, 90 for
79
+ * surface) against a known nearby reference position.
80
+ */
81
+ function decodeCprWithReference(latSpanDeg, format, frame, reference) {
82
+ const cprLat = frame.latCpr / CPR_DENOMINATOR;
83
+ const cprLon = frame.lonCpr / CPR_DENOMINATOR;
84
+ const dLat = format === 'odd' ? latSpanDeg / 59 : latSpanDeg / 60;
85
+ const j = Math.floor(0.5 + reference.lat / dLat - cprLat);
86
+ const lat = dLat * (j + cprLat);
87
+ const ni = cprNumLongitudeZones(lat) - (format === 'odd' ? 1 : 0);
88
+ const dLon = ni > 0 ? latSpanDeg / ni : latSpanDeg;
89
+ const m = Math.floor(0.5 + reference.lon / dLon - cprLon);
90
+ const lon = normalizeLongitudeDeg(dLon * (m + cprLon));
91
+ return { lat, lon };
92
+ }
93
+ /**
94
+ * Resolves an absolute position from a paired even and odd airborne CPR
95
+ * frame (ADS-B BDS 0,5 / 2,0 - type codes 9-18 and 20-22), per DO-260B SS
96
+ * A.1.7.3. The two frames must be close enough in time that the aircraft
97
+ * could not have crossed a latitude zone boundary - in practice, no more
98
+ * than about 10 seconds apart, since airborne position is broadcast at
99
+ * roughly 2 Hz. Enforcing that window is the caller's responsibility; this
100
+ * function only performs a zone-consistency check as a lightweight guard,
101
+ * which cannot by itself detect a same-zone false positive from a pair
102
+ * spanning too wide a gap.
103
+ *
104
+ * @param even - Raw CPR fields from the even-format (F=0) frame.
105
+ * @param odd - Raw CPR fields from the odd-format (F=1) frame.
106
+ * @param newerFormat - Which frame is more recent - determines which frame's latitude zone the result is reported in.
107
+ * @returns The resolved position, or undefined if the two frames fall in different latitude zones (the pair cannot be combined) or the result is physically impossible.
108
+ */
109
+ export function decodeAirborneCprPair(even, odd, newerFormat) {
110
+ const cprLatEven = even.latCpr / CPR_DENOMINATOR;
111
+ const cprLonEven = even.lonCpr / CPR_DENOMINATOR;
112
+ const cprLatOdd = odd.latCpr / CPR_DENOMINATOR;
113
+ const cprLonOdd = odd.lonCpr / CPR_DENOMINATOR;
114
+ const j = Math.floor(59 * cprLatEven - 60 * cprLatOdd + 0.5);
115
+ let latEven = (360 / 60) * (mod(j, 60) + cprLatEven);
116
+ let latOdd = (360 / 59) * (mod(j, 59) + cprLatOdd);
117
+ if (latEven >= 270) {
118
+ latEven -= 360;
119
+ }
120
+ if (latOdd >= 270) {
121
+ latOdd -= 360;
122
+ }
123
+ const resolved = resolveCprPairLongitude(360, latEven, latOdd, cprLonEven, cprLonOdd, newerFormat);
124
+ if (resolved === undefined) {
125
+ return undefined;
126
+ }
127
+ // A frame pair that passes the zone-consistency check above but still
128
+ // produces an out-of-range latitude is a straddling-boundary false
129
+ // positive - reject rather than report a physically impossible
130
+ // position. Longitude has no equivalent failure mode: the interpolation
131
+ // formula is bounded to one span by construction, and
132
+ // normalizeLongitudeDeg always yields a valid [-180, 180) result.
133
+ if (Math.abs(resolved.lat) > 90) {
134
+ return undefined;
135
+ }
136
+ return { lat: resolved.lat, lon: normalizeLongitudeDeg(resolved.lonFraction) };
137
+ }
138
+ /**
139
+ * Resolves an absolute position from a single airborne CPR frame plus a
140
+ * known nearby reference position, per DO-260B SS A.1.7.5. The reference
141
+ * must be within roughly 180 NM of the true position - typically the
142
+ * aircraft's own last known position, or the receiver's location as a
143
+ * fallback for the first message from a new aircraft.
144
+ *
145
+ * @param format - Whether `frame` is an even (F=0) or odd (F=1) format frame.
146
+ * @param frame - Raw CPR fields from the frame.
147
+ * @param reference - A position known to be near the frame's true position.
148
+ * @returns The resolved position.
149
+ */
150
+ export function decodeAirborneCprWithReference(format, frame, reference) {
151
+ return decodeCprWithReference(360, format, frame, reference);
152
+ }
153
+ /**
154
+ * Resolves an absolute position from a paired even and odd surface CPR
155
+ * frame (ADS-B BDS 0,6 - type codes 5-8), per DO-260B SS A.1.7.4. Surface
156
+ * CPR spans a 90 degree latitude zone (versus 360 degree for airborne) and
157
+ * is ambiguous across four longitude quadrants, so unlike the airborne
158
+ * pair decode, a reference position is required to resolve the correct
159
+ * hemisphere and quadrant - the receiver's own location is normally used.
160
+ *
161
+ * @param even - Raw CPR fields from the even-format frame.
162
+ * @param odd - Raw CPR fields from the odd-format frame.
163
+ * @param newerFormat - Which frame is more recent.
164
+ * @param reference - A position known to be within about 45 NM of the true position (typically the receiver's location).
165
+ * @returns The resolved position, or undefined if the two frames fall in different latitude zones.
166
+ */
167
+ export function decodeSurfaceCprPair(even, odd, newerFormat, reference) {
168
+ const cprLatEven = even.latCpr / CPR_DENOMINATOR;
169
+ const cprLonEven = even.lonCpr / CPR_DENOMINATOR;
170
+ const cprLatOdd = odd.latCpr / CPR_DENOMINATOR;
171
+ const cprLonOdd = odd.lonCpr / CPR_DENOMINATOR;
172
+ const j = Math.floor(59 * cprLatEven - 60 * cprLatOdd + 0.5);
173
+ const latEvenNorth = (90 / 60) * (mod(j, 60) + cprLatEven);
174
+ const latOddNorth = (90 / 59) * (mod(j, 59) + cprLatOdd);
175
+ const latEvenSouth = latEvenNorth - 90;
176
+ const latOddSouth = latOddNorth - 90;
177
+ // Surface CPR is ambiguous over two 90 degree latitude zones (north and
178
+ // south of the equator) - resolve using whichever zone's newer-frame
179
+ // latitude lands closer to the reference, rather than the reference's own
180
+ // hemisphere (a receiver and aircraft can be on opposite sides of the
181
+ // equator while still satisfying the 45 NM limit).
182
+ const newerLatNorth = newerFormat === 'even' ? latEvenNorth : latOddNorth;
183
+ const newerLatSouth = newerFormat === 'even' ? latEvenSouth : latOddSouth;
184
+ const useNorth = Math.abs(reference.lat - newerLatNorth) <= Math.abs(reference.lat - newerLatSouth);
185
+ const latEven = useNorth ? latEvenNorth : latEvenSouth;
186
+ const latOdd = useNorth ? latOddNorth : latOddSouth;
187
+ const resolved = resolveCprPairLongitude(90, latEven, latOdd, cprLonEven, cprLonOdd, newerFormat);
188
+ if (resolved === undefined) {
189
+ return undefined;
190
+ }
191
+ // Surface longitude only narrows to one of four 90 degree quadrants -
192
+ // pick whichever wraps closest to the reference, treating longitude as
193
+ // circular (near the date line, -180 is one degree from +179, not 359).
194
+ let bestLon = resolved.lonFraction;
195
+ let bestLonDiff = Number.POSITIVE_INFINITY;
196
+ for (const quadrantOffset of [0, 90, 180, 270]) {
197
+ const candidateLon = normalizeLongitudeDeg(resolved.lonFraction + quadrantOffset);
198
+ const diff = Math.abs(normalizeLongitudeDeg(candidateLon - reference.lon));
199
+ if (diff < bestLonDiff) {
200
+ bestLonDiff = diff;
201
+ bestLon = candidateLon;
202
+ }
203
+ }
204
+ return { lat: resolved.lat, lon: bestLon };
205
+ }
206
+ /**
207
+ * Resolves an absolute position from a single surface CPR frame plus a
208
+ * known nearby reference position, per DO-260B SS A.1.7.6. The reference
209
+ * must be within roughly 45 NM of the true position - typically the
210
+ * receiver's own location, since surface traffic is by definition close to
211
+ * an airport the receiver can see.
212
+ *
213
+ * @param format - Whether `frame` is an even (F=0) or odd (F=1) format frame.
214
+ * @param frame - Raw CPR fields from the frame.
215
+ * @param reference - A position known to be near the frame's true position.
216
+ * @returns The resolved position.
217
+ */
218
+ export function decodeSurfaceCprWithReference(format, frame, reference) {
219
+ return decodeCprWithReference(90, format, frame, reference);
220
+ }
@@ -0,0 +1,10 @@
1
+ import type { EmergencyState } from './types/index.js';
2
+ /**
3
+ * Decodes the 3-bit emergency state field of an ADS-B aircraft status
4
+ * message (BDS 6,1 subtype 1, type code 28).
5
+ *
6
+ * @param rawState - The raw 3-bit field, 0-7.
7
+ * @returns The decoded emergency state.
8
+ */
9
+ export declare function decodeEmergencyState(rawState: number): EmergencyState;
10
+ //# sourceMappingURL=emergency-status.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"emergency-status.d.ts","sourceRoot":"","sources":["../src/emergency-status.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAcvD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,cAAc,CAErE"}
@@ -0,0 +1,21 @@
1
+ /** 3-bit emergency state field values, per ICAO Annex 10 Vol. IV / RTCA DO-260. */
2
+ const EMERGENCY_STATES = [
3
+ 'none',
4
+ 'general',
5
+ 'lifeguardMedical',
6
+ 'minimumFuel',
7
+ 'noCommunications',
8
+ 'unlawfulInterference',
9
+ 'downed',
10
+ 'reserved',
11
+ ];
12
+ /**
13
+ * Decodes the 3-bit emergency state field of an ADS-B aircraft status
14
+ * message (BDS 6,1 subtype 1, type code 28).
15
+ *
16
+ * @param rawState - The raw 3-bit field, 0-7.
17
+ * @returns The decoded emergency state.
18
+ */
19
+ export function decodeEmergencyState(rawState) {
20
+ return EMERGENCY_STATES[rawState] ?? 'reserved';
21
+ }
@@ -0,0 +1,42 @@
1
+ import type { ModeSMessageEnvelope } from './types/index.js';
2
+ /**
3
+ * Extracts the downlink format from a Mode-S message's first 5 bits.
4
+ *
5
+ * @param bytes - Raw message bytes (7 or 14 bytes).
6
+ * @returns The raw downlink format value, 0-31.
7
+ */
8
+ export declare function extractDownlinkFormat(bytes: Uint8Array): number;
9
+ /**
10
+ * Computes the 24-bit Mode-S CRC remainder over an entire message (all
11
+ * bytes, including the trailing 24-bit parity field), via the standard
12
+ * bit-serial polynomial division used to check or recover Mode-S CRCs.
13
+ *
14
+ * See {@link ModeSMessageEnvelope.crcRemainder} for how to interpret the
15
+ * result - it is a pass/fail check for squitter formats (DF11/17/18) but a
16
+ * recovered ICAO address for surveillance-reply formats.
17
+ *
18
+ * @param bytes - Raw message bytes (7 or 14 bytes), parity field included.
19
+ * @returns The 24-bit CRC remainder.
20
+ */
21
+ export declare function computeCrc24(bytes: Uint8Array): number;
22
+ /**
23
+ * Parses the shared envelope (downlink format, CRC remainder) out of a raw
24
+ * Mode-S message. {@link decodeModeSMessage} uses this to decide how to
25
+ * route a message before calling the per-type decoders - those decoders
26
+ * take a raw ME field or an already-extracted field value, not the
27
+ * envelope itself.
28
+ *
29
+ * ```typescript
30
+ * import { parseModeSFrame } from '@squawk/mode-s';
31
+ *
32
+ * const envelope = parseModeSFrame(rawMessageBytes);
33
+ * if (envelope.downlinkFormat === 17 && envelope.crcRemainder === 0) {
34
+ * // unmodified DF17 extended squitter, safe to decode further
35
+ * }
36
+ * ```
37
+ *
38
+ * @param bytes - Raw message bytes (7 or 14 bytes).
39
+ * @returns The parsed envelope.
40
+ */
41
+ export declare function parseModeSFrame(bytes: Uint8Array): ModeSMessageEnvelope;
42
+ //# sourceMappingURL=frame.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"frame.d.ts","sourceRoot":"","sources":["../src/frame.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAS7D;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAG/D;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAatD;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,UAAU,GAAG,oBAAoB,CAMvE"}