From d28adf46cf980bbb2ce4a05c339a430bd154c2db Mon Sep 17 00:00:00 2001 From: Datalink Pro <84617984+WX4JCW@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:51:22 -0400 Subject: [PATCH 1/4] feat(plugins): decode the Southwest H1 position report Southwest 737s send a POS report with 21 fields: the short variant's fields with the distance to the next waypoint added after the next waypoint, then true and indicated airspeed, gross weight, fuel on board, origin, destination, destination runway, fuel at destination, ETA and distance to destination. Its /PR carries the cruise altitude, the temperature at cruise altitude and the transition altitude. The ARINC 702 helper read these by the short variant's positions, so everything after the next waypoint was shifted (the distance became the ETA), and /PR's cruise altitude and temperature came out as a second altitude and outside air temperature. Tested on 3,520 reports from 80 tails, 2025-11 to 2026-10. Co-Authored-By: Claude Opus 5.5 --- lib/plugins/Label_H1_POS.test.ts | 87 +++++++++++++ lib/utils/arinc_702_helper.ts | 208 ++++++++++++++++++++++++++++++- 2 files changed, 294 insertions(+), 1 deletion(-) diff --git a/lib/plugins/Label_H1_POS.test.ts b/lib/plugins/Label_H1_POS.test.ts index b0dc7312..bd56f669 100644 --- a/lib/plugins/Label_H1_POS.test.ts +++ b/lib/plugins/Label_H1_POS.test.ts @@ -424,6 +424,93 @@ describe('Label_H1 POS', () => { ); }); + test('decodes Southwest variant', () => { + // WN1793 KPIT-KLAS in the climb + message.text = + 'POSN40281W080542,DILNE,021507,158,TVTRN,56,022414,ROD,M8,276031,366K,290K,1594,370,KPIT,KLAS,,182,062759,1633,73/PR1594,238,340,370,,44,9,287060,M51,180,P0,P0/RI:DA:KPIT:AA:KLAS..TVTRN..ROD..DONVE..SPI..AGENT..KK57E..DUUZE..KD51W..FLOOD..KD48S..TYEGR:A:CHOWW4.TYEGR:F:WOLPH48AF'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.formatted.description).toBe('Position Report'); + expect(decodeResult.raw.position?.latitude).toBeCloseTo(40.468, 3); + expect(decodeResult.raw.position?.longitude).toBeCloseTo(-80.903, 3); + expect(decodeResult.raw.altitude).toBe(15800); + expect(decodeResult.formatted.items[2].label).toBe('Aircraft Route'); + expect(decodeResult.formatted.items[2].value).toBe( + 'DILNE@02:15:07 > TVTRN@02:24:14 > ROD', + ); + expect(decodeResult.raw.next_waypoint_distance).toBe(56); + expect(decodeResult.raw.outside_air_temperature).toBe(-8); + expect(decodeResult.raw.wind_direction).toBe(276); + expect(decodeResult.raw.wind_speed).toBe(31); + expect(decodeResult.raw.airspeed).toBe(366); + expect(decodeResult.raw.indicated_airspeed).toBe(290); + expect(decodeResult.raw.gross_weight).toBe(159400); + expect(decodeResult.raw.fuel_on_board).toBe(37000); + expect(decodeResult.raw.departure_icao).toBe('KPIT'); + expect(decodeResult.formatted.items[11].label).toBe('Destination'); + expect(decodeResult.formatted.items[11].value).toBe('KLAS'); + expect(decodeResult.raw.arrival_runway).toBeUndefined(); + expect(decodeResult.raw.destination_fuel).toBe(18200); + expect(decodeResult.raw.eta_time).toBe(6 * 3600 + 27 * 60 + 59); + expect(decodeResult.raw.destination_distance).toBe(1633); + expect(decodeResult.raw.cruise_altitude).toBe(34000); + expect(decodeResult.raw.cruise_temperature).toBe(-51); + expect(decodeResult.raw.transition_altitude).toBe(18000); + expect(decodeResult.remaining.text).toBe('73/PR238,,44,9,287060,P0,P0'); + }); + + test('decodes Southwest variant with a destination runway', () => { + message.text = + 'POSN41167W082339,AYYLE,123109,191,BRNIN,3,123139,GTLKE,M15,270024,423K,319K,1271,138,KCLE,KMDW,31R,103,131033,240,73/PR1271,222,260,138,,63,80,275023,M32,180,P0,P0/RI:DA:KCLE:AA:KMDW(31R):D:GTLKE6.BAGEL:F:BAGEL:A:PANGG7.BAGEL:F:HILLS:AP:ILS 31R.HILLS:F:EON8E85'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.formatted.items[12].label).toBe('Arrival Runway'); + expect(decodeResult.formatted.items[12].value).toBe('31R'); + expect(decodeResult.raw.next_waypoint_distance).toBe(3); + expect(decodeResult.raw.gross_weight).toBe(127100); + expect(decodeResult.raw.cruise_altitude).toBe(26000); + expect(decodeResult.remaining.text).toBe('73/PR222,,63,80,275023,P0,P0'); + }); + + test('decodes Southwest variant on a vectors leg', () => { + // no distance or ETA to the next waypoint + message.text = + 'POSN30372W097531,RW18R,164804,166,VECTOR,,,WLMRT,M2,182016,389K,300K,1347,205,KAUS,KDEN,,133,182028,2,73/PR1347,222,400,205,,80,41,076016,M55,180,P0,P0/WQ400:ABI.DOGIN.ZIGEE71E2'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.formatted.items[2].value).toBe( + 'RW18R@16:48:04 > VECTOR > WLMRT', + ); + expect(decodeResult.raw.next_waypoint_distance).toBeUndefined(); + expect(decodeResult.raw.airspeed).toBe(389); + expect(decodeResult.raw.destination_fuel).toBe(13300); + expect(decodeResult.remaining.text).toBe('73/PR222,,80,41,076016,P0,P0'); + }); + + test('decodes Southwest variant with blank fields', () => { + // on the ground: no last waypoint, wind, gross weight, ETA or distance to go + message.text = + 'POSN42218W071010,,111128,0,520,0,,NHANT,P17,,100K,045K,,150,KBOS,KBWI,33L,,,,73/PR,222,,150,,,,,,180,P0,P0/RI:DA:KBOS:AA:KBWI:R:04R(33L):D:SSOXS7:F:SSOXS..DISCO..SSOXS.Q167.ZIZZI..KNUKK..ATR..LAFLN:A:MIIDY4.LAFLN:F:JANNS:AP:ILS 33L.JANNS:F:YANNIACF9'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.raw.altitude).toBe(0); + expect(decodeResult.raw.wind_speed).toBeUndefined(); + expect(decodeResult.raw.gross_weight).toBeUndefined(); + expect(decodeResult.raw.fuel_on_board).toBe(15000); + expect(decodeResult.raw.destination_fuel).toBeUndefined(); + expect(decodeResult.raw.cruise_altitude).toBeUndefined(); + expect(decodeResult.raw.transition_altitude).toBe(18000); + expect(decodeResult.remaining.text).toBe('73/PR,222,,,,,,,P0,P0'); + }); + test('decodes Label H1 Preamble #M1BPOS ', () => { message.text = '#M1BPOS Bogus message'; const decodeResult = plugin.decode(message); diff --git a/lib/utils/arinc_702_helper.ts b/lib/utils/arinc_702_helper.ts index 1c645201..ec33581b 100644 --- a/lib/utils/arinc_702_helper.ts +++ b/lib/utils/arinc_702_helper.ts @@ -54,6 +54,8 @@ export class Arinc702Helper { return false; } const fields = data.split('/'); + // the Southwest position report has its own /PR layout + const southwest = isSouthwestPosition(fields[0]); const canDecode = parseMessageType(decodeResult, fields[0]); if (!canDecode) { decodeResult.decoded = false; @@ -116,7 +118,11 @@ export class Arinc702Helper { processMessageReference(decodeResult, data.split(',')); break; case 'PR': - processPerformanceData(decodeResult, data.split(',')); + if (southwest) { + processSouthwestPerformanceData(decodeResult, data.split(',')); + } else { + processPerformanceData(decodeResult, data.split(',')); + } break; //case 'PR': // TODO: decode /PR fields @@ -208,6 +214,10 @@ export class Arinc702Helper { if (position) { ResultFormatter.position(decodeResult, position); } + if (data.length === SOUTHWEST_POSITION_FIELDS) { + processSouthwestPosition(decodeResult, data); + return; + } if (data.length >= 10) { // variant 1, short ResultFormatter.altitude(decodeResult, Number(data[3]) * 100); @@ -542,6 +552,202 @@ function processPerformanceData(decodeResult: DecodeResult, data: string[]) { } } +/** + * Southwest 737 position report: 21 fields, tested on 3,520 reports (2025-11 to 2026-10). No + * other airline's POS report on disk has 21 fields. + * + * 0 position, 1 last waypoint, 2 time over it, 3 altitude (hundreds of feet), 4 next waypoint, + * 5 distance to it (nm), 6 ETA there, 7 the waypoint after, 8 outside air temperature, 9 wind + * (DDDSSS), 10 true airspeed, 11 indicated airspeed, 12 gross weight and 13 fuel on board + * (hundreds of pounds), 14 origin, 15 destination, 16 destination runway, 17 fuel at + * destination (hundreds of pounds), 18 ETA at destination, 19 distance to destination (nm), + * 20 unknown (always 73). + */ +const SOUTHWEST_POSITION_FIELDS = 21; +const SOUTHWEST_TIME = /^([01]\d|2[0-3])[0-5]\d[0-5]\d$/; + +function isSouthwestPosition(messagePart: string): boolean { + return ( + messagePart.startsWith('POS') && + messagePart.substring(3).split(',').length === SOUTHWEST_POSITION_FIELDS + ); +} + +/** Matches a field; a blank field is skipped and one that does not match is unknown. */ +function southwestField(value: string, pattern: RegExp, unknown: string[]) { + if (value === '') { + return undefined; + } + const m = value.match(pattern); + if (!m) { + unknown.push(value); + return undefined; + } + return m; +} + +function processSouthwestPosition(decodeResult: DecodeResult, data: string[]) { + const unknown: string[] = []; + const field = (i: number, pattern: RegExp) => + southwestField(data[i], pattern, unknown); + + const altitude = field(3, /^\d+$/); + if (altitude) { + ResultFormatter.altitude(decodeResult, Number(altitude[0]) * 100); + } + // distance and ETA are blank on a vectors leg + processRoute( + decodeResult, + data[1], + field(2, SOUTHWEST_TIME)?.[0] ?? '', + data[4], + field(6, SOUTHWEST_TIME)?.[0] ?? '', + data[7], + ); + // nm; time to the next waypoint x true airspeed agrees on 97.1% of 3,433 reports + const next = field(5, /^\d+$/); + if (next) { + ResultFormatter.raw( + decodeResult, + 'next_waypoint_distance', + 'NEXT_DIST', + 'Distance to Next Waypoint', + `${Number(next[0])} nm`, + Number(next[0]), + ); + } + if (field(8, /^[MP]\d+$/)) { + ResultFormatter.temperature(decodeResult, data[8]); + } + const wind = field(9, /^([0-2]\d\d|3[0-5]\d|360)(\d{3})$/); + if (wind) { + ResultFormatter.wind(decodeResult, Number(wind[1]), Number(wind[2])); + } + // the true airspeed worked out from field 11, the altitude and the temperature is within + // 5 kt of field 10 on 95.1% of 3,448 reports + const tas = field(10, /^(\d+)K$/); + if (tas) { + ResultFormatter.airspeed(decodeResult, Number(tas[1])); + } + const ias = field(11, /^(\d+)K$/); + if (ias) { + ResultFormatter.indicatedAirspeed(decodeResult, Number(ias[1])); + } + // gross weight less fuel on board stays under each 737 type's maximum zero fuel weight + const weight = field(12, /^\d+$/); + if (weight) { + ResultFormatter.grossWeight(decodeResult, Number(weight[0]) * 100); + } + const fuel = field(13, /^\d+$/); + if (fuel) { + ResultFormatter.currentFuel(decodeResult, Number(fuel[0]) * 100); + } + if (field(14, /^[A-Z]{4}$/)) { + ResultFormatter.departureAirport(decodeResult, data[14]); + } + if (field(15, /^[A-Z]{4}$/)) { + ResultFormatter.arrivalAirport(decodeResult, data[15]); + } + // blank until a runway is entered; then the same as the route's (1,170 of 1,170) + if (field(16, /^\d{1,2}[A-Z]?$/)) { + ResultFormatter.arrivalRunway(decodeResult, data[16]); + } + // fuel on board less this, over the distance to go, is 10.5 to 12.1 lb a nm (median by type) + const destinationFuel = field(17, /^\d+$/); + if (destinationFuel) { + ResultFormatter.raw( + decodeResult, + 'destination_fuel', + 'DEST_FUEL', + 'Fuel at Destination', + `${Number(destinationFuel[0]) * 100}`, + Number(destinationFuel[0]) * 100, + ); + } + const eta = field(18, SOUTHWEST_TIME); + if (eta) { + ResultFormatter.eta(decodeResult, DateTimeUtils.convertHHMMSSToTod(eta[0])); + } + const distance = field(19, /^\d+$/); + if (distance) { + ResultFormatter.raw( + decodeResult, + 'destination_distance', + 'DEST_DIST', + 'Distance to Destination', + `${Number(distance[0])} nm`, + Number(distance[0]), + ); + } + if (data[20] !== '') { + unknown.push(data[20]); + } + if (unknown.length > 0) { + ResultFormatter.unknownArr(decodeResult, unknown); + } +} + +/** + * /PR of the Southwest position report, 12 fields: 0 gross weight and 3 fuel on board (the + * same as position fields 12 and 13 on 3,449 of 3,450 reports, so not shown again), 2 cruise + * altitude (hundreds of feet), 8 temperature at cruise altitude (ISA +4 C median), 9 transition + * altitude (hundreds of feet: 180 from US airports, 185 only from Cancun, where it is 18,500 + * feet, 170 only from Punta Cana). The rest is unknown: 1 is set by 737 type (222 -700, + * 262 -800, 238 MAX), 4 is blank. + */ +function processSouthwestPerformanceData( + decodeResult: DecodeResult, + data: string[], +) { + const unknown: string[] = []; + data.forEach((value, i) => { + if (i === 0 && Number(value) * 100 === decodeResult.raw.gross_weight) { + return; + } + if (i === 3 && Number(value) * 100 === decodeResult.raw.fuel_on_board) { + return; + } + if (i === 2 && /^\d+$/.test(value)) { + ResultFormatter.raw( + decodeResult, + 'cruise_altitude', + 'CRZ_ALT', + 'Cruise Altitude', + `${Number(value) * 100} feet`, + Number(value) * 100, + ); + return; + } + if (i === 8 && /^[MP]\d+$/.test(value)) { + const temperature = Number(value.replace('M', '-').replace('P', '+')); + ResultFormatter.raw( + decodeResult, + 'cruise_temperature', + 'CRZ_TEMP', + 'Cruise Temperature (C)', + `${temperature} degrees`, + temperature, + ); + return; + } + if (i === 9 && /^\d+$/.test(value)) { + ResultFormatter.raw( + decodeResult, + 'transition_altitude', + 'TRANS_ALT', + 'Transition Altitude', + `${Number(value) * 100} feet`, + Number(value) * 100, + ); + return; + } + unknown.push(value); + }); + if (unknown.some((v) => v !== '')) { + ResultFormatter.unknown(decodeResult, unknown.join(','), '/PR'); + } +} + function processDateCode(decodeResult: DecodeResult, data: string[]) { if (data.length === 1) { // noop? From 59fc61d3a6c2c256887898f8c2d3473a1d54e57f Mon Sep 17 00:00:00 2001 From: Datalink Pro <84617984+WX4JCW@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:04:51 -0400 Subject: [PATCH 2/4] fix(plugins): decode the Southwest /PR only with its 12 fields, per review Any other field count goes to the remaining text instead of being read by position. All 3,450 Southwest /PR sections seen have 12 fields. Co-Authored-By: Claude Opus 5.5 --- lib/plugins/Label_H1_POS.test.ts | 16 ++++++++++++++++ lib/utils/arinc_702_helper.ts | 9 ++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/plugins/Label_H1_POS.test.ts b/lib/plugins/Label_H1_POS.test.ts index bd56f669..f29b6c45 100644 --- a/lib/plugins/Label_H1_POS.test.ts +++ b/lib/plugins/Label_H1_POS.test.ts @@ -511,6 +511,22 @@ describe('Label_H1 POS', () => { expect(decodeResult.remaining.text).toBe('73/PR,222,,,,,,,P0,P0'); }); + test('leaves a Southwest /PR without 12 fields undecoded', () => { + // WN1793 with the last /PR field removed and the checksum redone + message.text = + 'POSN40281W080542,DILNE,021507,158,TVTRN,56,022414,ROD,M8,276031,366K,290K,1594,370,KPIT,KLAS,,182,062759,1633,73/PR1594,238,340,370,,44,9,287060,M51,180,P0/RI:DA:KPIT:AA:KLAS..TVTRN..ROD..DONVE..SPI..AGENT..KK57E..DUUZE..KD51W..FLOOD..KD48S..TYEGR:A:CHOWW4.TYEGR:F:WOLPH167E'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.raw.next_waypoint_distance).toBe(56); + expect(decodeResult.raw.cruise_altitude).toBeUndefined(); + expect(decodeResult.raw.transition_altitude).toBeUndefined(); + expect(decodeResult.remaining.text).toBe( + '73/PR1594,238,340,370,,44,9,287060,M51,180,P0', + ); + }); + test('decodes Label H1 Preamble #M1BPOS ', () => { message.text = '#M1BPOS Bogus message'; const decodeResult = plugin.decode(message); diff --git a/lib/utils/arinc_702_helper.ts b/lib/utils/arinc_702_helper.ts index ec33581b..b215581b 100644 --- a/lib/utils/arinc_702_helper.ts +++ b/lib/utils/arinc_702_helper.ts @@ -687,8 +687,11 @@ function processSouthwestPosition(decodeResult: DecodeResult, data: string[]) { } } +const SOUTHWEST_PERFORMANCE_FIELDS = 12; + /** - * /PR of the Southwest position report, 12 fields: 0 gross weight and 3 fuel on board (the + * /PR of the Southwest position report, 12 fields (3,450 of 3,450 reports; any other count is + * left undecoded): 0 gross weight and 3 fuel on board (the * same as position fields 12 and 13 on 3,449 of 3,450 reports, so not shown again), 2 cruise * altitude (hundreds of feet), 8 temperature at cruise altitude (ISA +4 C median), 9 transition * altitude (hundreds of feet: 180 from US airports, 185 only from Cancun, where it is 18,500 @@ -699,6 +702,10 @@ function processSouthwestPerformanceData( decodeResult: DecodeResult, data: string[], ) { + if (data.length !== SOUTHWEST_PERFORMANCE_FIELDS) { + ResultFormatter.unknown(decodeResult, data.join(','), '/PR'); + return; + } const unknown: string[] = []; data.forEach((value, i) => { if (i === 0 && Number(value) * 100 === decodeResult.raw.gross_weight) { From 8f1ba4c97e395da6ccf06850ba598f8613cef895 Mon Sep 17 00:00:00 2001 From: Datalink Pro <84617984+WX4JCW@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:22:51 -0500 Subject: [PATCH 3/4] refactor(plugins): fold the Southwest /PR into processPerformanceData, per review The Southwest /PR layout is not airline-specific: the fields mean the same in every /PR on disk (6,201 reports from WN, AS, AA, B6, BA and AC with 12, 18 and 19 fields), so processPerformanceData now decodes them for all: - 0 gross weight (hundreds of pounds) - 2 cruise altitude (hundreds of feet): one value for the whole flight on 85-100% of each airline's flights, and sent in a flight plan before departure (FPN TNCA-TNCB: 110) - 3 fuel on board: not shown again when the report already gave it, and otherwise left unknown, since /DT shows another figure as fuel on board - 8 temperature at cruise altitude: M7 with that 110 (ISA at 11,000 feet) while the aircraft was on the ground - 9 transition altitude of the departure: 180 from US airports, 185 from Mexico, 60 from London, 50 from Berlin, 30 from Amsterdam and Eindhoven Fields 2 and 8 were shown as Altitude and Outside Air Temperature, which replaced a position report's own values (POS variant 10: 17,400 feet became 37,000). The Southwest-only branch and its 12-field rule go away. Co-Authored-By: Claude Opus 5.5 --- lib/plugins/Label_H1_FPN.test.ts | 38 +++--- lib/plugins/Label_H1_PER.test.ts | 47 ++++--- lib/plugins/Label_H1_POS.test.ts | 107 ++++++++------- lib/plugins/Label_H1_PRG.test.ts | 35 ++--- lib/utils/arinc_702_helper.ts | 217 +++++++++++++------------------ 5 files changed, 218 insertions(+), 226 deletions(-) diff --git a/lib/plugins/Label_H1_FPN.test.ts b/lib/plugins/Label_H1_FPN.test.ts index 199b7239..00e9c85f 100644 --- a/lib/plugins/Label_H1_FPN.test.ts +++ b/lib/plugins/Label_H1_FPN.test.ts @@ -120,7 +120,7 @@ describe('Label_H1 FPN', () => { expect(decodeResult.decoder.decodeLevel).toBe('partial'); expect(decodeResult.raw.message_timestamp).toBe(1708178417); expect(decodeResult.formatted.description).toBe('Flight Plan'); - expect(decodeResult.formatted.items.length).toBe(10); + expect(decodeResult.formatted.items.length).toBe(12); expect(decodeResult.formatted.items[0].label).toBe('Route Status'); expect(decodeResult.formatted.items[0].value).toBe('Route Planned'); expect(decodeResult.formatted.items[1].label).toBe('Origin'); @@ -133,18 +133,23 @@ describe('Label_H1 FPN', () => { expect(decodeResult.formatted.items[3].value).toBe('BAINY3'); expect(decodeResult.formatted.items[4].label).toBe('Approach Procedure'); expect(decodeResult.formatted.items[4].value).toBe('ILS30L(30L)'); - expect(decodeResult.formatted.items[5].label).toBe('Altitude'); - expect(decodeResult.formatted.items[5].value).toBe('36000 feet'); - expect(decodeResult.formatted.items[6].label).toBe('Arrival Runway'); - expect(decodeResult.formatted.items[6].value).toBe('30L'); - expect(decodeResult.formatted.items[7].label).toBe('Fuel On Board'); - expect(decodeResult.formatted.items[7].value).toBe('172'); - expect(decodeResult.formatted.items[8].label).toBe( + expect(decodeResult.formatted.items[5].label).toBe('Gross Weight'); + expect(decodeResult.formatted.items[5].value).toBe('435600 pounds'); + expect(decodeResult.formatted.items[6].label).toBe('Cruise Altitude'); + expect(decodeResult.formatted.items[6].value).toBe('36000 feet'); + // Amsterdam's transition altitude is 3,000 feet + expect(decodeResult.formatted.items[7].label).toBe('Transition Altitude'); + expect(decodeResult.formatted.items[7].value).toBe('3000 feet'); + expect(decodeResult.formatted.items[8].label).toBe('Arrival Runway'); + expect(decodeResult.formatted.items[8].value).toBe('30L'); + expect(decodeResult.formatted.items[9].label).toBe('Fuel On Board'); + expect(decodeResult.formatted.items[9].value).toBe('172'); + expect(decodeResult.formatted.items[10].label).toBe( 'Estimated Time of Arrival', ); - expect(decodeResult.formatted.items[8].value).toBe('21:51:17'); - expect(decodeResult.formatted.items[9].label).toBe('Message Checksum'); - expect(decodeResult.formatted.items[9].value).toBe('0x156d'); + expect(decodeResult.formatted.items[10].value).toBe('21:51:17'); + expect(decodeResult.formatted.items[11].label).toBe('Message Checksum'); + expect(decodeResult.formatted.items[11].value).toBe('0x156d'); }); test('decodes Label H1 Preamble FPN with newlines', () => { @@ -292,11 +297,14 @@ describe('Label_H1 FPN', () => { expect(decodeResult.raw.eta_time).toBe(85448); expect(decodeResult.raw.checksum).toBe(0x47c0); expect(decodeResult.formatted.description).toBe('Flight Plan'); - expect(decodeResult.formatted.items.length).toBe(13); - expect(decodeResult.raw.altitude).toBe(11000); - expect(decodeResult.raw.outside_air_temperature).toBe(-7); + expect(decodeResult.formatted.items.length).toBe(14); + // a flight plan sent before departure: 11,000 feet and ISA there, not where the aircraft is + expect(decodeResult.raw.altitude).toBeUndefined(); + expect(decodeResult.raw.cruise_altitude).toBe(11000); + expect(decodeResult.raw.cruise_temperature).toBe(-7); + expect(decodeResult.raw.transition_altitude).toBe(2500); expect(decodeResult.remaining.text).toBe( - 'F37A#M1B/,,,183,7,13,,25,,,P30,M40,36090,13,3455,300', + 'F37A#M1B/,,,183,7,13,,,,P30,M40,36090,13,3455,300', ); }); diff --git a/lib/plugins/Label_H1_PER.test.ts b/lib/plugins/Label_H1_PER.test.ts index c90b4563..6f3a6006 100644 --- a/lib/plugins/Label_H1_PER.test.ts +++ b/lib/plugins/Label_H1_PER.test.ts @@ -17,16 +17,23 @@ describe('Label_H1 PER', () => { expect(decodeResult.decoded).toBe(true); expect(decodeResult.decoder.decodeLevel).toBe('partial'); expect(decodeResult.raw.checksum).toBe(0x2917); - expect(decodeResult.raw.altitude).toBe(32000); - expect(decodeResult.raw.outside_air_temperature).toBe(-53); - expect(decodeResult.formatted.items.length).toBe(3); - expect(decodeResult.formatted.items[0].label).toBe('Altitude'); - expect(decodeResult.formatted.items[0].value).toBe('32000 feet'); - expect(decodeResult.formatted.items[1].label).toBe( - 'Outside Air Temperature (C)', + expect(decodeResult.raw.gross_weight).toBe(133700); + expect(decodeResult.raw.cruise_altitude).toBe(32000); + expect(decodeResult.raw.cruise_temperature).toBe(-53); + expect(decodeResult.raw.transition_altitude).toBe(18000); + expect(decodeResult.formatted.items.length).toBe(5); + expect(decodeResult.formatted.items[0].label).toBe('Gross Weight'); + expect(decodeResult.formatted.items[0].value).toBe('133700 pounds'); + expect(decodeResult.formatted.items[1].label).toBe('Cruise Altitude'); + expect(decodeResult.formatted.items[1].value).toBe('32000 feet'); + expect(decodeResult.formatted.items[2].label).toBe( + 'Cruise Temperature (C)', ); - expect(decodeResult.formatted.items[1].value).toBe('-53 degrees'); - expect(decodeResult.formatted.items[2].label).toBe('Message Checksum'); + expect(decodeResult.formatted.items[2].value).toBe('-53 degrees'); + expect(decodeResult.formatted.items[3].label).toBe('Transition Altitude'); + expect(decodeResult.formatted.items[3].value).toBe('18000 feet'); + expect(decodeResult.formatted.items[4].label).toBe('Message Checksum'); + expect(decodeResult.remaining.text).toBe('262,222,,60,24,275103,P52,P0'); }); test('long variant', () => { @@ -37,18 +44,20 @@ describe('Label_H1 PER', () => { expect(decodeResult.decoded).toBe(true); expect(decodeResult.decoder.decodeLevel).toBe('partial'); expect(decodeResult.raw.checksum).toBe(0x388d); - expect(decodeResult.raw.altitude).toBe(34000); - expect(decodeResult.raw.outside_air_temperature).toBe(-56); - expect(decodeResult.formatted.items.length).toBe(3); - expect(decodeResult.formatted.items[0].label).toBe('Altitude'); - expect(decodeResult.formatted.items[0].value).toBe('34000 feet'); - expect(decodeResult.formatted.items[1].label).toBe( - 'Outside Air Temperature (C)', + expect(decodeResult.raw.gross_weight).toBe(121800); + expect(decodeResult.raw.cruise_altitude).toBe(34000); + expect(decodeResult.raw.cruise_temperature).toBe(-56); + expect(decodeResult.raw.transition_altitude).toBe(18000); + expect(decodeResult.formatted.items.length).toBe(5); + expect(decodeResult.formatted.items[1].label).toBe('Cruise Altitude'); + expect(decodeResult.formatted.items[1].value).toBe('34000 feet'); + expect(decodeResult.formatted.items[2].label).toBe( + 'Cruise Temperature (C)', ); - expect(decodeResult.formatted.items[1].value).toBe('-56 degrees'); - expect(decodeResult.formatted.items[2].label).toBe('Message Checksum'); + expect(decodeResult.formatted.items[2].value).toBe('-56 degrees'); + expect(decodeResult.formatted.items[4].label).toBe('Message Checksum'); expect(decodeResult.remaining.text).toBe( - '1218,276,134,,0,68,,180,,,P30,P0,33936,,1084,284', + '276,134,,0,68,,,,P30,P0,33936,,1084,284', ); }); diff --git a/lib/plugins/Label_H1_POS.test.ts b/lib/plugins/Label_H1_POS.test.ts index f29b6c45..d7a4727a 100644 --- a/lib/plugins/Label_H1_POS.test.ts +++ b/lib/plugins/Label_H1_POS.test.ts @@ -193,7 +193,7 @@ describe('Label_H1 POS', () => { expect(decodeResult.decoded).toBe(true); expect(decodeResult.decoder.decodeLevel).toBe('partial'); expect(decodeResult.formatted.description).toBe('Position Report'); - expect(decodeResult.formatted.items.length).toBe(14); + expect(decodeResult.formatted.items.length).toBe(16); expect(decodeResult.formatted.items[0].label).toBe('Destination'); expect(decodeResult.formatted.items[0].value).toBe('MMGL'); expect(decodeResult.formatted.items[1].label).toBe('Arrival Runway'); @@ -204,34 +204,39 @@ describe('Label_H1 POS', () => { 'Estimated Time of Arrival', ); expect(decodeResult.formatted.items[3].value).toBe('10:33:16'); - expect(decodeResult.formatted.items[4].label).toBe('Altitude'); - expect(decodeResult.formatted.items[4].value).toBe('35000 feet'); - expect(decodeResult.formatted.items[5].label).toBe( - 'Outside Air Temperature (C)', + expect(decodeResult.formatted.items[4].label).toBe('Gross Weight'); + expect(decodeResult.formatted.items[4].value).toBe('175400 pounds'); + expect(decodeResult.formatted.items[5].label).toBe('Cruise Altitude'); + expect(decodeResult.formatted.items[5].value).toBe('35000 feet'); + expect(decodeResult.formatted.items[6].label).toBe( + 'Cruise Temperature (C)', ); - expect(decodeResult.formatted.items[5].value).toBe('-45 degrees'); - expect(decodeResult.formatted.items[6].label).toBe('Route Status'); - expect(decodeResult.formatted.items[6].value).toBe('Route Planned'); - expect(decodeResult.formatted.items[7].label).toBe('Origin'); - expect(decodeResult.formatted.items[7].value).toBe('MMTJ'); - expect(decodeResult.formatted.items[8].label).toBe('Destination'); - expect(decodeResult.formatted.items[8].value).toBe('MMGL'); - expect(decodeResult.formatted.items[9].label).toBe('Departure Runway'); - expect(decodeResult.formatted.items[9].value).toBe('27O'); - expect(decodeResult.formatted.items[10].label).toBe('Departure Procedure'); - expect(decodeResult.formatted.items[10].value).toBe( + expect(decodeResult.formatted.items[6].value).toBe('-45 degrees'); + // Mexico's transition altitude is 18,500 feet + expect(decodeResult.formatted.items[7].label).toBe('Transition Altitude'); + expect(decodeResult.formatted.items[7].value).toBe('18500 feet'); + expect(decodeResult.formatted.items[8].label).toBe('Route Status'); + expect(decodeResult.formatted.items[8].value).toBe('Route Planned'); + expect(decodeResult.formatted.items[9].label).toBe('Origin'); + expect(decodeResult.formatted.items[9].value).toBe('MMTJ'); + expect(decodeResult.formatted.items[10].label).toBe('Destination'); + expect(decodeResult.formatted.items[10].value).toBe('MMGL'); + expect(decodeResult.formatted.items[11].label).toBe('Departure Runway'); + expect(decodeResult.formatted.items[11].value).toBe('27O'); + expect(decodeResult.formatted.items[12].label).toBe('Departure Procedure'); + expect(decodeResult.formatted.items[12].value).toBe( 'TUMA2B: >> SANFE > UT4 >> LMM', ); - expect(decodeResult.formatted.items[11].label).toBe('Arrival Procedure'); - expect(decodeResult.formatted.items[11].value).toBe('LONV1D'); - expect(decodeResult.formatted.items[12].label).toBe('Approach Procedure'); - expect(decodeResult.formatted.items[12].value).toBe( + expect(decodeResult.formatted.items[13].label).toBe('Arrival Procedure'); + expect(decodeResult.formatted.items[13].value).toBe('LONV1D'); + expect(decodeResult.formatted.items[14].label).toBe('Approach Procedure'); + expect(decodeResult.formatted.items[14].value).toBe( 'ILSZ29 starting at PLADE(29O)', ); - expect(decodeResult.formatted.items[13].label).toBe('Message Checksum'); - expect(decodeResult.formatted.items[13].value).toBe('0x9d1c'); + expect(decodeResult.formatted.items[15].label).toBe('Message Checksum'); + expect(decodeResult.formatted.items[15].value).toBe('0x9d1c'); expect(decodeResult.remaining.text).toBe( - '1754,231,189,,0,0,,185,,,P16,P0,36000,,1565,250', + '231,189,,0,0,,,,P16,P0,36000,,1565,250', ); }); @@ -298,7 +303,7 @@ describe('Label_H1 POS', () => { expect(decodeResult.decoded).toBe(true); expect(decodeResult.decoder.decodeLevel).toBe('partial'); expect(decodeResult.formatted.description).toBe('Position Report'); - expect(decodeResult.formatted.items.length).toBe(14); + expect(decodeResult.formatted.items.length).toBe(16); expect(decodeResult.formatted.items[0].label).toBe('Aircraft Position'); expect(decodeResult.formatted.items[0].value).toBe('51.323 N, 4.878 E'); expect(decodeResult.formatted.items[1].label).toBe('Altitude'); @@ -319,26 +324,34 @@ describe('Label_H1 POS', () => { expect(decodeResult.formatted.items[6].value).toBe( 'LEIB..CIV.N872.MEDIL.UN872.KOVIN.UM728.RESMI.UN857.DISAK..DIRMO..ETAMO..ADEKA..MOKDI..MEN..BADAM..KANIG..KENAS.N855.POS', ); - expect(decodeResult.formatted.items[7].label).toBe('Altitude'); - expect(decodeResult.formatted.items[7].value).toBe('37000 feet'); - expect(decodeResult.formatted.items[8].label).toBe( - 'Outside Air Temperature (C)', + // the /PR cruise values no longer replace the report's own altitude and temperature + expect(decodeResult.raw.altitude).toBe(17400); + expect(decodeResult.raw.outside_air_temperature).toBe(-20); + expect(decodeResult.formatted.items[7].label).toBe('Gross Weight'); + expect(decodeResult.formatted.items[7].value).toBe('149600 pounds'); + expect(decodeResult.formatted.items[8].label).toBe('Cruise Altitude'); + expect(decodeResult.formatted.items[8].value).toBe('37000 feet'); + expect(decodeResult.formatted.items[9].label).toBe( + 'Cruise Temperature (C)', ); - expect(decodeResult.formatted.items[8].value).toBe('-47 degrees'); - expect(decodeResult.formatted.items[9].label).toBe('Arrival Runway'); // FIXME should remove - expect(decodeResult.formatted.items[9].value).toBe(''); // FIXME should remove - expect(decodeResult.formatted.items[10].label).toBe('Fuel On Board'); - expect(decodeResult.formatted.items[10].value).toBe('89'); - expect(decodeResult.formatted.items[11].label).toBe( + expect(decodeResult.formatted.items[9].value).toBe('-47 degrees'); + // Eindhoven's transition altitude is 3,000 feet + expect(decodeResult.formatted.items[10].label).toBe('Transition Altitude'); + expect(decodeResult.formatted.items[10].value).toBe('3000 feet'); + expect(decodeResult.formatted.items[11].label).toBe('Arrival Runway'); // FIXME should remove + expect(decodeResult.formatted.items[11].value).toBe(''); // FIXME should remove + expect(decodeResult.formatted.items[12].label).toBe('Fuel On Board'); + expect(decodeResult.formatted.items[12].value).toBe('89'); + expect(decodeResult.formatted.items[13].label).toBe( 'Estimated Time of Arrival', ); - expect(decodeResult.formatted.items[11].value).toBe('12:45:03'); - expect(decodeResult.formatted.items[12].label).toBe('Fuel Remaining'); - expect(decodeResult.formatted.items[12].value).toBe('73'); - expect(decodeResult.formatted.items[13].label).toBe('Message Checksum'); - expect(decodeResult.formatted.items[13].value).toBe('0x53b2'); + expect(decodeResult.formatted.items[13].value).toBe('12:45:03'); + expect(decodeResult.formatted.items[14].label).toBe('Fuel Remaining'); + expect(decodeResult.formatted.items[14].value).toBe('73'); + expect(decodeResult.formatted.items[15].label).toBe('Message Checksum'); + expect(decodeResult.formatted.items[15].value).toBe('0x53b2'); expect(decodeResult.remaining.text).toBe( - '290016,191/PR1496,150,191,,55,10,248028,30,P19,P0/FHCIV,105208,273K,3226,175,M41,252027,450,N,221,62.MEDIL,105411,267K,3439,172,M44,250028,459,N,203,15.PITHI,105533,259K,3584,170,M47,249028,456,N,203,10.LESDO,105859,252K,3700,167,M47,248028,456,N,203,25.KOVIN,110153,252K,3700,164,M47,248028,456,N,203,21.DUCRA,110705,252K,3700,160,M47,248028,456,N,213,37.RESMI,111101,251K,3700,156,M47,248028,455,N,213,28.DEKOD,111325,251K,3700,154,M47,248028,455,N,192,17.DISAK,111438,251K,3700,153,M47,248028,454,N,172,9.DIRMO,112306,251K,3700,145,M47,248028,454,N,178,63.ETAMO,112514,250K,3700,143,M47,248028,453,N,158,16.ADEKA,113339,250K,3700,136,M47,248028,454,N,147,64.MOKDI,114139,251K,3700,129,M47,248028,454,N,181,59.MEN,114429,251K,3700,127,M47,248028,454,N,181,21.BADAM,114843,251K,3700,123,M47,248028,454,N,179,31.KANIG,120154,250K,3700,111,M47,248028,453,N,185,97.KENAS,121800,250K,3700,98,M47,248028,453,N,177,119.POS,122257,250K,3018,96,M45,248023,395,N,182,34.LEIB,124503,150K,2,89,P15,000000,161,N,231,103', + '290016,191/PR150,191,,55,10,248028,P19,P0/FHCIV,105208,273K,3226,175,M41,252027,450,N,221,62.MEDIL,105411,267K,3439,172,M44,250028,459,N,203,15.PITHI,105533,259K,3584,170,M47,249028,456,N,203,10.LESDO,105859,252K,3700,167,M47,248028,456,N,203,25.KOVIN,110153,252K,3700,164,M47,248028,456,N,203,21.DUCRA,110705,252K,3700,160,M47,248028,456,N,213,37.RESMI,111101,251K,3700,156,M47,248028,455,N,213,28.DEKOD,111325,251K,3700,154,M47,248028,455,N,192,17.DISAK,111438,251K,3700,153,M47,248028,454,N,172,9.DIRMO,112306,251K,3700,145,M47,248028,454,N,178,63.ETAMO,112514,250K,3700,143,M47,248028,453,N,158,16.ADEKA,113339,250K,3700,136,M47,248028,454,N,147,64.MOKDI,114139,251K,3700,129,M47,248028,454,N,181,59.MEN,114429,251K,3700,127,M47,248028,454,N,181,21.BADAM,114843,251K,3700,123,M47,248028,454,N,179,31.KANIG,120154,250K,3700,111,M47,248028,453,N,185,97.KENAS,121800,250K,3700,98,M47,248028,453,N,177,119.POS,122257,250K,3018,96,M45,248023,395,N,182,34.LEIB,124503,150K,2,89,P15,000000,161,N,231,103', ); }); @@ -511,8 +524,9 @@ describe('Label_H1 POS', () => { expect(decodeResult.remaining.text).toBe('73/PR,222,,,,,,,P0,P0'); }); - test('leaves a Southwest /PR without 12 fields undecoded', () => { - // WN1793 with the last /PR field removed and the checksum redone + test('decodes a Southwest /PR with a field missing', () => { + // WN1793 with the last /PR field removed and the checksum redone: the fields keep their + // meaning, so the ones still there are decoded message.text = 'POSN40281W080542,DILNE,021507,158,TVTRN,56,022414,ROD,M8,276031,366K,290K,1594,370,KPIT,KLAS,,182,062759,1633,73/PR1594,238,340,370,,44,9,287060,M51,180,P0/RI:DA:KPIT:AA:KLAS..TVTRN..ROD..DONVE..SPI..AGENT..KK57E..DUUZE..KD51W..FLOOD..KD48S..TYEGR:A:CHOWW4.TYEGR:F:WOLPH167E'; const decodeResult = plugin.decode(message); @@ -520,11 +534,10 @@ describe('Label_H1 POS', () => { expect(decodeResult.decoded).toBe(true); expect(decodeResult.decoder.decodeLevel).toBe('partial'); expect(decodeResult.raw.next_waypoint_distance).toBe(56); - expect(decodeResult.raw.cruise_altitude).toBeUndefined(); - expect(decodeResult.raw.transition_altitude).toBeUndefined(); - expect(decodeResult.remaining.text).toBe( - '73/PR1594,238,340,370,,44,9,287060,M51,180,P0', - ); + expect(decodeResult.raw.altitude).toBe(15800); + expect(decodeResult.raw.cruise_altitude).toBe(34000); + expect(decodeResult.raw.transition_altitude).toBe(18000); + expect(decodeResult.remaining.text).toBe('73/PR238,,44,9,287060,P0'); }); test('decodes Label H1 Preamble #M1BPOS ', () => { diff --git a/lib/plugins/Label_H1_PRG.test.ts b/lib/plugins/Label_H1_PRG.test.ts index f954d3f9..5d54e29d 100644 --- a/lib/plugins/Label_H1_PRG.test.ts +++ b/lib/plugins/Label_H1_PRG.test.ts @@ -58,7 +58,7 @@ describe('Label H1 Preamble PRG', () => { expect(decodeResult.decoded).toBe(true); expect(decodeResult.decoder.decodeLevel).toBe('partial'); expect(decodeResult.formatted.description).toBe('Progress Report'); - expect(decodeResult.formatted.items.length).toBe(11); + expect(decodeResult.formatted.items.length).toBe(13); expect(decodeResult.formatted.items[0].label).toBe('Destination'); expect(decodeResult.formatted.items[0].value).toBe('GCRR'); expect(decodeResult.formatted.items[1].label).toBe('Arrival Runway'); @@ -69,24 +69,29 @@ describe('Label H1 Preamble PRG', () => { 'Estimated Time of Arrival', ); expect(decodeResult.formatted.items[3].value).toBe('16:30:55'); - expect(decodeResult.formatted.items[4].label).toBe('Altitude'); - expect(decodeResult.formatted.items[4].value).toBe('35000 feet'); - expect(decodeResult.formatted.items[5].label).toBe('Route Status'); - expect(decodeResult.formatted.items[5].value).toBe('Route Planned'); - expect(decodeResult.formatted.items[6].label).toBe('Origin'); - expect(decodeResult.formatted.items[6].value).toBe('EDDB'); - expect(decodeResult.formatted.items[7].label).toBe('Destination'); - expect(decodeResult.formatted.items[7].value).toBe( + expect(decodeResult.formatted.items[4].label).toBe('Gross Weight'); + expect(decodeResult.formatted.items[4].value).toBe('154400 pounds'); + expect(decodeResult.formatted.items[5].label).toBe('Cruise Altitude'); + expect(decodeResult.formatted.items[5].value).toBe('35000 feet'); + // Berlin's transition altitude is 5,000 feet + expect(decodeResult.formatted.items[6].label).toBe('Transition Altitude'); + expect(decodeResult.formatted.items[6].value).toBe('5000 feet'); + expect(decodeResult.formatted.items[7].label).toBe('Route Status'); + expect(decodeResult.formatted.items[7].value).toBe('Route Planned'); + expect(decodeResult.formatted.items[8].label).toBe('Origin'); + expect(decodeResult.formatted.items[8].value).toBe('EDDB'); + expect(decodeResult.formatted.items[9].label).toBe('Destination'); + expect(decodeResult.formatted.items[9].value).toBe( 'GCRR..N50059E004552..N49588E004338..N49241E002528..N49122E002199..PON.UN872..ERIGA..FUJTI', ); // TODO - pull out route - expect(decodeResult.formatted.items[8].label).toBe('Arrival Procedure'); - expect(decodeResult.formatted.items[8].value).toBe('TERT1P'); - expect(decodeResult.formatted.items[9].label).toBe('Approach Procedure'); - expect(decodeResult.formatted.items[9].value).toBe( + expect(decodeResult.formatted.items[10].label).toBe('Arrival Procedure'); + expect(decodeResult.formatted.items[10].value).toBe('TERT1P'); + expect(decodeResult.formatted.items[11].label).toBe('Approach Procedure'); + expect(decodeResult.formatted.items[11].value).toBe( 'ILSZ03 starting at BAPAL(03O)', ); - expect(decodeResult.formatted.items[10].label).toBe('Message Checksum'); - expect(decodeResult.formatted.items[10].value).toBe('0x226d'); + expect(decodeResult.formatted.items[12].label).toBe('Message Checksum'); + expect(decodeResult.formatted.items[12].value).toBe('0x226d'); expect(decodeResult.remaining.text).toBe( ':WS:FUJTI,360..BAKUP..BATAX..TAKAV..VEDOD', ); diff --git a/lib/utils/arinc_702_helper.ts b/lib/utils/arinc_702_helper.ts index b215581b..e2dde86d 100644 --- a/lib/utils/arinc_702_helper.ts +++ b/lib/utils/arinc_702_helper.ts @@ -54,8 +54,6 @@ export class Arinc702Helper { return false; } const fields = data.split('/'); - // the Southwest position report has its own /PR layout - const southwest = isSouthwestPosition(fields[0]); const canDecode = parseMessageType(decodeResult, fields[0]); if (!canDecode) { decodeResult.decoded = false; @@ -118,11 +116,7 @@ export class Arinc702Helper { processMessageReference(decodeResult, data.split(',')); break; case 'PR': - if (southwest) { - processSouthwestPerformanceData(decodeResult, data.split(',')); - } else { - processPerformanceData(decodeResult, data.split(',')); - } + processPerformanceData(decodeResult, data.split(',')); break; //case 'PR': // TODO: decode /PR fields @@ -493,62 +487,100 @@ function processIMI(decodeResult: DecodeResult, type: string): boolean { return true; } +/** + * /PR, performance data. The fields mean the same in every layout on disk (6,201 reports with + * /PR from WN, AS, AA, B6, BA and AC; 12, 18 and 19 fields), so shorter and longer ones just + * carry fewer or more of them: + * - 0 gross weight (hundreds of pounds); + * - 2 cruise altitude (hundreds of feet): one value for the whole flight on 85-100% of each + * airline's flights, and present in a flight plan sent before departure (FPN TNCA-TNCB: 110); + * - 3 fuel on board (hundreds of pounds); + * - 8 temperature at cruise altitude: M7 with the 110 above, ISA at 11,000 feet, while the + * aircraft was on the ground; + * - 9 transition altitude (hundreds of feet) of the departure: 180 from US airports, 185 from + * Mexico, 60 from London, 50 from Berlin, 30 from Amsterdam. + * The rest is unknown. Fields 0 and 3 are the same as the Southwest position report's gross + * weight and fuel on board on 3,449 of 3,450 reports, so a value the report already gave is not + * shown again. Field 3 is otherwise left in the remaining text, since /DT also shows a figure as + * fuel on board and the two would clash. + */ function processPerformanceData(decodeResult: DecodeResult, data: string[]) { - // /PR fields contain performance data - // Known field positions (12-field short variant and 18-field long variant): - // [0]: ground speed (knots * 10 or similar) - // [1]: indicated airspeed or mach-related - // [2]: altitude in hundreds of feet - // [3]: fuel-related value - // [4]: unknown (often empty) - // [5]: fuel flow or consumption - // [6]: fuel-related - // [7]: wind data (DDDSSSS format) or empty - // [8]: temperature (M=minus, P=plus) - // [9]: heading or unknown - if (data.length < 3) { ResultFormatter.unknownArr(decodeResult, data, ','); return; } - - // Field 2: altitude in hundreds - if (data[2] !== undefined && data[2] !== '') { - const alt = Number(data[2]) * 100; - if (!isNaN(alt) && alt > 0) { - ResultFormatter.altitude(decodeResult, alt); - } - } - - // Field 8: temperature (if present) - if (data.length > 8 && data[8] !== undefined && data[8] !== '') { - ResultFormatter.temperature(decodeResult, data[8]); + const remaining = data.filter( + (value, i) => !decodePerformanceField(decodeResult, i, value), + ); + if (remaining.some((r) => r !== '')) { + ResultFormatter.unknown(decodeResult, remaining.join(','), '/PR'); } +} - // Collect remaining undecoded fields - const remaining: string[] = []; - for (let i = 0; i < data.length; i++) { - if ( - i === 2 && - data[i] !== '' && - !isNaN(Number(data[i])) && - Number(data[i]) * 100 > 0 - ) { - continue; // altitude - already decoded - } - if ( - i === 8 && - data[i] !== '' && - (data[i].startsWith('M') || - data[i].startsWith('P') || - !isNaN(Number(data[i]))) - ) { - continue; // temperature - already decoded +/** Decodes one /PR field; false leaves it in the remaining text. */ +function decodePerformanceField( + decodeResult: DecodeResult, + i: number, + value: string, +): boolean { + const hundreds = /^\d+$/.test(value) ? Number(value) * 100 : undefined; + switch (i) { + case 0: + if (hundreds === undefined) { + return false; + } + if (decodeResult.raw.gross_weight === undefined) { + ResultFormatter.grossWeight(decodeResult, hundreds); + return true; + } + return decodeResult.raw.gross_weight === hundreds; + case 2: + if (!hundreds) { + return false; + } + ResultFormatter.raw( + decodeResult, + 'cruise_altitude', + 'CRZ_ALT', + 'Cruise Altitude', + `${hundreds} feet`, + hundreds, + ); + return true; + case 3: + return ( + hundreds !== undefined && decodeResult.raw.fuel_on_board === hundreds + ); + case 8: { + if (!/^[MP]\d+$/.test(value)) { + return false; + } + const temperature = Number(value.replace('M', '-').replace('P', '+')); + ResultFormatter.raw( + decodeResult, + 'cruise_temperature', + 'CRZ_TEMP', + 'Cruise Temperature (C)', + `${temperature} degrees`, + temperature, + ); + return true; } - remaining.push(data[i]); - } - if (remaining.length > 0 && remaining.some((r) => r !== '')) { - ResultFormatter.unknown(decodeResult, remaining.join(','), '/PR'); + case 9: + if (hundreds === undefined) { + return false; + } + ResultFormatter.raw( + decodeResult, + 'transition_altitude', + 'TRANS_ALT', + 'Transition Altitude', + `${hundreds} feet`, + hundreds, + ); + return true; + default: + return false; } } @@ -566,13 +598,6 @@ function processPerformanceData(decodeResult: DecodeResult, data: string[]) { const SOUTHWEST_POSITION_FIELDS = 21; const SOUTHWEST_TIME = /^([01]\d|2[0-3])[0-5]\d[0-5]\d$/; -function isSouthwestPosition(messagePart: string): boolean { - return ( - messagePart.startsWith('POS') && - messagePart.substring(3).split(',').length === SOUTHWEST_POSITION_FIELDS - ); -} - /** Matches a field; a blank field is skipped and one that does not match is unknown. */ function southwestField(value: string, pattern: RegExp, unknown: string[]) { if (value === '') { @@ -687,74 +712,6 @@ function processSouthwestPosition(decodeResult: DecodeResult, data: string[]) { } } -const SOUTHWEST_PERFORMANCE_FIELDS = 12; - -/** - * /PR of the Southwest position report, 12 fields (3,450 of 3,450 reports; any other count is - * left undecoded): 0 gross weight and 3 fuel on board (the - * same as position fields 12 and 13 on 3,449 of 3,450 reports, so not shown again), 2 cruise - * altitude (hundreds of feet), 8 temperature at cruise altitude (ISA +4 C median), 9 transition - * altitude (hundreds of feet: 180 from US airports, 185 only from Cancun, where it is 18,500 - * feet, 170 only from Punta Cana). The rest is unknown: 1 is set by 737 type (222 -700, - * 262 -800, 238 MAX), 4 is blank. - */ -function processSouthwestPerformanceData( - decodeResult: DecodeResult, - data: string[], -) { - if (data.length !== SOUTHWEST_PERFORMANCE_FIELDS) { - ResultFormatter.unknown(decodeResult, data.join(','), '/PR'); - return; - } - const unknown: string[] = []; - data.forEach((value, i) => { - if (i === 0 && Number(value) * 100 === decodeResult.raw.gross_weight) { - return; - } - if (i === 3 && Number(value) * 100 === decodeResult.raw.fuel_on_board) { - return; - } - if (i === 2 && /^\d+$/.test(value)) { - ResultFormatter.raw( - decodeResult, - 'cruise_altitude', - 'CRZ_ALT', - 'Cruise Altitude', - `${Number(value) * 100} feet`, - Number(value) * 100, - ); - return; - } - if (i === 8 && /^[MP]\d+$/.test(value)) { - const temperature = Number(value.replace('M', '-').replace('P', '+')); - ResultFormatter.raw( - decodeResult, - 'cruise_temperature', - 'CRZ_TEMP', - 'Cruise Temperature (C)', - `${temperature} degrees`, - temperature, - ); - return; - } - if (i === 9 && /^\d+$/.test(value)) { - ResultFormatter.raw( - decodeResult, - 'transition_altitude', - 'TRANS_ALT', - 'Transition Altitude', - `${Number(value) * 100} feet`, - Number(value) * 100, - ); - return; - } - unknown.push(value); - }); - if (unknown.some((v) => v !== '')) { - ResultFormatter.unknown(decodeResult, unknown.join(','), '/PR'); - } -} - function processDateCode(decodeResult: DecodeResult, data: string[]) { if (data.length === 1) { // noop? From 6599aca1a890c625986c2b65f6973d3c0eaf19f9 Mon Sep 17 00:00:00 2001 From: Datalink Pro <84617984+WX4JCW@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:32:52 -0500 Subject: [PATCH 4/4] docs(plugins): add docstrings to the POS decoders, per review processPosition and processSouthwestPosition had none; the docstring coverage check counts the functions this PR touches. Co-Authored-By: Claude Opus 5.5 --- lib/utils/arinc_702_helper.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/utils/arinc_702_helper.ts b/lib/utils/arinc_702_helper.ts index e2dde86d..fab46d19 100644 --- a/lib/utils/arinc_702_helper.ts +++ b/lib/utils/arinc_702_helper.ts @@ -201,6 +201,13 @@ export class Arinc702Helper { } } + /** + * Decodes the fields of a POS report after the IMI: the position, then by field count the + * Southwest layout (21 fields), the short variant (10 or more) or the long one (14 or more). + * + * @param decodeResult - object to store decoded results in + * @param data - the report's comma-separated fields + */ public static processPosition(decodeResult: DecodeResult, data: string[]) { const position = CoordinateUtils.decodeStringCoordinatesDecimalMinutes( data[0], @@ -611,6 +618,10 @@ function southwestField(value: string, pattern: RegExp, unknown: string[]) { return m; } +/** + * Decodes the 21 fields of the Southwest position report (layout above). A blank field is + * skipped; a field that does not fit its layout, and field 20, go to the remaining text. + */ function processSouthwestPosition(decodeResult: DecodeResult, data: string[]) { const unknown: string[] = []; const field = (i: number, pattern: RegExp) =>