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 b0dc7312..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', ); }); @@ -424,6 +437,109 @@ 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 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); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.raw.next_waypoint_distance).toBe(56); + 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 ', () => { message.text = '#M1BPOS Bogus message'; const decodeResult = plugin.decode(message); 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 1c645201..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], @@ -208,6 +215,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); @@ -483,62 +494,232 @@ 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; } + const remaining = data.filter( + (value, i) => !decodePerformanceField(decodeResult, i, value), + ); + if (remaining.some((r) => r !== '')) { + ResultFormatter.unknown(decodeResult, remaining.join(','), '/PR'); + } +} - // 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); +/** 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; } + case 9: + if (hundreds === undefined) { + return false; + } + ResultFormatter.raw( + decodeResult, + 'transition_altitude', + 'TRANS_ALT', + 'Transition Altitude', + `${hundreds} feet`, + hundreds, + ); + return true; + default: + return false; } +} - // Field 8: temperature (if present) - if (data.length > 8 && data[8] !== undefined && data[8] !== '') { - ResultFormatter.temperature(decodeResult, data[8]); +/** + * 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$/; + +/** 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; +} - // 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 - } - remaining.push(data[i]); +/** + * 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) => + southwestField(data[i], pattern, unknown); + + const altitude = field(3, /^\d+$/); + if (altitude) { + ResultFormatter.altitude(decodeResult, Number(altitude[0]) * 100); } - if (remaining.length > 0 && remaining.some((r) => r !== '')) { - ResultFormatter.unknown(decodeResult, remaining.join(','), '/PR'); + // 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); } }