diff --git a/.cspell.json b/.cspell.json index f8710f7fc7..a667b8b795 100644 --- a/.cspell.json +++ b/.cspell.json @@ -489,7 +489,17 @@ "requestfinished", "LOCF", "Unack", - "Tabnabbing" + "Tabnabbing", + "ARINC", + "criticals", + "exceedance", + "exceedances", + "IRIG", + "keyframed", + "measurand", + "TMATS", + "TSPI", + "unshelves" ], "dictionaries": ["npm", "softwareTerms", "node", "html", "css", "bash", "en_US", "en-gb", "misc"], "ignorePaths": [ diff --git a/example/flightTest/Chapter10Adapter.js b/example/flightTest/Chapter10Adapter.js new file mode 100644 index 0000000000..742575d7e0 --- /dev/null +++ b/example/flightTest/Chapter10Adapter.js @@ -0,0 +1,546 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +/* eslint-disable no-bitwise, max-classes-per-file */ +// Bit masks are the natural way to express the packed header fields below. + +/** + * IRIG 106 Chapter 10 (now Chapter 11) packet adapter. + * + * Parses the 24 byte packet header common to every Chapter 10 packet and, + * for MIL-STD-1553 Data Format 1 packets, the channel specific data word and + * the intra-packet headers that precede each recorded bus message. It does + * not decode PCM frames or 1553 command/status/data words; it extracts only + * what is needed to route a packet to this plugin's telemetry keys and to + * derive bus-health counters. + * + * Layout reference: RCC/IRIG 106-07 Chapter 10 "Digital Recording Standard" + * paragraph 10.6.1 (Common Packet Elements) and 10.6.4 (MIL-STD-1553 Bus + * Data Packets, Format 1), published at + * https://www.irig106.org/docs/106-07/chapter10.pdf and + * http://www.irig106.org/docs/106-07_html/10_6_1.phtml (packet header) and + * http://www.irig106.org/docs/106-07_html/10_6_4.phtml (1553 Format 1). The + * IRIG 106 Chapter 10 Programmers' Handbook + * (http://irig106.org/wiki/ch10_handbook:data_file_interpretation) gives + * the equivalent C structure used to cross-check the byte offsets below. + * + * All multi-byte fields are little-endian (10.6.1, Figure 10-6). + * + * Packet header (24 bytes, 10.6.1.1): + * offset 0 uint16 Packet Sync Pattern, always 0xEB25 + * offset 2 uint16 Channel ID (0x0000 reserved for computer generated data) + * offset 4 uint32 Packet Length, bytes, multiple of 4, includes header, + * secondary header, body, filler and data checksum + * offset 8 uint32 Data Length, bytes of channel specific data, + * intra-packet headers and data (no filler/checksum) + * offset 12 uint8 Data Type Version ("Header Version") + * offset 13 uint8 Sequence Number, wraps 0x00-0xFF per channel + * offset 14 uint8 Packet Flags + * bit 7 secondary header present + * bit 6 intra-packet time stamp source + * (0 = RTC, 1 = secondary header time) + * bit 5 RTC sync error + * bit 4 data overflow error + * bits 3-2 secondary header time format + * bits 1-0 data checksum: 00 none, 01 8-bit, + * 10 16-bit, 11 32-bit + * offset 15 uint8 Data Type (0x09 PCM Format 1, 0x11 Time Format 1, + * 0x19 MIL-STD-1553 Format 1, ...) + * offset 16 uint48 Relative Time Counter, 10 MHz free-running counter + * offset 22 uint16 Header Checksum, 16-bit arithmetic sum of the + * eleven preceding 16-bit header words, modulo 2^16 + * + * MIL-STD-1553 Format 1 packet body (10.6.4.2): + * Channel Specific Data Word (uint32): + * bits 23-0 message count in this packet + * bits 31-30 time tag bits (which bit of the message is time-tagged) + * Per message: + * uint64 intra-packet time stamp (RTC in the low 48 bits when packet + * flag bit 6 is 0) + * uint16 Block Status Word + * bit 13 Bus ID: 0 = Bus A, 1 = Bus B + * bit 12 Message Error + * bit 11 RT-to-RT transfer + * bit 10 Format Error + * bit 9 Response Time Out + * bit 5 Word Count Error + * bit 4 Sync Type Error + * bit 3 Invalid Word Error + * uint16 Gap Times Word (GAP1 low byte, GAP2 high byte, 0.1 us units) + * uint16 Length Word, bytes of 1553 words that follow + * ... command/status/data words (not decoded here) + */ + +export const SYNC_PATTERN = 0xeb25; +export const PACKET_HEADER_LENGTH = 24; +export const SECONDARY_HEADER_LENGTH = 12; +export const CHANNEL_SPECIFIC_DATA_LENGTH = 4; +export const INTRA_PACKET_TIME_STAMP_LENGTH = 8; +export const INTRA_PACKET_DATA_HEADER_LENGTH = 6; +export const MIL_STD_1553_FORMAT_1 = 0x19; +export const RTC_FREQUENCY_HZ = 10_000_000; +export const MAX_PACKET_LENGTH = 524_288; + +export const DATA_TYPES = { + 0x00: 'Computer Generated Data, Format 0 (User Defined)', + 0x01: 'Computer Generated Data, Format 1 (Setup Record / TMATS)', + 0x02: 'Computer Generated Data, Format 2 (Recording Events)', + 0x03: 'Computer Generated Data, Format 3 (Recording Index)', + 0x09: 'PCM Data, Format 1', + 0x11: 'Time Data, Format 1', + 0x19: 'MIL-STD-1553 Data, Format 1', + 0x21: 'Analog Data, Format 1', + 0x29: 'Discrete Data, Format 1', + 0x30: 'Message Data, Format 0', + 0x38: 'ARINC 429 Data, Format 0', + 0x40: 'Video Data, Format 0', + 0x50: 'UART Data, Format 0' +}; + +export const DATA_CHECKSUM_TYPES = ['none', '8-bit', '16-bit', '32-bit']; + +/** + * Recorder channels used on the test article, mapped to the plugin's + * telemetry object keys. A real installation would build this from the + * TMATS setup record (Data Type 0x01) at the head of the recording. + */ +export const DEFAULT_CHANNEL_MAP = { + 0x0001: { + name: 'Time', + dataType: 0x11, + keys: [] + }, + 0x0010: { + name: 'PCM Airframe', + dataType: 0x09, + keys: [ + 'ta-01.pcm.altitude', + 'ta-01.pcm.airspeed', + 'ta-01.pcm.aoa', + 'ta-01.pcm.pitch', + 'ta-01.pcm.roll', + 'ta-01.pcm.yaw', + 'ta-01.pcm.nz' + ] + }, + 0x0011: { + name: 'PCM Propulsion', + dataType: 0x09, + keys: [ + 'ta-01.pcm.n1', + 'ta-01.pcm.n2', + 'ta-01.pcm.egt', + 'ta-01.pcm.fuel-flow', + 'ta-01.pcm.fuel-quantity' + ] + }, + 0x0020: { + name: 'MIL-STD-1553 Avionics Bus A/B', + dataType: MIL_STD_1553_FORMAT_1, + keys: { + A: [ + 'ta-01.bus.a.message-rate', + 'ta-01.bus.a.word-errors', + 'ta-01.bus.a.no-response', + 'ta-01.bus.a.status' + ], + B: [ + 'ta-01.bus.b.message-rate', + 'ta-01.bus.b.word-errors', + 'ta-01.bus.b.no-response', + 'ta-01.bus.b.status' + ] + } + }, + 0x0030: { + name: 'TSPI', + dataType: 0x30, + keys: [ + 'ta-01.tspi.latitude', + 'ta-01.tspi.longitude', + 'ta-01.tspi.altitude', + 'ta-01.tspi.ground-speed' + ] + } +}; + +export class Chapter10Error extends Error { + constructor(message, offset) { + super(offset === undefined ? message : `${message} (byte offset ${offset})`); + this.name = 'Chapter10Error'; + this.offset = offset; + } +} + +function toDataView(source) { + if (source instanceof DataView) { + return source; + } + + if (source instanceof ArrayBuffer) { + return new DataView(source); + } + + if (ArrayBuffer.isView(source)) { + return new DataView(source.buffer, source.byteOffset, source.byteLength); + } + + throw new Chapter10Error('Packet source must be an ArrayBuffer, DataView or typed array'); +} + +function requireBytes(view, offset, length, what) { + if (!Number.isInteger(offset) || offset < 0 || offset + length > view.byteLength) { + throw new Chapter10Error(`Truncated packet: ${what} needs ${length} bytes`, offset); + } +} + +/** + * 16-bit arithmetic sum of the first eleven 16-bit words of the header, + * modulo 2^16 (10.6.1.1 item 10). + */ +export function computeHeaderChecksum(view, offset = 0) { + requireBytes(view, offset, PACKET_HEADER_LENGTH - 2, 'header checksum'); + + let sum = 0; + + for (let i = 0; i < PACKET_HEADER_LENGTH - 2; i += 2) { + sum = (sum + view.getUint16(offset + i, true)) & 0xffff; + } + + return sum; +} + +function readUint48(view, offset) { + const low = view.getUint32(offset, true); + const high = view.getUint16(offset + 4, true); + + return high * 0x1_0000_0000 + low; +} + +/** + * Parses the 24 byte packet header at `offset`. + * + * @param {ArrayBuffer|DataView|ArrayBufferView} source + * @param {number} [offset] + * @returns {object} decoded header fields plus `checksumValid` + * @throws {Chapter10Error} if the buffer is too short, the sync pattern is + * wrong, or the length fields are inconsistent + */ +export function parsePacketHeader(source, offset = 0) { + const view = toDataView(source); + + requireBytes(view, offset, PACKET_HEADER_LENGTH, 'packet header'); + + const sync = view.getUint16(offset, true); + + if (sync !== SYNC_PATTERN) { + throw new Chapter10Error( + `Bad sync pattern 0x${sync.toString(16).padStart(4, '0').toUpperCase()}, expected 0xEB25`, + offset + ); + } + + const channelId = view.getUint16(offset + 2, true); + const packetLength = view.getUint32(offset + 4, true); + const dataLength = view.getUint32(offset + 8, true); + const dataTypeVersion = view.getUint8(offset + 12); + const sequenceNumber = view.getUint8(offset + 13); + const packetFlags = view.getUint8(offset + 14); + const dataType = view.getUint8(offset + 15); + const relativeTimeCounter = readUint48(view, offset + 16); + const headerChecksum = view.getUint16(offset + 22, true); + + const secondaryHeaderPresent = (packetFlags & 0x80) !== 0; + const headerLength = + PACKET_HEADER_LENGTH + (secondaryHeaderPresent ? SECONDARY_HEADER_LENGTH : 0); + + if (packetLength % 4 !== 0 || packetLength < headerLength || packetLength > MAX_PACKET_LENGTH) { + throw new Chapter10Error(`Invalid packet length ${packetLength}`, offset + 4); + } + + if (dataLength > packetLength - headerLength) { + throw new Chapter10Error( + `Data length ${dataLength} exceeds packet length ${packetLength}`, + offset + 8 + ); + } + + return { + sync, + channelId, + packetLength, + dataLength, + dataTypeVersion, + sequenceNumber, + packetFlags, + flags: { + secondaryHeaderPresent, + intraPacketTimeFromSecondaryHeader: (packetFlags & 0x40) !== 0, + rtcSyncError: (packetFlags & 0x20) !== 0, + dataOverflowError: (packetFlags & 0x10) !== 0, + secondaryHeaderTimeFormat: (packetFlags >> 2) & 0x03, + dataChecksum: DATA_CHECKSUM_TYPES[packetFlags & 0x03] + }, + dataType, + dataTypeName: DATA_TYPES[dataType] ?? `Reserved (0x${dataType.toString(16)})`, + relativeTimeCounter, + relativeTimeSeconds: relativeTimeCounter / RTC_FREQUENCY_HZ, + headerChecksum, + checksumValid: headerChecksum === computeHeaderChecksum(view, offset), + headerLength, + bodyOffset: offset + headerLength + }; +} + +/** + * Decodes a MIL-STD-1553 Format 1 Block Status Word (10.6.4.2, Figure 10-20). + */ +export function parseBlockStatusWord(blockStatusWord) { + const word = blockStatusWord & 0xffff; + + return { + raw: word, + busId: (word & 0x2000) !== 0 ? 'B' : 'A', + messageError: (word & 0x1000) !== 0, + rtToRtTransfer: (word & 0x0800) !== 0, + formatError: (word & 0x0400) !== 0, + responseTimeout: (word & 0x0200) !== 0, + wordCountError: (word & 0x0020) !== 0, + syncTypeError: (word & 0x0010) !== 0, + invalidWordError: (word & 0x0008) !== 0 + }; +} + +/** + * Parses the body of a MIL-STD-1553 Format 1 packet: the channel specific + * data word followed by one intra-packet header per message. Message payload + * words are skipped using each message's Length Word. + * + * @param {ArrayBuffer|DataView|ArrayBufferView} source + * @param {object} header result of `parsePacketHeader` + * @returns {{messageCount: number, timeTagBits: number, messages: Array}} + */ +export function parse1553Format1Body(source, header) { + const view = toDataView(source); + + if (header.dataType !== MIL_STD_1553_FORMAT_1) { + throw new Chapter10Error( + `Data type 0x${header.dataType.toString(16)} is not MIL-STD-1553 Format 1 (0x19)` + ); + } + + const bodyStart = header.bodyOffset; + const bodyEnd = bodyStart + header.dataLength; + + requireBytes(view, bodyStart, header.dataLength, 'packet body'); + requireBytes(view, bodyStart, CHANNEL_SPECIFIC_DATA_LENGTH, 'channel specific data word'); + + const channelSpecificData = view.getUint32(bodyStart, true); + const messageCount = channelSpecificData & 0x00ffffff; + const timeTagBits = (channelSpecificData >>> 30) & 0x03; + const messages = []; + let cursor = bodyStart + CHANNEL_SPECIFIC_DATA_LENGTH; + + for (let index = 0; index < messageCount; index++) { + const intraPacketHeaderLength = + INTRA_PACKET_TIME_STAMP_LENGTH + INTRA_PACKET_DATA_HEADER_LENGTH; + + if (cursor + intraPacketHeaderLength > bodyEnd) { + throw new Chapter10Error( + `Message ${index} intra-packet header runs past data length`, + cursor + ); + } + + const intraPacketTimeStamp = readUint48(view, cursor); + const blockStatusWord = view.getUint16(cursor + 8, true); + const gapTimesWord = view.getUint16(cursor + 10, true); + const lengthWord = view.getUint16(cursor + 12, true); + const dataOffset = cursor + intraPacketHeaderLength; + + if (dataOffset + lengthWord > bodyEnd) { + throw new Chapter10Error( + `Message ${index} length ${lengthWord} runs past data length`, + cursor + 12 + ); + } + + const status = parseBlockStatusWord(blockStatusWord); + + messages.push({ + index, + intraPacketTimeStamp, + ...status, + gapTimes: { + gap1TenthsMicroseconds: gapTimesWord & 0xff, + gap2TenthsMicroseconds: (gapTimesWord >> 8) & 0xff + }, + lengthBytes: lengthWord, + wordCount: Math.floor(lengthWord / 2), + dataOffset + }); + + cursor = dataOffset + lengthWord; + } + + return { messageCount, timeTagBits, messages }; +} + +/** + * Bus-health counters derived from the messages in one 1553 packet, keyed + * by bus. `wordErrors` counts messages with any word-level error (message, + * format, word count, sync or invalid word), matching what the test + * article's `word-errors` parameters report. + */ +export function summarize1553Messages(messages) { + const summary = { + A: { messages: 0, wordErrors: 0, noResponse: 0, words: 0 }, + B: { messages: 0, wordErrors: 0, noResponse: 0, words: 0 } + }; + + messages.forEach((message) => { + const bus = summary[message.busId]; + + bus.messages += 1; + bus.words += message.wordCount; + + if (message.responseTimeout) { + bus.noResponse += 1; + } + + if ( + message.messageError || + message.formatError || + message.wordCountError || + message.syncTypeError || + message.invalidWordError + ) { + bus.wordErrors += 1; + } + }); + + return summary; +} + +/** + * Parses a single Chapter 10 packet and maps it to this plugin's telemetry + * keys through a channel map. + */ +export default class Chapter10Adapter { + constructor(channelMap = DEFAULT_CHANNEL_MAP) { + this.channelMap = channelMap; + } + + /** + * Resolves the telemetry keys a channel feeds. For 1553 channels the + * optional `busId` ('A' or 'B') selects the per-bus keys. + * + * @returns {string[]} telemetry object keys, empty when unmapped + */ + keysForChannel(channelId, busId) { + const channel = this.channelMap[channelId]; + + if (channel === undefined) { + return []; + } + + if (Array.isArray(channel.keys)) { + return [...channel.keys]; + } + + if (busId !== undefined && Array.isArray(channel.keys[busId])) { + return [...channel.keys[busId]]; + } + + return Object.values(channel.keys).flat(); + } + + /** + * Parses one packet starting at `offset`. The whole packet, as declared by + * its Packet Length, must be present in the buffer and the header checksum + * must verify; anything else is rejected rather than partially decoded. + * + * @returns {{header: object, channel: object|undefined, keys: string[], + * body: object|undefined, busHealth: object|undefined}} + * @throws {Chapter10Error} on any header, checksum, length or body error + */ + parsePacket(source, offset = 0) { + const view = toDataView(source); + const header = parsePacketHeader(view, offset); + + if (!header.checksumValid) { + throw new Chapter10Error('Header checksum mismatch', offset + 22); + } + + requireBytes(view, offset, header.packetLength, 'complete packet'); + + const channel = this.channelMap[header.channelId]; + const result = { + header, + channel, + keys: this.keysForChannel(header.channelId), + body: undefined, + busHealth: undefined + }; + + if (channel !== undefined && channel.dataType !== header.dataType) { + throw new Chapter10Error( + `Channel ${header.channelId} carries data type 0x${header.dataType.toString( + 16 + )} but the channel map expects 0x${channel.dataType.toString(16)}`, + offset + 15 + ); + } + + if (header.dataType === MIL_STD_1553_FORMAT_1) { + result.body = parse1553Format1Body(view, header); + result.busHealth = summarize1553Messages(result.body.messages); + } + + return result; + } + + /** + * Walks a buffer containing back-to-back packets, using each header's + * Packet Length to find the next one. + * + * @returns {Array} one `parsePacket` result per packet + */ + parseStream(source) { + const view = toDataView(source); + const packets = []; + let offset = 0; + + while (offset + PACKET_HEADER_LENGTH <= view.byteLength) { + const packet = this.parsePacket(view, offset); + + packets.push(packet); + offset += packet.header.packetLength; + } + + if (offset !== view.byteLength) { + throw new Chapter10Error('Trailing bytes after last complete packet', offset); + } + + return packets; + } +} diff --git a/example/flightTest/Chapter10AdapterSpec.js b/example/flightTest/Chapter10AdapterSpec.js new file mode 100644 index 0000000000..dc624820f3 --- /dev/null +++ b/example/flightTest/Chapter10AdapterSpec.js @@ -0,0 +1,484 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +/* eslint-disable no-bitwise */ + +import Chapter10Adapter, { + Chapter10Error, + computeHeaderChecksum, + DEFAULT_CHANNEL_MAP, + MIL_STD_1553_FORMAT_1, + PACKET_HEADER_LENGTH, + parse1553Format1Body, + parseBlockStatusWord, + parsePacketHeader, + summarize1553Messages, + SYNC_PATTERN +} from './Chapter10Adapter.js'; + +const BUS_B = 0x2000; +const MESSAGE_ERROR = 0x1000; +const FORMAT_ERROR = 0x0400; +const RESPONSE_TIMEOUT = 0x0200; +const WORD_COUNT_ERROR = 0x0020; + +/** + * Builds a 1553 Format 1 message: 8 byte time stamp, block status word, + * gap times word, length word, then `wordCount` 16-bit data words. + */ +function build1553Message({ timeStamp = 0, blockStatus = 0, gapTimes = 0x0800, wordCount = 3 }) { + const lengthBytes = wordCount * 2; + const buffer = new ArrayBuffer(8 + 6 + lengthBytes); + const view = new DataView(buffer); + + view.setUint32(0, timeStamp >>> 0, true); + view.setUint32(4, 0, true); + view.setUint16(8, blockStatus, true); + view.setUint16(10, gapTimes, true); + view.setUint16(12, lengthBytes, true); + + for (let i = 0; i < wordCount; i++) { + view.setUint16(14 + i * 2, 0x1000 + i, true); + } + + return new Uint8Array(buffer); +} + +function build1553Body(messages, timeTagBits = 0) { + const payload = messages.map((message) => build1553Message(message)); + const payloadLength = payload.reduce((total, bytes) => total + bytes.byteLength, 0); + const body = new Uint8Array(4 + payloadLength); + const view = new DataView(body.buffer); + + view.setUint32(0, ((timeTagBits & 0x03) << 30) | (messages.length & 0x00ffffff), true); + + let cursor = 4; + payload.forEach((bytes) => { + body.set(bytes, cursor); + cursor += bytes.byteLength; + }); + + return body; +} + +/** + * Builds a complete packet: 24 byte header, optional body, filler to a + * multiple of four bytes. The header checksum is computed unless a value is + * supplied explicitly (to test corruption handling). + */ +function buildPacket({ + sync = SYNC_PATTERN, + channelId = 0x0020, + dataType = MIL_STD_1553_FORMAT_1, + dataTypeVersion = 0x05, + sequenceNumber = 0x2a, + packetFlags = 0x00, + relativeTimeCounter = 0, + body = new Uint8Array(0), + dataLength = body.byteLength, + packetLength, + headerChecksum +}) { + const unpaddedLength = PACKET_HEADER_LENGTH + body.byteLength; + const paddedLength = Math.ceil(unpaddedLength / 4) * 4; + const totalLength = packetLength ?? paddedLength; + const packet = new Uint8Array(Math.max(totalLength, paddedLength)); + const view = new DataView(packet.buffer); + + view.setUint16(0, sync, true); + view.setUint16(2, channelId, true); + view.setUint32(4, totalLength, true); + view.setUint32(8, dataLength, true); + view.setUint8(12, dataTypeVersion); + view.setUint8(13, sequenceNumber); + view.setUint8(14, packetFlags); + view.setUint8(15, dataType); + view.setUint32(16, relativeTimeCounter % 0x1_0000_0000, true); + view.setUint16(20, Math.floor(relativeTimeCounter / 0x1_0000_0000), true); + view.setUint16(22, headerChecksum ?? computeHeaderChecksum(view), true); + packet.set(body, PACKET_HEADER_LENGTH); + + return packet; +} + +describe('The IRIG 106 Chapter 10 adapter', () => { + describe('packet header parsing', () => { + let packet; + let header; + + beforeEach(() => { + packet = buildPacket({ + channelId: 0x0010, + dataType: 0x09, + dataTypeVersion: 0x04, + sequenceNumber: 0x7f, + packetFlags: 0b1011_0110, + relativeTimeCounter: 0x0123_4567_89ab, + // 12-byte secondary header (zeros) followed by 8 data bytes + body: new Uint8Array([...new Array(12).fill(0), 1, 2, 3, 4, 5, 6, 7, 8]), + dataLength: 8 + }); + header = parsePacketHeader(packet.buffer); + }); + + it('decodes every fixed header field little-endian', () => { + expect(header.sync).toBe(0xeb25); + expect(header.channelId).toBe(0x0010); + expect(header.packetLength).toBe(44); + expect(header.dataLength).toBe(8); + expect(header.dataTypeVersion).toBe(0x04); + expect(header.sequenceNumber).toBe(0x7f); + expect(header.packetFlags).toBe(0b1011_0110); + expect(header.dataType).toBe(0x09); + expect(header.dataTypeName).toBe('PCM Data, Format 1'); + }); + + it('decodes the 48-bit relative time counter', () => { + expect(header.relativeTimeCounter).toBe(0x0123_4567_89ab); + expect(header.relativeTimeSeconds).toBeCloseTo(0x0123_4567_89ab / 10_000_000, 6); + }); + + it('decodes the packet flag bits', () => { + expect(header.flags.secondaryHeaderPresent).toBe(true); + expect(header.flags.intraPacketTimeFromSecondaryHeader).toBe(false); + expect(header.flags.rtcSyncError).toBe(true); + expect(header.flags.dataOverflowError).toBe(true); + expect(header.flags.secondaryHeaderTimeFormat).toBe(0b01); + expect(header.flags.dataChecksum).toBe('16-bit'); + expect(header.headerLength).toBe(36); + expect(header.bodyOffset).toBe(36); + }); + + it('validates the header checksum', () => { + expect(header.checksumValid).toBe(true); + + const corrupt = buildPacket({ headerChecksum: 0x0000 }); + expect(parsePacketHeader(corrupt.buffer).checksumValid).toBe(false); + }); + + it('computes the checksum as a 16-bit sum of the first eleven words', () => { + const view = new DataView(packet.buffer); + let expected = 0; + + for (let i = 0; i < 22; i += 2) { + expected = (expected + view.getUint16(i, true)) & 0xffff; + } + + expect(computeHeaderChecksum(view)).toBe(expected); + expect(view.getUint16(22, true)).toBe(expected); + }); + + it('accepts an ArrayBuffer, a DataView or a typed array', () => { + const fromView = parsePacketHeader(new DataView(packet.buffer)); + const fromTyped = parsePacketHeader(packet); + + expect(fromView.channelId).toBe(header.channelId); + expect(fromTyped.relativeTimeCounter).toBe(header.relativeTimeCounter); + }); + + it('parses a header at a non-zero offset inside a larger buffer', () => { + const padded = new Uint8Array(8 + packet.byteLength); + padded.set(packet, 8); + + expect(parsePacketHeader(padded.buffer, 8).sequenceNumber).toBe(0x7f); + }); + + it('rejects an invalid sync pattern', () => { + const bad = buildPacket({ sync: 0x25eb }); + + expect(() => parsePacketHeader(bad.buffer)).toThrowError(Chapter10Error, /sync pattern/i); + }); + + it('rejects a buffer shorter than the header', () => { + expect(() => parsePacketHeader(new ArrayBuffer(23))).toThrowError( + Chapter10Error, + /truncated/i + ); + }); + + it('rejects packet lengths that are not multiples of four or shorter than the header', () => { + const odd = buildPacket({ packetLength: 30 }); + const short = buildPacket({ packetLength: 20 }); + + expect(() => parsePacketHeader(odd.buffer)).toThrowError(Chapter10Error, /packet length/i); + expect(() => parsePacketHeader(short.buffer)).toThrowError(Chapter10Error, /packet length/i); + }); + + it('rejects a data length that overruns the packet', () => { + const overrun = buildPacket({ body: new Uint8Array(8), dataLength: 9 }); + + expect(() => parsePacketHeader(overrun.buffer)).toThrowError(Chapter10Error, /data length/i); + }); + + it('rejects sources that are not binary buffers', () => { + expect(() => parsePacketHeader('EB25')).toThrowError(Chapter10Error); + expect(() => parsePacketHeader(undefined)).toThrowError(Chapter10Error); + }); + }); + + describe('MIL-STD-1553 Format 1 parsing', () => { + it('decodes the block status word bit fields', () => { + const status = parseBlockStatusWord( + BUS_B | MESSAGE_ERROR | FORMAT_ERROR | RESPONSE_TIMEOUT | WORD_COUNT_ERROR + ); + + expect(status.busId).toBe('B'); + expect(status.messageError).toBe(true); + expect(status.formatError).toBe(true); + expect(status.responseTimeout).toBe(true); + expect(status.wordCountError).toBe(true); + expect(status.syncTypeError).toBe(false); + expect(status.invalidWordError).toBe(false); + expect(status.rtToRtTransfer).toBe(false); + expect(parseBlockStatusWord(0x0000).busId).toBe('A'); + }); + + it('extracts bus ID, error flags and word count for each message', () => { + const body = build1553Body( + [ + { timeStamp: 1000, blockStatus: 0, wordCount: 4 }, + { timeStamp: 2000, blockStatus: BUS_B | MESSAGE_ERROR | WORD_COUNT_ERROR, wordCount: 6 }, + { timeStamp: 3000, blockStatus: BUS_B | RESPONSE_TIMEOUT, wordCount: 1 } + ], + 0b10 + ); + const packet = buildPacket({ body }); + const header = parsePacketHeader(packet.buffer); + const parsed = parse1553Format1Body(packet.buffer, header); + + expect(parsed.messageCount).toBe(3); + expect(parsed.timeTagBits).toBe(0b10); + expect(parsed.messages.length).toBe(3); + + expect(parsed.messages[0]).toEqual( + jasmine.objectContaining({ + index: 0, + intraPacketTimeStamp: 1000, + busId: 'A', + messageError: false, + wordCount: 4, + lengthBytes: 8 + }) + ); + expect(parsed.messages[1]).toEqual( + jasmine.objectContaining({ + busId: 'B', + messageError: true, + wordCountError: true, + wordCount: 6 + }) + ); + expect(parsed.messages[2]).toEqual( + jasmine.objectContaining({ + busId: 'B', + responseTimeout: true, + wordCount: 1 + }) + ); + expect(parsed.messages[1].gapTimes).toEqual({ + gap1TenthsMicroseconds: 0x00, + gap2TenthsMicroseconds: 0x08 + }); + }); + + it('advances past each message using its length word', () => { + const body = build1553Body([{ wordCount: 2 }, { wordCount: 5 }]); + const packet = buildPacket({ body }); + const parsed = parse1553Format1Body(packet.buffer, parsePacketHeader(packet.buffer)); + const first = parsed.messages[0]; + const second = parsed.messages[1]; + + expect(second.dataOffset).toBe(first.dataOffset + first.lengthBytes + 14); + }); + + it('summarizes per-bus health counters', () => { + const body = build1553Body([ + { blockStatus: 0, wordCount: 2 }, + { blockStatus: 0, wordCount: 2 }, + { blockStatus: BUS_B | FORMAT_ERROR, wordCount: 3 }, + { blockStatus: BUS_B | RESPONSE_TIMEOUT, wordCount: 1 } + ]); + const packet = buildPacket({ body }); + const parsed = parse1553Format1Body(packet.buffer, parsePacketHeader(packet.buffer)); + + expect(summarize1553Messages(parsed.messages)).toEqual({ + A: { messages: 2, wordErrors: 0, noResponse: 0, words: 4 }, + B: { messages: 2, wordErrors: 1, noResponse: 1, words: 4 } + }); + }); + + it('rejects a message whose intra-packet header runs past the data length', () => { + const body = build1553Body([{ wordCount: 2 }]); + const view = new DataView(body.buffer); + view.setUint32(0, 2, true); + const packet = buildPacket({ body }); + + expect(() => + parse1553Format1Body(packet.buffer, parsePacketHeader(packet.buffer)) + ).toThrowError(Chapter10Error, /runs past data length/i); + }); + + it('rejects a message whose length word runs past the data length', () => { + const body = build1553Body([{ wordCount: 2 }]); + const view = new DataView(body.buffer); + view.setUint16(4 + 12, 400, true); + const packet = buildPacket({ body }); + + expect(() => + parse1553Format1Body(packet.buffer, parsePacketHeader(packet.buffer)) + ).toThrowError(Chapter10Error, /length 400/); + }); + + it('rejects packets of another data type', () => { + const packet = buildPacket({ dataType: 0x09, channelId: 0x0010 }); + + expect(() => + parse1553Format1Body(packet.buffer, parsePacketHeader(packet.buffer)) + ).toThrowError(Chapter10Error, /not MIL-STD-1553/i); + }); + }); + + describe('channel mapping', () => { + let adapter; + + beforeEach(() => { + adapter = new Chapter10Adapter(); + }); + + it('maps PCM channels to their telemetry keys', () => { + expect(adapter.keysForChannel(0x0010)).toEqual(DEFAULT_CHANNEL_MAP[0x0010].keys); + expect(adapter.keysForChannel(0x0011)).toContain('ta-01.pcm.egt'); + }); + + it('maps the 1553 channel to per-bus telemetry keys', () => { + expect(adapter.keysForChannel(0x0020, 'A')).toEqual([ + 'ta-01.bus.a.message-rate', + 'ta-01.bus.a.word-errors', + 'ta-01.bus.a.no-response', + 'ta-01.bus.a.status' + ]); + expect(adapter.keysForChannel(0x0020, 'B')).toContain('ta-01.bus.b.status'); + expect(adapter.keysForChannel(0x0020).length).toBe(8); + }); + + it('returns no keys for an unmapped channel', () => { + expect(adapter.keysForChannel(0x0999)).toEqual([]); + }); + + it('parses a 1553 packet end to end into bus health', () => { + const body = build1553Body([ + { blockStatus: 0, wordCount: 3 }, + { blockStatus: BUS_B | MESSAGE_ERROR, wordCount: 3 } + ]); + const packet = buildPacket({ body, channelId: 0x0020 }); + const result = adapter.parsePacket(packet.buffer); + + expect(result.channel.name).toBe('MIL-STD-1553 Avionics Bus A/B'); + expect(result.keys.length).toBe(8); + expect(result.body.messageCount).toBe(2); + expect(result.busHealth.B.wordErrors).toBe(1); + expect(result.busHealth.A.messages).toBe(1); + }); + + it('does not decode the body of PCM packets', () => { + const packet = buildPacket({ + channelId: 0x0010, + dataType: 0x09, + body: new Uint8Array(16) + }); + const result = adapter.parsePacket(packet.buffer); + + expect(result.body).toBeUndefined(); + expect(result.busHealth).toBeUndefined(); + expect(result.keys).toContain('ta-01.pcm.nz'); + }); + + it('rejects a packet with a header checksum mismatch', () => { + const packet = buildPacket({ channelId: 0x0010, dataType: 0x09, headerChecksum: 0x0000 }); + + expect(() => adapter.parsePacket(packet.buffer)).toThrowError( + Chapter10Error, + /checksum mismatch \(byte offset 22\)/i + ); + }); + + it('rejects a packet whose declared length runs past the buffer', () => { + const packet = buildPacket({ + channelId: 0x0010, + dataType: 0x09, + body: new Uint8Array(16) + }); + const truncated = packet.subarray(0, packet.byteLength - 4); + + expect(() => adapter.parsePacket(truncated)).toThrowError( + Chapter10Error, + /truncated packet: complete packet/i + ); + expect(() => adapter.parseStream(truncated)).toThrowError(Chapter10Error, /truncated/i); + }); + + it('rejects a packet whose data type disagrees with the channel map', () => { + const packet = buildPacket({ channelId: 0x0010, dataType: MIL_STD_1553_FORMAT_1 }); + + expect(() => adapter.parsePacket(packet.buffer)).toThrowError( + Chapter10Error, + /channel map expects/i + ); + }); + + it('walks back-to-back packets in a stream', () => { + const first = buildPacket({ + channelId: 0x0010, + dataType: 0x09, + sequenceNumber: 1, + body: new Uint8Array(6) + }); + const second = buildPacket({ + channelId: 0x0020, + sequenceNumber: 2, + body: build1553Body([{ blockStatus: BUS_B, wordCount: 2 }]) + }); + const stream = new Uint8Array(first.byteLength + second.byteLength); + stream.set(first, 0); + stream.set(second, first.byteLength); + + const packets = adapter.parseStream(stream.buffer); + + expect(packets.length).toBe(2); + expect(packets[0].header.sequenceNumber).toBe(1); + expect(packets[0].header.packetLength).toBe(32); + expect(packets[1].header.sequenceNumber).toBe(2); + expect(packets[1].busHealth.B.messages).toBe(1); + }); + + it('rejects a stream with trailing partial bytes', () => { + const packet = buildPacket({ channelId: 0x0010, dataType: 0x09 }); + const stream = new Uint8Array(packet.byteLength + 3); + stream.set(packet, 0); + + expect(() => adapter.parseStream(stream.buffer)).toThrowError( + Chapter10Error, + /trailing bytes/i + ); + }); + }); +}); diff --git a/example/flightTest/FlightTestFaultProvider.js b/example/flightTest/FlightTestFaultProvider.js new file mode 100644 index 0000000000..8204f62fd2 --- /dev/null +++ b/example/flightTest/FlightTestFaultProvider.js @@ -0,0 +1,312 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +import { BUS_STATUS, BUS_STATUS_ENUMERATIONS, sampleFlight } from './flightProfile.js'; +import { exceedanceLevel } from './FlightTestLimitProvider.js'; +import { PARAMETERS, parametersWithLimits } from './parameters.js'; + +export const FAULT_NAMESPACE = 'Flight Test Telemetry/Test Article TA-01'; +export const FAULT_MANAGEMENT_TYPE = 'faultManagement'; +export const GLOBAL_ALARM_STATUS = 'global-alarm-status'; +export const ALARMS = 'alarms'; +export const DEFAULT_POLL_PERIOD_MS = 1000; + +export const SHELVE_DURATIONS = [ + { name: '5 Minutes', value: 300000 }, + { name: '10 Minutes', value: 600000 }, + { name: '15 Minutes', value: 900000 }, + { name: 'Indefinite', value: 0 } +]; + +const BUS_STATUS_SEVERITY = { + [BUS_STATUS.DEGRADED]: 'WARNING', + [BUS_STATUS.FAILED]: 'CRITICAL' +}; + +const BUS_STATUS_PARAMETERS = PARAMETERS.filter((parameter) => parameter.format === 'enum'); + +function busStatusString(value) { + return BUS_STATUS_ENUMERATIONS.find((entry) => entry.value === value)?.string ?? String(value); +} + +function formatValue(parameter, value) { + if (parameter.format === 'enum') { + return busStatusString(value); + } + + return parameter.unit === undefined ? `${value}` : `${value} ${parameter.unit}`; +} + +/** + * Publishes exceedances as faults through the Fault Management API. + * + * The provider samples the simulated flight state on a fixed period and + * raises a fault when a monitored parameter enters its CRITICAL band, or a + * MIL-STD-1553 bus reports DEGRADED (WARNING severity) or FAILED (CRITICAL + * severity). Faults latch: once raised they remain listed until the + * condition clears AND an operator acknowledges them, mirroring how a + * ground-station alarm summary behaves. Shelving suppresses a fault for a + * chosen duration. + */ +export default class FlightTestFaultProvider { + /** + * @param {object} [options] + * @param {number} [options.pollPeriod] monitor period in milliseconds + * @param {() => number} [options.now] clock, injectable for tests + */ + constructor(options = {}) { + this.pollPeriod = options.pollPeriod ?? DEFAULT_POLL_PERIOD_MS; + this.now = options.now ?? Date.now; + this.faults = new Map(); + this.listeners = new Set(); + this.shelveTimers = new Map(); + this.sequence = 0; + this.interval = undefined; + } + + start() { + if (this.interval !== undefined) { + return; + } + + this.evaluate(this.now()); + this.interval = setInterval(() => this.evaluate(this.now()), this.pollPeriod); + } + + stop() { + clearInterval(this.interval); + this.interval = undefined; + this.shelveTimers.forEach((timer) => clearTimeout(timer)); + this.shelveTimers.clear(); + } + + /** + * Evaluates the flight state at `timestamp` and updates the fault set. + * Exposed so specs can drive the monitor deterministically. + * + * @returns {Array} snapshot of active faults after evaluation + */ + evaluate(timestamp) { + const state = sampleFlight(timestamp); + let listChanged = false; + + parametersWithLimits().forEach((parameter) => { + const value = state[parameter.field]; + const level = exceedanceLevel(parameter, value); + + listChanged = + this.#reconcile(parameter, { + active: level === 'CRITICAL', + severity: 'CRITICAL', + value, + timestamp, + description: `${parameter.name} at or above critical limit ${formatValue( + parameter, + parameter.limits.CRITICAL.high + )}` + }) || listChanged; + }); + + BUS_STATUS_PARAMETERS.forEach((parameter) => { + const value = state[parameter.field]; + const severity = BUS_STATUS_SEVERITY[value]; + + listChanged = + this.#reconcile(parameter, { + active: severity !== undefined, + severity, + value, + timestamp, + description: `MIL-STD-1553 Bus ${parameter.bus} ${busStatusString(value)}` + }) || listChanged; + }); + + if (listChanged) { + this.#notify({ type: GLOBAL_ALARM_STATUS }); + } + + return this.snapshot(); + } + + snapshot() { + return [...this.faults.values()] + .sort((a, b) => b.triggerTimestamp - a.triggerTimestamp) + .map((fault) => structuredClone(fault)); + } + + supportsRequest(domainObject) { + return domainObject?.type === FAULT_MANAGEMENT_TYPE; + } + + supportsSubscribe(domainObject) { + return domainObject?.type === FAULT_MANAGEMENT_TYPE; + } + + request() { + return Promise.resolve(this.snapshot().map((fault) => ({ fault }))); + } + + subscribe(domainObject, callback) { + this.listeners.add(callback); + callback({ type: GLOBAL_ALARM_STATUS }); + + return () => { + this.listeners.delete(callback); + }; + } + + acknowledgeFault(fault, ackData = {}) { + const tracked = this.faults.get(fault?.id); + + if (tracked === undefined) { + return Promise.resolve({ success: false }); + } + + tracked.acknowledged = true; + tracked.acknowledgeComment = ackData.comment ?? ''; + + if (tracked.currentValueInfo.monitoringResult === 'IN_LIMITS') { + this.#remove(tracked.id); + this.#notify({ type: GLOBAL_ALARM_STATUS }); + } else { + this.#notify({ type: ALARMS, fault: structuredClone(tracked) }); + } + + return Promise.resolve({ success: true }); + } + + shelveFault(fault, shelveData = {}) { + const tracked = this.faults.get(fault?.id); + + if (tracked === undefined) { + return Promise.resolve({ success: false }); + } + + this.#clearShelveTimer(tracked.id); + + tracked.shelved = shelveData.shelved !== false; + tracked.shelveComment = shelveData.comment ?? ''; + + const duration = Number(shelveData.shelveDuration); + + if (tracked.shelved && Number.isFinite(duration) && duration > 0) { + this.shelveTimers.set( + tracked.id, + setTimeout(() => { + tracked.shelved = false; + this.shelveTimers.delete(tracked.id); + this.#notify({ type: ALARMS, fault: structuredClone(tracked) }); + }, duration) + ); + } + + this.#notify({ type: ALARMS, fault: structuredClone(tracked) }); + + return Promise.resolve({ success: true }); + } + + getShelveDurations() { + return SHELVE_DURATIONS; + } + + /** + * @returns {boolean} true when the fault list membership changed + */ + #reconcile(parameter, { active, severity, value, timestamp, description }) { + const id = parameter.key; + const existing = this.faults.get(id); + const valueInfo = { + value: formatValue(parameter, value), + rangeCondition: active ? 'HIGH' : 'IN_LIMITS', + monitoringResult: active ? severity : 'IN_LIMITS' + }; + + if (active && existing === undefined) { + this.faults.set(id, { + id, + name: parameter.name, + namespace: FAULT_NAMESPACE, + seqNum: this.sequence++, + severity, + shortDescription: description, + triggerTime: new Date(timestamp).toISOString(), + triggerTimestamp: timestamp, + triggerValueInfo: { ...valueInfo }, + currentValueInfo: { ...valueInfo }, + acknowledged: false, + shelved: false + }); + + return true; + } + + if (existing === undefined) { + return false; + } + + const wasInLimits = existing.currentValueInfo.monitoringResult === 'IN_LIMITS'; + const escalated = active && (wasInLimits || severity !== existing.severity); + + if (escalated) { + existing.severity = severity; + existing.shortDescription = description; + existing.triggerTime = new Date(timestamp).toISOString(); + existing.triggerTimestamp = timestamp; + existing.triggerValueInfo = { ...valueInfo }; + existing.acknowledged = false; + } + + if (!active && !wasInLimits && existing.acknowledged) { + this.#remove(id); + + return true; + } + + const previous = existing.currentValueInfo; + const changed = + escalated || + previous.value !== valueInfo.value || + previous.monitoringResult !== valueInfo.monitoringResult; + + existing.currentValueInfo = valueInfo; + + if (changed) { + this.#notify({ type: ALARMS, fault: structuredClone(existing) }); + } + + return escalated; + } + + #remove(id) { + this.#clearShelveTimer(id); + this.faults.delete(id); + } + + #clearShelveTimer(id) { + clearTimeout(this.shelveTimers.get(id)); + this.shelveTimers.delete(id); + } + + #notify(message) { + this.listeners.forEach((listener) => listener(message)); + } +} diff --git a/example/flightTest/FlightTestFaultProviderSpec.js b/example/flightTest/FlightTestFaultProviderSpec.js new file mode 100644 index 0000000000..2511abdab7 --- /dev/null +++ b/example/flightTest/FlightTestFaultProviderSpec.js @@ -0,0 +1,323 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +import { sampleFlight, SORTIE_DURATION_MS } from './flightProfile.js'; +import FlightTestFaultProvider, { + ALARMS, + FAULT_NAMESPACE, + GLOBAL_ALARM_STATUS, + SHELVE_DURATIONS +} from './FlightTestFaultProvider.js'; + +const MINUTE_MS = 60_000; +const SORTIE_BASE = 3_000 * SORTIE_DURATION_MS; +const FAULT_MANAGEMENT = { type: 'faultManagement', identifier: { namespace: '', key: 'fm' } }; + +function at(minutes) { + return SORTIE_BASE + Math.round(minutes * MINUTE_MS); +} + +function faultIds(faults) { + return faults.map((fault) => fault.id).sort(); +} + +describe('The flight test fault provider', () => { + let provider; + + beforeEach(() => { + provider = new FlightTestFaultProvider({ pollPeriod: 1000, now: () => at(13.35) }); + }); + + afterEach(() => { + provider.stop(); + }); + + it('serves only Fault Management objects', () => { + expect(provider.supportsRequest(FAULT_MANAGEMENT)).toBe(true); + expect(provider.supportsSubscribe(FAULT_MANAGEMENT)).toBe(true); + expect(provider.supportsRequest({ type: 'folder' })).toBe(false); + expect(provider.supportsSubscribe(undefined)).toBe(false); + }); + + it('has no faults while the aircraft is within limits', async () => { + provider.evaluate(at(8)); + + expect(await provider.request(FAULT_MANAGEMENT)).toEqual([]); + }); + + it('raises CRITICAL faults when Nz and AOA exceed their critical limits', async () => { + const state = sampleFlight(at(13.35)); + provider.evaluate(at(13.35)); + const faults = (await provider.request(FAULT_MANAGEMENT)).map((entry) => entry.fault); + + expect(faultIds(faults)).toEqual(['ta-01.pcm.aoa', 'ta-01.pcm.nz']); + + const nz = faults.find((fault) => fault.id === 'ta-01.pcm.nz'); + expect(nz.name).toBe('Normal Load Factor (Nz)'); + expect(nz.namespace).toBe(FAULT_NAMESPACE); + expect(nz.severity).toBe('CRITICAL'); + expect(nz.acknowledged).toBe(false); + expect(nz.shelved).toBe(false); + expect(nz.triggerTime).toBe(new Date(at(13.35)).toISOString()); + expect(nz.triggerValueInfo).toEqual({ + value: `${state.nz} g`, + rangeCondition: 'HIGH', + monitoringResult: 'CRITICAL' + }); + expect(nz.currentValueInfo).toEqual(nz.triggerValueInfo); + expect(nz.shortDescription).toContain('6.5 g'); + }); + + it('does not raise a fault for a warning-only exceedance', () => { + let warningOnly; + + for (let minute = 12; minute < 14 && warningOnly === undefined; minute += 1 / 60) { + const state = sampleFlight(at(minute)); + if (state.nz >= 5.5 && state.nz < 6.5 && state.aoa < 25) { + warningOnly = at(minute); + } + } + + expect(warningOnly).toBeDefined(); + expect(provider.evaluate(warningOnly)).toEqual([]); + }); + + it('raises a WARNING fault when Bus B degrades and escalates to CRITICAL when it fails', () => { + const degraded = provider.evaluate(at(15.5)); + + expect(faultIds(degraded)).toEqual(['ta-01.bus.b.status']); + const status = degraded.find((fault) => fault.id === 'ta-01.bus.b.status'); + expect(status.severity).toBe('WARNING'); + expect(status.name).toBe('Bus B Status'); + expect(status.currentValueInfo.value).toBe('DEGRADED'); + expect(status.shortDescription).toBe('MIL-STD-1553 Bus B DEGRADED'); + + const failed = provider.evaluate(at(16.2)); + const escalated = failed.find((fault) => fault.id === 'ta-01.bus.b.status'); + expect(escalated.severity).toBe('CRITICAL'); + expect(escalated.currentValueInfo.value).toBe('FAILED'); + expect(escalated.triggerTime).toBe(new Date(at(16.2)).toISOString()); + expect(escalated.triggerValueInfo.monitoringResult).toBe('CRITICAL'); + expect(escalated.acknowledged).toBe(false); + + const wordErrors = failed.find((fault) => fault.id === 'ta-01.bus.b.word-errors'); + expect(wordErrors.severity).toBe('CRITICAL'); + expect(wordErrors.currentValueInfo.value).toMatch(/^\d+ err\/s$/); + }); + + it('does not fault word errors while they are only in the warning band', () => { + const state = sampleFlight(at(15.5)); + const degraded = provider.evaluate(at(15.5)); + + expect(state.busBWordErrors).toBeGreaterThanOrEqual(5); + expect(state.busBWordErrors).toBeLessThan(20); + expect(degraded.find((fault) => fault.id === 'ta-01.bus.b.word-errors')).toBeUndefined(); + }); + + it('never faults Bus A during the sortie', () => { + for (let minute = 0; minute < 24; minute += 0.25) { + const ids = faultIds(provider.evaluate(at(minute))); + expect(ids.some((id) => id.startsWith('ta-01.bus.a'))).toBe(false); + } + }); + + it('latches faults until the condition clears and the operator acknowledges', async () => { + provider.evaluate(at(13.35)); + const cleared = provider.evaluate(at(14.5)); + const nz = cleared.find((fault) => fault.id === 'ta-01.pcm.nz'); + + expect(nz).toBeDefined(); + expect(nz.severity).toBe('CRITICAL'); + expect(nz.currentValueInfo.monitoringResult).toBe('IN_LIMITS'); + expect(nz.currentValueInfo.rangeCondition).toBe('IN_LIMITS'); + + const result = await provider.acknowledgeFault(nz, { comment: 'Pilot called knock-it-off' }); + expect(result).toEqual({ success: true }); + expect(faultIds(provider.snapshot())).toEqual(['ta-01.pcm.aoa']); + }); + + it('keeps an acknowledged fault listed while the condition persists, then drops it', async () => { + provider.evaluate(at(13.35)); + const [first] = provider.snapshot(); + + await provider.acknowledgeFault(first, {}); + const stillActive = provider.evaluate(at(13.36)).find((fault) => fault.id === first.id); + expect(stillActive.acknowledged).toBe(true); + expect(stillActive.acknowledgeComment).toBe(''); + + const afterClear = provider.evaluate(at(15)); + expect(afterClear.find((fault) => fault.id === first.id)).toBeUndefined(); + }); + + it('re-triggers an acknowledged fault that clears and recurs', async () => { + provider.evaluate(at(13.35)); + const nz = provider.snapshot().find((fault) => fault.id === 'ta-01.pcm.nz'); + await provider.acknowledgeFault(nz, {}); + + provider.evaluate(at(15)); + const nextSortie = provider.evaluate(at(13.35) + SORTIE_DURATION_MS); + const recurred = nextSortie.find((fault) => fault.id === 'ta-01.pcm.nz'); + + expect(recurred.acknowledged).toBe(false); + expect(recurred.triggerTime).toBe(new Date(at(13.35) + SORTIE_DURATION_MS).toISOString()); + }); + + it('shelves and unshelves faults, expiring timed shelves', async () => { + jasmine.clock().install(); + + try { + provider.evaluate(at(13.35)); + const nz = provider.snapshot().find((fault) => fault.id === 'ta-01.pcm.nz'); + + expect( + await provider.shelveFault(nz, { shelved: true, comment: 'Known', shelveDuration: 5000 }) + ).toEqual({ success: true }); + expect(provider.snapshot().find((fault) => fault.id === nz.id).shelved).toBe(true); + expect(provider.snapshot().find((fault) => fault.id === nz.id).shelveComment).toBe('Known'); + + jasmine.clock().tick(5001); + expect(provider.snapshot().find((fault) => fault.id === nz.id).shelved).toBe(false); + + await provider.shelveFault(nz, { shelved: true, shelveDuration: 0 }); + jasmine.clock().tick(60 * MINUTE_MS); + expect(provider.snapshot().find((fault) => fault.id === nz.id).shelved).toBe(true); + + await provider.shelveFault(nz, { shelved: false }); + expect(provider.snapshot().find((fault) => fault.id === nz.id).shelved).toBe(false); + } finally { + jasmine.clock().uninstall(); + } + }); + + it('cancels a pending shelve timer when the fault is removed', async () => { + jasmine.clock().install(); + + try { + const listener = jasmine.createSpy('listener'); + provider.subscribe({}, listener); + + provider.evaluate(at(13.35)); + const nz = provider.snapshot().find((fault) => fault.id === 'ta-01.pcm.nz'); + await provider.shelveFault(nz, { shelved: true, shelveDuration: 5000 }); + await provider.acknowledgeFault(nz, {}); + provider.evaluate(at(14.5)); + expect(faultIds(provider.snapshot())).toEqual(['ta-01.pcm.aoa']); + + listener.calls.reset(); + jasmine.clock().tick(5001); + expect(listener).not.toHaveBeenCalled(); + + const aoa = provider.snapshot().find((fault) => fault.id === 'ta-01.pcm.aoa'); + await provider.shelveFault(aoa, { shelved: true, shelveDuration: 5000 }); + provider.evaluate(at(14.5)); + await provider.acknowledgeFault(aoa, {}); + expect(provider.snapshot()).toEqual([]); + + listener.calls.reset(); + jasmine.clock().tick(5001); + expect(listener).not.toHaveBeenCalled(); + } finally { + jasmine.clock().uninstall(); + } + }); + + it('reports failure when acknowledging or shelving an unknown fault', async () => { + expect(await provider.acknowledgeFault({ id: 'nope' }, {})).toEqual({ success: false }); + expect(await provider.shelveFault({ id: 'nope' }, {})).toEqual({ success: false }); + expect(await provider.acknowledgeFault(undefined, {})).toEqual({ success: false }); + }); + + it('offers shelve durations', () => { + expect(provider.getShelveDurations()).toBe(SHELVE_DURATIONS); + expect(SHELVE_DURATIONS.map((duration) => duration.value)).toContain(0); + }); + + it('notifies subscribers of list changes and per-fault updates', async () => { + const callback = jasmine.createSpy('callback'); + const unsubscribe = provider.subscribe(FAULT_MANAGEMENT, callback); + + expect(callback).toHaveBeenCalledWith({ type: GLOBAL_ALARM_STATUS }); + callback.calls.reset(); + + provider.evaluate(at(13.35)); + expect(callback).toHaveBeenCalledWith({ type: GLOBAL_ALARM_STATUS }); + callback.calls.reset(); + + provider.evaluate(at(13.36)); + const updates = callback.calls.allArgs().map(([message]) => message); + expect(updates.every((message) => message.type === ALARMS)).toBe(true); + expect(updates.length).toBe(2); + const nzUpdate = updates.find((message) => message.fault.id === 'ta-01.pcm.nz'); + expect(nzUpdate.fault.currentValueInfo.value).toBe(`${sampleFlight(at(13.36)).nz} g`); + expect(nzUpdate.fault.triggerValueInfo.value).toBe(`${sampleFlight(at(13.35)).nz} g`); + callback.calls.reset(); + + const nz = provider.snapshot().find((fault) => fault.id === 'ta-01.pcm.nz'); + await provider.acknowledgeFault(nz, {}); + expect(callback).toHaveBeenCalledWith({ + type: ALARMS, + fault: jasmine.objectContaining({ id: 'ta-01.pcm.nz', acknowledged: true }) + }); + callback.calls.reset(); + + unsubscribe(); + provider.evaluate(at(15)); + expect(callback).not.toHaveBeenCalled(); + }); + + it('returns copies so the view cannot mutate provider state', async () => { + provider.evaluate(at(13.35)); + const [entry] = await provider.request(FAULT_MANAGEMENT); + entry.fault.acknowledged = true; + + const [again] = await provider.request(FAULT_MANAGEMENT); + expect(again.fault.acknowledged).toBe(false); + }); + + it('polls the clock while started and stops cleanly', () => { + jasmine.clock().install(); + + try { + let minutes = 8; + const polled = new FlightTestFaultProvider({ pollPeriod: 1000, now: () => at(minutes) }); + const callback = jasmine.createSpy('callback'); + polled.subscribe(FAULT_MANAGEMENT, callback); + callback.calls.reset(); + + polled.start(); + polled.start(); + expect(polled.snapshot()).toEqual([]); + + minutes = 13.35; + jasmine.clock().tick(1000); + expect(faultIds(polled.snapshot())).toEqual(['ta-01.pcm.aoa', 'ta-01.pcm.nz']); + expect(callback).toHaveBeenCalledWith({ type: GLOBAL_ALARM_STATUS }); + + polled.stop(); + minutes = 16.2; + jasmine.clock().tick(5000); + expect(faultIds(polled.snapshot())).toEqual(['ta-01.pcm.aoa', 'ta-01.pcm.nz']); + } finally { + jasmine.clock().uninstall(); + } + }); +}); diff --git a/example/flightTest/FlightTestLimitProvider.js b/example/flightTest/FlightTestLimitProvider.js new file mode 100644 index 0000000000..fa50da3e8d --- /dev/null +++ b/example/flightTest/FlightTestLimitProvider.js @@ -0,0 +1,129 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +import { PARAMETERS_BY_KEY, TYPES } from './parameters.js'; + +export const LIMIT_LEVELS = { + WARNING: { + name: 'Warning High', + cssClass: 'is-limit--upr is-limit--yellow', + color: 'yellow' + }, + CRITICAL: { + name: 'Critical High', + cssClass: 'is-limit--upr is-limit--red', + color: 'red' + } +}; + +/** + * Classifies a value against a parameter's exceedance thresholds. + * + * @returns {'CRITICAL'|'WARNING'|undefined} the most severe band the value + * is in, or undefined when the value is within limits + */ +export function exceedanceLevel(parameter, value) { + const limits = parameter?.limits; + + if (limits === undefined || typeof value !== 'number' || Number.isNaN(value)) { + return undefined; + } + + if (limits.CRITICAL !== undefined && value >= limits.CRITICAL.high) { + return 'CRITICAL'; + } + + if (limits.WARNING !== undefined && value >= limits.WARNING.high) { + return 'WARNING'; + } + + return undefined; +} + +/** + * Warning and critical exceedance limits for the test article's monitored + * parameters. Telemetry tables use the evaluator to color cells; plots use + * `getLimits` to draw limit lines. Only parameters with limits defined in + * parameters.js are supported. + */ +export default class FlightTestLimitProvider { + supportsLimits(domainObject) { + return domainObject.type === TYPES.PARAMETER && this.#parameterFor(domainObject) !== undefined; + } + + getLimitEvaluator(domainObject) { + const parameter = this.#parameterFor(domainObject); + + return { + evaluate(datum, valueMetadata) { + const rangeKey = valueMetadata?.key ?? 'value'; + + if (rangeKey !== 'value') { + return undefined; + } + + const level = exceedanceLevel(parameter, datum[rangeKey]); + + if (level === undefined) { + return undefined; + } + + const limit = parameter.limits[level]; + const next = level === 'WARNING' ? parameter.limits.CRITICAL : undefined; + + return { + name: LIMIT_LEVELS[level].name, + cssClass: LIMIT_LEVELS[level].cssClass, + low: limit.high, + high: next !== undefined ? next.high : Number.POSITIVE_INFINITY + }; + } + }; + } + + getLimits(domainObject) { + const parameter = this.#parameterFor(domainObject); + + return { + limits() { + const limits = {}; + + Object.keys(parameter.limits).forEach((level) => { + limits[level] = { + high: { + color: LIMIT_LEVELS[level].color, + value: parameter.limits[level].high + } + }; + }); + + return Promise.resolve(limits); + } + }; + } + + #parameterFor(domainObject) { + const parameter = PARAMETERS_BY_KEY.get(domainObject.identifier?.key); + + return parameter?.limits === undefined ? undefined : parameter; + } +} diff --git a/example/flightTest/FlightTestLimitProviderSpec.js b/example/flightTest/FlightTestLimitProviderSpec.js new file mode 100644 index 0000000000..1b3b1b0be7 --- /dev/null +++ b/example/flightTest/FlightTestLimitProviderSpec.js @@ -0,0 +1,178 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +import FlightTestLimitProvider, { exceedanceLevel } from './FlightTestLimitProvider.js'; +import FlightTestObjectProvider from './FlightTestObjectProvider.js'; +import { NAMESPACE, PARAMETERS_BY_KEY, parametersWithLimits } from './parameters.js'; + +function identifier(key) { + return { namespace: NAMESPACE, key }; +} + +describe('The flight test limit provider', () => { + let provider; + let objects; + let nz; + let aoa; + let egt; + let wordErrors; + let altitude; + let events; + + beforeEach(async () => { + provider = new FlightTestLimitProvider(); + objects = new FlightTestObjectProvider(); + nz = await objects.get(identifier('ta-01.pcm.nz')); + aoa = await objects.get(identifier('ta-01.pcm.aoa')); + egt = await objects.get(identifier('ta-01.pcm.egt')); + wordErrors = await objects.get(identifier('ta-01.bus.b.word-errors')); + altitude = await objects.get(identifier('ta-01.pcm.altitude')); + events = await objects.get(identifier('ta-01.events.test-card')); + }); + + it('defines the required exceedance thresholds', () => { + expect(PARAMETERS_BY_KEY.get('ta-01.pcm.nz').limits).toEqual({ + WARNING: { high: 5.5 }, + CRITICAL: { high: 6.5 } + }); + expect(PARAMETERS_BY_KEY.get('ta-01.pcm.aoa').limits).toEqual({ + WARNING: { high: 20 }, + CRITICAL: { high: 25 } + }); + expect(PARAMETERS_BY_KEY.get('ta-01.pcm.egt').limits).toEqual({ + WARNING: { high: 900 }, + CRITICAL: { high: 950 } + }); + expect(PARAMETERS_BY_KEY.get('ta-01.bus.a.word-errors').limits).toEqual({ + WARNING: { high: 5 }, + CRITICAL: { high: 20 } + }); + expect(PARAMETERS_BY_KEY.get('ta-01.bus.b.word-errors').limits).toEqual( + PARAMETERS_BY_KEY.get('ta-01.bus.a.word-errors').limits + ); + expect(parametersWithLimits().map((parameter) => parameter.key)).toEqual([ + 'ta-01.pcm.aoa', + 'ta-01.pcm.nz', + 'ta-01.pcm.egt', + 'ta-01.bus.a.word-errors', + 'ta-01.bus.b.word-errors' + ]); + }); + + it('supports only parameters that define limits', () => { + expect(provider.supportsLimits(nz)).toBe(true); + expect(provider.supportsLimits(aoa)).toBe(true); + expect(provider.supportsLimits(egt)).toBe(true); + expect(provider.supportsLimits(wordErrors)).toBe(true); + expect(provider.supportsLimits(altitude)).toBe(false); + expect(provider.supportsLimits(events)).toBe(false); + expect(provider.supportsLimits({ type: 'folder', identifier: identifier('ta-01') })).toBe( + false + ); + }); + + describe('exceedance classification', () => { + const parameter = PARAMETERS_BY_KEY.get('ta-01.pcm.nz'); + + it('is inclusive at the thresholds', () => { + expect(exceedanceLevel(parameter, 5.49)).toBeUndefined(); + expect(exceedanceLevel(parameter, 5.5)).toBe('WARNING'); + expect(exceedanceLevel(parameter, 6.49)).toBe('WARNING'); + expect(exceedanceLevel(parameter, 6.5)).toBe('CRITICAL'); + expect(exceedanceLevel(parameter, 9)).toBe('CRITICAL'); + }); + + it('ignores non-numeric values and parameters without limits', () => { + expect(exceedanceLevel(parameter, undefined)).toBeUndefined(); + expect(exceedanceLevel(parameter, Number.NaN)).toBeUndefined(); + expect(exceedanceLevel(parameter, '7')).toBeUndefined(); + expect(exceedanceLevel(PARAMETERS_BY_KEY.get('ta-01.pcm.altitude'), 1e9)).toBeUndefined(); + expect(exceedanceLevel(undefined, 1e9)).toBeUndefined(); + }); + }); + + describe('the limit evaluator', () => { + const valueMetadata = { key: 'value' }; + + it('returns nothing while a value is within limits', () => { + const evaluator = provider.getLimitEvaluator(nz); + + expect(evaluator.evaluate({ value: 1.02 }, valueMetadata)).toBeUndefined(); + expect(evaluator.evaluate({ value: 5.4999 }, valueMetadata)).toBeUndefined(); + }); + + it('flags warnings yellow and criticals red', () => { + const evaluator = provider.getLimitEvaluator(aoa); + + expect(evaluator.evaluate({ value: 21 }, valueMetadata)).toEqual({ + name: 'Warning High', + cssClass: 'is-limit--upr is-limit--yellow', + low: 20, + high: 25 + }); + expect(evaluator.evaluate({ value: 26 }, valueMetadata)).toEqual({ + name: 'Critical High', + cssClass: 'is-limit--upr is-limit--red', + low: 25, + high: Number.POSITIVE_INFINITY + }); + }); + + it('evaluates MIL-STD-1553 word error rates', () => { + const evaluator = provider.getLimitEvaluator(wordErrors); + + expect(evaluator.evaluate({ value: 4 }, valueMetadata)).toBeUndefined(); + expect(evaluator.evaluate({ value: 5 }, valueMetadata).cssClass).toContain('yellow'); + expect(evaluator.evaluate({ value: 20 }, valueMetadata).cssClass).toContain('red'); + }); + + it('does not evaluate non-range fields such as the timestamp or phase', () => { + const evaluator = provider.getLimitEvaluator(egt); + + expect(evaluator.evaluate({ utc: 1e12, value: 990 }, { key: 'utc' })).toBeUndefined(); + expect(evaluator.evaluate({ phase: 'CRUISE', value: 990 }, { key: 'phase' })).toBeUndefined(); + expect(evaluator.evaluate({ value: 990 })).toBeDefined(); + }); + }); + + describe('plot limit lines', () => { + it('describes each level with the telemetry range key and a color', async () => { + const limits = await provider.getLimits(egt).limits(); + + expect(limits).toEqual({ + WARNING: { high: { color: 'yellow', value: 900 } }, + CRITICAL: { high: { color: 'red', value: 950 } } + }); + }); + + it('matches the evaluator thresholds for every limited parameter', async () => { + for (const parameter of parametersWithLimits()) { + const domainObject = await objects.get(identifier(parameter.key)); + const limits = await provider.getLimits(domainObject).limits(); + + expect(limits.WARNING.high.value).toBe(parameter.limits.WARNING.high); + expect(limits.CRITICAL.high.value).toBe(parameter.limits.CRITICAL.high); + expect(limits.WARNING.low).toBeUndefined(); + } + }); + }); +}); diff --git a/example/flightTest/FlightTestObjectProvider.js b/example/flightTest/FlightTestObjectProvider.js new file mode 100644 index 0000000000..46a491ca49 --- /dev/null +++ b/example/flightTest/FlightTestObjectProvider.js @@ -0,0 +1,132 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +import { + EVENT_METADATA, + EVENT_STREAM, + FOLDERS, + NAMESPACE, + parameterMetadata, + PARAMETERS_BY_KEY, + parametersInGroup, + ROOT_KEY, + TEST_ARTICLE_KEY, + TYPES +} from './parameters.js'; + +function identifierFor(key) { + return { namespace: NAMESPACE, key }; +} + +function locationFor(key) { + return `${NAMESPACE}:${key}`; +} + +/** + * Serves the static object tree for the test article: + * + * Flight Test Telemetry + * └── Test Article TA-01 + * ├── PCM Parameters + * ├── MIL-STD-1553 Bus Health + * ├── TSPI + * └── Test Card Events + * + * Every object carries a `composition` array so the default composition + * provider can expand it, and every telemetry object carries its metadata + * inline in `telemetry.values`. + */ +export default class FlightTestObjectProvider { + constructor() { + this.objects = new Map(); + + this.addObject({ + identifier: identifierFor(ROOT_KEY), + name: 'Flight Test Telemetry', + type: 'folder', + location: 'ROOT', + composition: [identifierFor(TEST_ARTICLE_KEY)] + }); + + this.addObject({ + identifier: identifierFor(TEST_ARTICLE_KEY), + name: 'Test Article TA-01', + type: 'folder', + location: locationFor(ROOT_KEY), + notes: + 'Instrumented test article flying the TP-01 through TP-07 test card: climb, cruise, wind-up turn, bus health check, descent.', + composition: FOLDERS.map((folder) => identifierFor(folder.key)) + }); + + FOLDERS.forEach((folder) => { + const children = + folder.group === 'events' + ? [identifierFor(EVENT_STREAM.key)] + : parametersInGroup(folder.group).map((parameter) => identifierFor(parameter.key)); + + this.addObject({ + identifier: identifierFor(folder.key), + name: folder.name, + type: 'folder', + location: locationFor(TEST_ARTICLE_KEY), + composition: children + }); + }); + + PARAMETERS_BY_KEY.forEach((parameter) => { + const folder = FOLDERS.find((candidate) => candidate.group === parameter.group); + + this.addObject({ + identifier: identifierFor(parameter.key), + name: parameter.name, + type: TYPES.PARAMETER, + location: locationFor(folder.key), + notes: parameter.description, + telemetry: parameterMetadata(parameter) + }); + }); + + const eventsFolder = FOLDERS.find((folder) => folder.group === 'events'); + this.addObject({ + identifier: identifierFor(EVENT_STREAM.key), + name: EVENT_STREAM.name, + type: TYPES.EVENTS, + location: locationFor(eventsFolder.key), + notes: EVENT_STREAM.description, + telemetry: EVENT_METADATA + }); + } + + addObject(domainObject) { + this.objects.set(domainObject.identifier.key, domainObject); + } + + get(identifier) { + const domainObject = this.objects.get(identifier.key); + + if (domainObject === undefined) { + return Promise.reject(new Error(`Unknown flight test object: ${identifier.key}`)); + } + + return Promise.resolve(structuredClone(domainObject)); + } +} diff --git a/example/flightTest/FlightTestTelemetryProvider.js b/example/flightTest/FlightTestTelemetryProvider.js new file mode 100644 index 0000000000..a71ad14ad3 --- /dev/null +++ b/example/flightTest/FlightTestTelemetryProvider.js @@ -0,0 +1,165 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +import { eventsBetween, sampleFlight } from './flightProfile.js'; +import { PARAMETERS_BY_KEY, TYPES } from './parameters.js'; + +export const SAMPLE_PERIOD_MS = 1000; +export const MAX_REQUEST_DATUMS = 50000; +const EVENT_POLL_PERIOD_MS = 1000; + +/** + * Builds a telemetry datum for a parameter object at a point in time. + */ +export function parameterDatum(domainObject, timestamp) { + const parameter = PARAMETERS_BY_KEY.get(domainObject.identifier.key); + const state = sampleFlight(timestamp); + + return { + id: domainObject.identifier.key, + utc: timestamp, + value: state[parameter.field], + phase: state.phase + }; +} + +/** + * Historical and realtime telemetry for the test article. History is a + * pure function of time (see flightProfile.js), so a request for any window + * returns the same values every time and matches what a subscription + * streamed while that window was live. + */ +export default class FlightTestTelemetryProvider { + supportsRequest(domainObject) { + return this.#isFlightTestTelemetry(domainObject); + } + + supportsSubscribe(domainObject) { + return this.#isFlightTestTelemetry(domainObject); + } + + request(domainObject, options = {}) { + if (domainObject.type === TYPES.EVENTS) { + return Promise.resolve(this.#requestEvents(domainObject, options)); + } + + return Promise.resolve(this.#requestParameter(domainObject, options)); + } + + subscribe(domainObject, callback) { + if (domainObject.type === TYPES.EVENTS) { + return this.#subscribeEvents(domainObject, callback); + } + + const interval = setInterval(() => { + callback(parameterDatum(domainObject, Date.now())); + }, SAMPLE_PERIOD_MS); + + return function unsubscribe() { + clearInterval(interval); + }; + } + + #requestParameter(domainObject, options) { + const now = Date.now(); + const end = Math.min(options.end ?? now, now); + const start = Math.min(options.start ?? end - SAMPLE_PERIOD_MS, end); + const size = Math.min(options.size ?? MAX_REQUEST_DATUMS, MAX_REQUEST_DATUMS); + const data = []; + + if (size <= 0) { + return data; + } + + const alignedStart = Math.ceil(start / SAMPLE_PERIOD_MS) * SAMPLE_PERIOD_MS; + const alignedEnd = Math.floor(end / SAMPLE_PERIOD_MS) * SAMPLE_PERIOD_MS; + + if (alignedStart > alignedEnd) { + return data; + } + + const available = (alignedEnd - alignedStart) / SAMPLE_PERIOD_MS + 1; + const count = Math.min(available, size); + + if (options.strategy === 'latest') { + for (let i = count - 1; i >= 0; i--) { + data.push(parameterDatum(domainObject, alignedEnd - i * SAMPLE_PERIOD_MS)); + } + + return data; + } + + const step = count > 1 ? (alignedEnd - alignedStart) / (count - 1) : 0; + + for (let i = 0; i < count; i++) { + data.push(parameterDatum(domainObject, Math.round(alignedStart + i * step))); + } + + return data; + } + + #requestEvents(domainObject, options) { + const now = Date.now(); + const end = Math.min(options.end ?? now, now); + const start = Math.min(options.start ?? 0, end); + const size = Math.min(options.size ?? MAX_REQUEST_DATUMS, MAX_REQUEST_DATUMS); + const events = eventsBetween(start, end).map((event) => this.#eventDatum(domainObject, event)); + + if (size <= 0) { + return []; + } + + if (options.strategy === 'latest') { + return events.slice(-1); + } + + return events.slice(-size); + } + + #subscribeEvents(domainObject, callback) { + let lastEmitted = Date.now(); + + const interval = setInterval(() => { + const now = Date.now(); + + eventsBetween(lastEmitted + 1, now).forEach((event) => { + callback(this.#eventDatum(domainObject, event)); + }); + lastEmitted = now; + }, EVENT_POLL_PERIOD_MS); + + return function unsubscribe() { + clearInterval(interval); + }; + } + + #eventDatum(domainObject, event) { + return { + id: domainObject.identifier.key, + ...event + }; + } + + #isFlightTestTelemetry(domainObject) { + return domainObject.type === TYPES.PARAMETER || domainObject.type === TYPES.EVENTS; + } +} diff --git a/example/flightTest/FlightTestTelemetryProviderSpec.js b/example/flightTest/FlightTestTelemetryProviderSpec.js new file mode 100644 index 0000000000..ae01e75328 --- /dev/null +++ b/example/flightTest/FlightTestTelemetryProviderSpec.js @@ -0,0 +1,343 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +import { sampleFlight, SORTIE_DURATION_MS } from './flightProfile.js'; +import FlightTestObjectProvider from './FlightTestObjectProvider.js'; +import FlightTestTelemetryProvider, { + MAX_REQUEST_DATUMS, + SAMPLE_PERIOD_MS +} from './FlightTestTelemetryProvider.js'; +import { EVENT_STREAM, FOLDERS, NAMESPACE, PARAMETERS, ROOT_KEY, TYPES } from './parameters.js'; + +const MINUTE_MS = 60_000; +const SORTIE_BASE = 2_000 * SORTIE_DURATION_MS; + +function identifier(key) { + return { namespace: NAMESPACE, key }; +} + +describe('The flight test object provider', () => { + let provider; + + beforeEach(() => { + provider = new FlightTestObjectProvider(); + }); + + it('serves the root, test article and folders', async () => { + const root = await provider.get(identifier(ROOT_KEY)); + const testArticle = await provider.get(root.composition[0]); + const folders = await Promise.all(testArticle.composition.map((id) => provider.get(id))); + + expect(root.name).toBe('Flight Test Telemetry'); + expect(root.type).toBe('folder'); + expect(root.location).toBe('ROOT'); + expect(testArticle.name).toBe('Test Article TA-01'); + expect(testArticle.location).toBe(`${NAMESPACE}:${ROOT_KEY}`); + expect(folders.map((folder) => folder.name)).toEqual([ + 'PCM Parameters', + 'MIL-STD-1553 Bus Health', + 'TSPI', + 'Test Card Events' + ]); + }); + + it('places every parameter in exactly one folder with telemetry metadata', async () => { + const folders = await Promise.all( + FOLDERS.map((folder) => provider.get(identifier(folder.key))) + ); + const children = await Promise.all( + folders.flatMap((folder) => folder.composition.map((id) => provider.get(id))) + ); + const parameters = children.filter((child) => child.type === TYPES.PARAMETER); + const events = children.filter((child) => child.type === TYPES.EVENTS); + + expect(parameters.length).toBe(PARAMETERS.length); + expect(new Set(parameters.map((child) => child.identifier.key)).size).toBe(PARAMETERS.length); + expect(events.length).toBe(1); + expect(events[0].name).toBe(EVENT_STREAM.name); + + parameters.forEach((parameter) => { + const keys = parameter.telemetry.values.map((value) => value.key); + expect(keys).toEqual(['utc', 'value', 'phase']); + expect(parameter.telemetry.values[0].hints.domain).toBe(1); + expect(parameter.telemetry.values[1].hints.range).toBe(1); + }); + }); + + it('exposes the PCM parameters requested for the test article', async () => { + const pcm = await provider.get(identifier('ta-01.pcm')); + const names = await Promise.all( + pcm.composition.map((id) => provider.get(id).then((o) => o.name)) + ); + + expect(names).toEqual([ + 'Pressure Altitude', + 'Indicated Airspeed', + 'Angle of Attack', + 'Pitch Attitude', + 'Roll Attitude', + 'Yaw / Heading', + 'Normal Load Factor (Nz)', + 'Engine N1', + 'Engine N2', + 'Exhaust Gas Temperature', + 'Fuel Flow', + 'Fuel Quantity' + ]); + }); + + it('exposes enumerated bus status with NOMINAL / DEGRADED / FAILED', async () => { + const status = await provider.get(identifier('ta-01.bus.b.status')); + const value = status.telemetry.values.find((entry) => entry.key === 'value'); + + expect(value.format).toBe('enum'); + expect(value.enumerations.map((entry) => entry.string)).toEqual([ + 'NOMINAL', + 'DEGRADED', + 'FAILED' + ]); + }); + + it('returns copies so callers cannot mutate the catalog', async () => { + const first = await provider.get(identifier('ta-01.pcm.nz')); + first.name = 'changed'; + const second = await provider.get(identifier('ta-01.pcm.nz')); + + expect(second.name).toBe('Normal Load Factor (Nz)'); + }); + + it('rejects unknown identifiers', async () => { + await expectAsync(provider.get(identifier('nope'))).toBeRejectedWithError(/Unknown/); + }); +}); + +describe('The flight test telemetry provider', () => { + let provider; + let objects; + let nz; + let egt; + let busBStatus; + let events; + let folder; + + beforeEach(async () => { + provider = new FlightTestTelemetryProvider(); + objects = new FlightTestObjectProvider(); + nz = await objects.get(identifier('ta-01.pcm.nz')); + egt = await objects.get(identifier('ta-01.pcm.egt')); + busBStatus = await objects.get(identifier('ta-01.bus.b.status')); + events = await objects.get(identifier(EVENT_STREAM.key)); + folder = await objects.get(identifier('ta-01.pcm')); + jasmine.clock().install(); + jasmine.clock().mockDate(new Date(SORTIE_BASE + 20 * MINUTE_MS)); + }); + + afterEach(() => { + jasmine.clock().uninstall(); + }); + + it('supports parameters and the event stream but not folders', () => { + expect(provider.supportsRequest(nz)).toBe(true); + expect(provider.supportsSubscribe(nz)).toBe(true); + expect(provider.supportsRequest(events)).toBe(true); + expect(provider.supportsSubscribe(events)).toBe(true); + expect(provider.supportsRequest(folder)).toBe(false); + expect(provider.supportsSubscribe(folder)).toBe(false); + }); + + describe('historical requests', () => { + it('returns one datum per second in chronological order', async () => { + const start = SORTIE_BASE + 13 * MINUTE_MS; + const end = start + 10 * SAMPLE_PERIOD_MS; + const data = await provider.request(nz, { start, end }); + + expect(data.length).toBe(11); + expect(data[0].utc).toBe(start); + expect(data.at(-1).utc).toBe(end); + data.forEach((datum, index) => { + expect(datum.id).toBe('ta-01.pcm.nz'); + expect(datum.value).toBe(sampleFlight(datum.utc).nz); + expect(datum.phase).toBe('WIND_UP_TURN'); + if (index > 0) { + expect(datum.utc).toBeGreaterThan(data[index - 1].utc); + } + }); + }); + + it('is deterministic across repeated requests', async () => { + const options = { start: SORTIE_BASE + 5 * MINUTE_MS, end: SORTIE_BASE + 6 * MINUTE_MS }; + const first = await provider.request(egt, options); + const second = await provider.request(egt, options); + + expect(first).toEqual(second); + expect(first.length).toBe(61); + }); + + it('honors options.size by decimating evenly across the window', async () => { + const start = SORTIE_BASE; + const end = SORTIE_BASE + 10 * MINUTE_MS; + const data = await provider.request(nz, { start, end, size: 25 }); + + expect(data.length).toBe(25); + expect(data[0].utc).toBe(start); + expect(data.at(-1).utc).toBe(end); + expect(data[12].utc).toBeCloseTo(start + 5 * MINUTE_MS, -3); + }); + + it('caps the total number of datums', async () => { + const start = SORTIE_BASE - 40 * SORTIE_DURATION_MS; + const end = SORTIE_BASE + 20 * MINUTE_MS; + const data = await provider.request(nz, { start, end, size: MAX_REQUEST_DATUMS * 4 }); + const uncapped = await provider.request(nz, { start, end }); + + expect(data.length).toBe(MAX_REQUEST_DATUMS); + expect(uncapped.length).toBe(MAX_REQUEST_DATUMS); + }); + + it('returns only the newest sample for strategy latest', async () => { + const start = SORTIE_BASE; + const end = SORTIE_BASE + 13 * MINUTE_MS + 350; + const data = await provider.request(nz, { start, end, strategy: 'latest', size: 1 }); + + expect(data.length).toBe(1); + expect(data[0].utc).toBe(SORTIE_BASE + 13 * MINUTE_MS); + }); + + it('returns the newest N samples ending at the aligned end for strategy latest', async () => { + const end = SORTIE_BASE + 13 * MINUTE_MS + 999; + const data = await provider.request(nz, { + start: SORTIE_BASE, + end, + strategy: 'latest', + size: 3 + }); + + expect(data.map((datum) => datum.utc)).toEqual([ + SORTIE_BASE + 13 * MINUTE_MS - 2000, + SORTIE_BASE + 13 * MINUTE_MS - 1000, + SORTIE_BASE + 13 * MINUTE_MS + ]); + }); + + it('never returns samples from the future', async () => { + const now = Date.now(); + const data = await provider.request(nz, { start: now - 5000, end: now + 60_000 }); + + expect(data.at(-1).utc).toBeLessThanOrEqual(now); + expect(data.length).toBe(6); + }); + + it('returns nothing when the window contains no sample boundary', async () => { + const start = SORTIE_BASE + 100; + const options = { start, end: start + 200 }; + + expect(await provider.request(nz, options)).toEqual([]); + expect(await provider.request(nz, { ...options, strategy: 'latest' })).toEqual([]); + }); + + it('keeps every sample inside the requested bounds', async () => { + const start = SORTIE_BASE + 13 * MINUTE_MS + 250; + const end = start + 2500; + const data = await provider.request(nz, { start, end }); + const latest = await provider.request(nz, { start, end, strategy: 'latest', size: 5 }); + + expect(data.map((datum) => datum.utc)).toEqual([ + SORTIE_BASE + 13 * MINUTE_MS + 1000, + SORTIE_BASE + 13 * MINUTE_MS + 2000 + ]); + expect(latest).toEqual(data); + }); + + it('returns enumerated bus status values as numbers', async () => { + const start = SORTIE_BASE + 16.2 * MINUTE_MS; + const data = await provider.request(busBStatus, { start, end: start + 2000 }); + + data.forEach((datum) => expect(datum.value).toBe(2)); + }); + + it('returns test card events within the window as label/message datums', async () => { + const data = await provider.request(events, { + start: SORTIE_BASE + 11 * MINUTE_MS, + end: SORTIE_BASE + 14.5 * MINUTE_MS + }); + + expect(data.map((datum) => datum.message)).toEqual([ + 'TP-04 wind-up turn 4g start', + 'TP-04 Nz exceedance, knock it off', + 'TP-04 complete' + ]); + expect(data[0].id).toBe(EVENT_STREAM.key); + expect(data[0].utc).toBe(SORTIE_BASE + 12 * MINUTE_MS); + expect(data[0].testPoint).toBe('TP-04'); + }); + + it('honors size and latest for the event stream', async () => { + const options = { start: SORTIE_BASE, end: SORTIE_BASE + 17 * MINUTE_MS }; + const sized = await provider.request(events, { ...options, size: 2 }); + const latest = await provider.request(events, { ...options, strategy: 'latest' }); + + expect(sized.map((datum) => datum.message)).toEqual([ + 'Bus B failover', + 'TP-06 descent start' + ]); + expect(latest.length).toBe(1); + expect(latest[0].message).toBe('TP-06 descent start'); + }); + }); + + describe('realtime subscriptions', () => { + it('streams one parameter datum per second from the same profile', () => { + const callback = jasmine.createSpy('callback'); + const unsubscribe = provider.subscribe(nz, callback); + + jasmine.clock().tick(SAMPLE_PERIOD_MS * 3); + + expect(callback).toHaveBeenCalledTimes(3); + const datum = callback.calls.mostRecent().args[0]; + expect(datum.id).toBe('ta-01.pcm.nz'); + expect(datum.utc).toBe(Date.now()); + expect(datum.value).toBe(sampleFlight(Date.now()).nz); + + unsubscribe(); + jasmine.clock().tick(SAMPLE_PERIOD_MS * 3); + expect(callback).toHaveBeenCalledTimes(3); + }); + + it('streams test card events as they occur and stops after unsubscribe', () => { + jasmine.clock().mockDate(new Date(SORTIE_BASE + 12 * MINUTE_MS - 1500)); + const callback = jasmine.createSpy('callback'); + const unsubscribe = provider.subscribe(events, callback); + + jasmine.clock().tick(1000); + expect(callback).not.toHaveBeenCalled(); + + jasmine.clock().tick(1000); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback.calls.mostRecent().args[0].message).toBe('TP-04 wind-up turn 4g start'); + expect(callback.calls.mostRecent().args[0].id).toBe(EVENT_STREAM.key); + + unsubscribe(); + jasmine.clock().tick(3 * MINUTE_MS); + expect(callback).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/example/flightTest/README.md b/example/flightTest/README.md new file mode 100644 index 0000000000..fa10727f62 --- /dev/null +++ b/example/flightTest/README.md @@ -0,0 +1,99 @@ +# Flight Test Telemetry example plugin + +An example plugin for aircraft flight-test / mission-systems integration +telemetry. It adds a **Flight Test Telemetry** root containing a fictional +test article (**Test Article TA-01**) backed by a deterministic simulated +sortie, so plots look like a real test flight and every value can be +reproduced for any point in time. + +The plugin is **not** installed by default. Enable it from `index.html` (or +your own entry point) with: + +```js +openmct.install(openmct.plugins.example.FlightTest()); +``` + +To see exceedances as faults, also install Fault Management and create a +_Fault Management_ object in the tree: + +```js +openmct.install(openmct.plugins.FaultManagement()); +``` + +## Object tree + +``` +Flight Test Telemetry +└── Test Article TA-01 + ├── PCM Parameters altitude, airspeed, AOA, pitch/roll/yaw, Nz, + │ N1, N2, EGT, fuel flow, fuel quantity + ├── MIL-STD-1553 Bus Health Bus A / Bus B message rate, word errors, + │ no-response count, status (NOMINAL/DEGRADED/FAILED) + ├── TSPI latitude, longitude, altitude, ground speed + └── Test Card Events test-point marks and bus events +``` + +## Simulated sortie + +Each 24-minute sortie is aligned to the Unix epoch and repeats, so historical +requests and realtime subscriptions read from the same profile: + +| Minutes | Phase | Notes | +| ------- | -------------------- | -------------------------------------------------------- | +| 0–1 | Takeoff | | +| 1–6 | Climb | Climb schedule 300 kt to FL250 | +| 6–12 | Level cruise | Heading change, acceleration to 420 kt | +| 12–14 | Wind-up turn (TP-04) | Nz and AOA pass the warning and then the critical limits | +| 14–17 | Recovery | Bus B degrades, fails, fails over and is restored | +| 17–22 | Descent | | +| 22–24 | Approach and landing | | + +## Limits and faults + +| Parameter | Warning | Critical | +| ------------------------ | ------- | -------- | +| Normal load factor (Nz) | ≥ 5.5 g | ≥ 6.5 g | +| Angle of attack | ≥ 20° | ≥ 25° | +| Exhaust gas temperature | ≥ 900°C | ≥ 950°C | +| MIL-STD-1553 word errors | ≥ 5/s | ≥ 20/s | + +Warnings highlight yellow and criticals red in tables; plots draw limit lines. +Critical exceedances and DEGRADED/FAILED bus status are published to Fault +Management (WARNING for a degraded bus, CRITICAL otherwise), latch until the +condition clears and the fault is acknowledged, and can be shelved. + +## Modules + +| Module | Purpose | +| ------------------------------- | ---------------------------------------------------------------------------- | +| `plugin.js` | Registers types, root, object/telemetry/limit providers and the fault source | +| `flightProfile.js` | Deterministic keyframed sortie: `sampleFlight(utc)`, `eventsBetween()` | +| `parameters.js` | Parameter catalog, units, enumerations, limits and telemetry metadata | +| `FlightTestObjectProvider.js` | Static domain-object tree | +| `FlightTestTelemetryProvider.js`| Historical `request()` (size, `latest`, datum cap) and realtime `subscribe()`| +| `FlightTestLimitProvider.js` | Limit evaluator (table highlighting) and limit lines (plots) | +| `FlightTestFaultProvider.js` | Exceedance monitor publishing to the Fault Management API | +| `Chapter10Adapter.js` | IRIG 106 Chapter 10 packet header + MIL-STD-1553 Format 1 parser | + +## IRIG 106 Chapter 10 adapter + +`Chapter10Adapter` parses Chapter 10 packet headers (sync `0xEB25`, channel +ID, packet/data length, data type version, sequence number, packet flags, data +type, 48-bit relative time counter, header checksum) from an `ArrayBuffer`, +`DataView` or typed array, maps channel IDs to this plugin's telemetry keys, +and parses MIL-STD-1553 Format 1 (data type `0x19`) intra-packet headers far +enough to extract bus ID, error flags and word count. It is not a full +PCM/1553 decoder. Malformed input (bad sync, header checksum mismatch, +truncated packets, inconsistent lengths) is rejected with a `Chapter10Error` +that carries the byte offset; `parsePacketHeader` alone reports the checksum +result as `checksumValid` for diagnostics. + +```js +import Chapter10Adapter from './Chapter10Adapter.js'; + +const adapter = new Chapter10Adapter(); +adapter.parseStream(arrayBuffer).forEach((packet) => { + // packet.header, packet.keys (mapped telemetry keys), + // packet.busHealth (per-bus error summary, 1553 Format 1 only) +}); +``` diff --git a/example/flightTest/flightProfile.js b/example/flightTest/flightProfile.js new file mode 100644 index 0000000000..f1d7aa3dd9 --- /dev/null +++ b/example/flightTest/flightProfile.js @@ -0,0 +1,592 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +/** + * Deterministic simulation of one flight-test sortie flown by the test + * article. The sortie is a fixed 24 minute test card that repeats back to + * back, aligned to the Unix epoch, so any timestamp maps to exactly one + * point in the profile. Historical requests and realtime subscriptions both + * sample the same function, which keeps history and live data consistent. + * + * Test card (minutes into the sortie): + * 00:00 - 01:00 TP-01 takeoff roll and liftoff + * 01:00 - 06:00 TP-01 climb to FL250 + * 06:00 - 12:00 TP-02/TP-03 level cruise, heading change, accel to 420 kt + * 12:00 - 14:00 TP-04 wind-up turn (Nz and AOA driven through limits) + * 14:00 - 17:00 recovery; MIL-STD-1553 Bus B degrades, fails, fails over + * 17:00 - 22:00 TP-06 descent + * 22:00 - 24:00 TP-07 approach and landing + */ + +const MINUTE_MS = 60 * 1000; +const SECOND_MS = 1000; + +export const SORTIE_DURATION_MIN = 24; +export const SORTIE_DURATION_MS = SORTIE_DURATION_MIN * MINUTE_MS; + +export const PHASES = [ + { key: 'TAKEOFF', name: 'Takeoff', start: 0, end: 1 }, + { key: 'CLIMB', name: 'Climb', start: 1, end: 6 }, + { key: 'CRUISE', name: 'Level cruise', start: 6, end: 12 }, + { key: 'WIND_UP_TURN', name: 'Wind-up turn', start: 12, end: 14 }, + { key: 'RECOVERY', name: 'Recovery', start: 14, end: 17 }, + { key: 'DESCENT', name: 'Descent', start: 17, end: 22 }, + { key: 'APPROACH', name: 'Approach and landing', start: 22, end: 24 } +]; + +export const BUS_STATUS = { + NOMINAL: 0, + DEGRADED: 1, + FAILED: 2 +}; + +export const BUS_STATUS_ENUMERATIONS = [ + { value: BUS_STATUS.NOMINAL, string: 'NOMINAL' }, + { value: BUS_STATUS.DEGRADED, string: 'DEGRADED' }, + { value: BUS_STATUS.FAILED, string: 'FAILED' } +]; + +/** + * Test-point marks recorded on the test card, as offsets into the sortie. + */ +export const TEST_CARD_EVENTS = [ + { offsetMin: 0, testPoint: 'TP-01', category: 'TEST_POINT', message: 'TP-01 takeoff roll start' }, + { offsetMin: 0.75, testPoint: 'TP-01', category: 'TEST_POINT', message: 'TP-01 liftoff' }, + { + offsetMin: 1, + testPoint: 'TP-01', + category: 'TEST_POINT', + message: 'TP-01 gear up, climb schedule 300 kt' + }, + { + offsetMin: 6, + testPoint: 'TP-02', + category: 'TEST_POINT', + message: 'TP-02 level off FL250, cruise setup' + }, + { + offsetMin: 7, + testPoint: 'TP-02', + category: 'TEST_POINT', + message: 'TP-02 heading change 090 to 180' + }, + { offsetMin: 10, testPoint: 'TP-03', category: 'TEST_POINT', message: 'TP-03 accel to 420 kt' }, + { + offsetMin: 12, + testPoint: 'TP-04', + category: 'TEST_POINT', + message: 'TP-04 wind-up turn 4g start' + }, + { + offsetMin: 13.35, + testPoint: 'TP-04', + category: 'EXCEEDANCE', + message: 'TP-04 Nz exceedance, knock it off' + }, + { offsetMin: 14, testPoint: 'TP-04', category: 'TEST_POINT', message: 'TP-04 complete' }, + { + offsetMin: 15, + testPoint: 'TP-05', + category: 'BUS', + message: 'Bus B degraded, word errors rising' + }, + { + offsetMin: 16, + testPoint: 'TP-05', + category: 'BUS', + message: 'Bus B failed, no-response count rising' + }, + { offsetMin: 16.5, testPoint: 'TP-05', category: 'BUS', message: 'Bus B failover' }, + { offsetMin: 17, testPoint: 'TP-06', category: 'TEST_POINT', message: 'TP-06 descent start' }, + { offsetMin: 17.5, testPoint: 'TP-05', category: 'BUS', message: 'Bus B restored' }, + { offsetMin: 22, testPoint: 'TP-07', category: 'TEST_POINT', message: 'TP-07 approach' }, + { + offsetMin: 23.5, + testPoint: 'TP-07', + category: 'TEST_POINT', + message: 'Touchdown, test card complete' + } +]; + +// Keyframes are [minutesIntoSortie, value]; values are eased between keys. +const ALTITUDE_FT = [ + [0, 60], + [0.75, 60], + [1, 500], + [6, 25000], + [12, 25000], + [12.5, 24800], + [13.5, 23400], + [14, 24000], + [17, 25000], + [22, 2000], + [23.5, 60], + [24, 60] +]; + +const AIRSPEED_KT = [ + [0, 0], + [0.75, 150], + [1, 180], + [6, 320], + [10, 350], + [12, 420], + [13.3, 380], + [14, 360], + [17, 340], + [22, 180], + [23.5, 130], + [24, 0] +]; + +const PITCH_DEG = [ + [0, 0], + [0.75, 0], + [1, 12], + [6, 10], + [6.5, 3], + [12, 3], + [12.3, 8], + [13.3, 14], + [14, 3], + [17, 2], + [17.5, -4], + [22, -3], + [23.5, 5], + [24, 0] +]; + +const ROLL_DEG = [ + [0, 0], + [7, 0], + [7.3, 30], + [8, 30], + [8.3, 0], + [12, 0], + [12.4, 55], + [13.3, 78], + [13.8, 30], + [14, 0], + [17, 0], + [17.3, -30], + [18, -30], + [18.3, 0], + [24, 0] +]; + +const HEADING_DEG = [ + [0, 90], + [7, 90], + [8.3, 180], + [12, 180], + [14, 540], + [17, 540], + [18.3, 630], + [24, 630] +]; + +const NZ_G = [ + [0, 1], + [0.75, 1], + [1, 1.3], + [1.3, 1], + [6, 1], + [6.3, 0.85], + [6.6, 1], + [7, 1], + [7.3, 1.15], + [8, 1.15], + [8.3, 1], + [12, 1], + [12.4, 2], + [12.9, 4], + [13.2, 6], + [13.35, 6.7], + [13.5, 5], + [13.8, 1.8], + [14, 1], + [17, 1], + [17.3, 1.15], + [18, 1.15], + [18.3, 1], + [23.5, 1.2], + [23.6, 1], + [24, 1] +]; + +const AOA_DEG = [ + [0, 2], + [0.75, 8], + [1, 12], + [6, 9], + [6.5, 4], + [12, 3.5], + [12.4, 6], + [12.9, 12], + [13.2, 22], + [13.35, 26.5], + [13.5, 18], + [13.8, 7], + [14, 4], + [17, 4.5], + [22, 7], + [23.5, 11], + [24, 3] +]; + +const N1_PERCENT = [ + [0, 25], + [0.5, 98], + [1, 100], + [6, 96], + [6.5, 82], + [10, 82], + [10.5, 90], + [12, 90], + [12.4, 100], + [13.5, 102], + [14, 85], + [17, 82], + [17.5, 55], + [22, 60], + [23.5, 70], + [23.7, 30], + [24, 25] +]; + +const EGT_C = [ + [0, 480], + [0.5, 860], + [1, 880], + [6, 850], + [6.5, 720], + [10, 720], + [10.5, 800], + [12, 800], + [12.4, 900], + [13.4, 935], + [14, 760], + [17, 720], + [17.5, 560], + [22, 600], + [23.5, 680], + [23.7, 480], + [24, 460] +]; + +const FUEL_FLOW_PPH = [ + [0, 900], + [0.5, 9500], + [1, 10500], + [6, 8500], + [6.5, 4200], + [10, 4200], + [10.5, 5800], + [12, 5800], + [12.4, 11000], + [13.5, 12500], + [14, 5000], + [17, 4200], + [17.5, 1800], + [22, 2200], + [23.5, 3000], + [23.7, 900], + [24, 850] +]; + +const BUS_A_MSG_RATE = [ + [0, 850], + [16.5, 850], + [16.6, 1620], + [17.5, 1620], + [17.6, 850], + [24, 850] +]; + +const BUS_B_MSG_RATE = [ + [0, 770], + [15, 770], + [16, 740], + [16.4, 600], + [16.5, 0], + [17.5, 0], + [17.6, 770], + [24, 770] +]; + +const BUS_B_WORD_ERRORS = [ + [0, 0], + [14.8, 0], + [15, 6], + [15.9, 9], + [16, 22], + [16.4, 28], + [16.5, 0], + [24, 0] +]; + +const BUS_B_NO_RESPONSE = [ + [0, 0], + [15.9, 0], + [16, 4], + [16.4, 14], + [16.5, 0], + [24, 0] +]; + +const INITIAL_FUEL_LB = 12000; +const RANGE_ORIGIN = { latitude: 36.9, longitude: -75.6 }; +const WIND_FROM_DEG = 270; +const WIND_KT = 15; +const DEG_TO_RAD = Math.PI / 180; + +function smoothstep(fraction) { + return fraction * fraction * (3 - 2 * fraction); +} + +/** + * Piecewise interpolation between keyframes, eased with smoothstep so that + * maneuver entries and exits are rounded like a hand-flown profile. + */ +export function interpolate(keyframes, minutes) { + const first = keyframes[0]; + const last = keyframes[keyframes.length - 1]; + + if (minutes <= first[0]) { + return first[1]; + } + + if (minutes >= last[0]) { + return last[1]; + } + + for (let i = 1; i < keyframes.length; i++) { + const [endMin, endValue] = keyframes[i]; + + if (minutes <= endMin) { + const [startMin, startValue] = keyframes[i - 1]; + const fraction = (minutes - startMin) / (endMin - startMin); + + return startValue + (endValue - startValue) * smoothstep(fraction); + } + } + + return last[1]; +} + +function ripple(seconds, periodSeconds, amplitude, phase = 0) { + return amplitude * Math.sin((2 * Math.PI * seconds) / periodSeconds + phase); +} + +function round(value, decimals) { + const factor = Math.pow(10, decimals); + + return Math.round(value * factor) / factor; +} + +function trueAirspeed(indicatedKt, altitudeFt) { + return indicatedKt * (1 + altitudeFt / 60000); +} + +function groundSpeedKt(minutes) { + const heading = interpolate(HEADING_DEG, minutes); + const tas = trueAirspeed(interpolate(AIRSPEED_KT, minutes), interpolate(ALTITUDE_FT, minutes)); + const windComponent = WIND_KT * Math.cos((heading - (WIND_FROM_DEG + 180)) * DEG_TO_RAD); + + return Math.max(0, tas + windComponent); +} + +/** + * The ground track and fuel burn are integrals of the keyframed profile. + * They are integrated once per second over the whole sortie at module load + * and then sampled, which keeps every lookup deterministic and cheap. + */ +function integrateSortie() { + const seconds = SORTIE_DURATION_MIN * 60; + const latitude = new Float64Array(seconds + 1); + const longitude = new Float64Array(seconds + 1); + const fuelUsed = new Float64Array(seconds + 1); + + latitude[0] = RANGE_ORIGIN.latitude; + longitude[0] = RANGE_ORIGIN.longitude; + fuelUsed[0] = 0; + + for (let s = 1; s <= seconds; s++) { + const minutes = (s - 0.5) / 60; + const heading = interpolate(HEADING_DEG, minutes) * DEG_TO_RAD; + const nmPerSecond = groundSpeedKt(minutes) / 3600; + const dLat = (nmPerSecond * Math.cos(heading)) / 60; + const dLon = (nmPerSecond * Math.sin(heading)) / 60 / Math.cos(latitude[s - 1] * DEG_TO_RAD); + + latitude[s] = latitude[s - 1] + dLat; + longitude[s] = longitude[s - 1] + dLon; + fuelUsed[s] = fuelUsed[s - 1] + interpolate(FUEL_FLOW_PPH, minutes) / 3600; + } + + return { latitude, longitude, fuelUsed }; +} + +const TRACK = integrateSortie(); + +function sampleTrack(series, seconds) { + const clamped = Math.min(Math.max(seconds, 0), series.length - 1); + const index = Math.floor(clamped); + const fraction = clamped - index; + + if (index >= series.length - 1) { + return series[series.length - 1]; + } + + return series[index] + (series[index + 1] - series[index]) * fraction; +} + +export function busStatusFor(messageRate, wordErrors, noResponse) { + if (messageRate < 100 || wordErrors >= 20 || noResponse >= 10) { + return BUS_STATUS.FAILED; + } + + if (wordErrors >= 5 || noResponse >= 1) { + return BUS_STATUS.DEGRADED; + } + + return BUS_STATUS.NOMINAL; +} + +/** + * @param {number} timestamp epoch milliseconds + * @returns {number} epoch milliseconds at which the sortie containing + * `timestamp` started + */ +export function sortieStart(timestamp) { + return timestamp - (timestamp % SORTIE_DURATION_MS); +} + +export function sortieNumber(timestamp) { + return Math.floor(timestamp / SORTIE_DURATION_MS); +} + +export function phaseAt(minutes) { + return PHASES.find((phase) => minutes >= phase.start && minutes < phase.end) ?? PHASES.at(-1); +} + +/** + * Samples the complete aircraft state at a point in time. + * + * @param {number} timestamp epoch milliseconds + */ +export function sampleFlight(timestamp) { + const elapsedMs = timestamp - sortieStart(timestamp); + const seconds = elapsedMs / SECOND_MS; + const minutes = elapsedMs / MINUTE_MS; + + const altitude = + interpolate(ALTITUDE_FT, minutes) + ripple(seconds, 7, 12) + ripple(seconds, 31, 20); + const airspeed = interpolate(AIRSPEED_KT, minutes) + ripple(seconds, 5, 1.5, 1); + const aoa = interpolate(AOA_DEG, minutes) + ripple(seconds, 3, 0.25, 2); + const pitch = interpolate(PITCH_DEG, minutes) + ripple(seconds, 4, 0.4); + const roll = interpolate(ROLL_DEG, minutes) + ripple(seconds, 6, 0.8, 1); + const yaw = (interpolate(HEADING_DEG, minutes) + ripple(seconds, 9, 0.6, 2) + 360) % 360; + const nz = interpolate(NZ_G, minutes) + ripple(seconds, 2.5, 0.04); + const n1 = interpolate(N1_PERCENT, minutes) + ripple(seconds, 11, 0.3); + const n2 = 58 + 0.43 * n1 + ripple(seconds, 13, 0.2, 1); + const egt = interpolate(EGT_C, minutes) + ripple(seconds, 17, 4); + const fuelFlow = interpolate(FUEL_FLOW_PPH, minutes) + ripple(seconds, 8, 40); + const fuelQuantity = INITIAL_FUEL_LB - sampleTrack(TRACK.fuelUsed, seconds); + + const busAWordErrors = Math.floor(seconds) % 97 === 0 ? 1 : 0; + const busAMessageRate = interpolate(BUS_A_MSG_RATE, minutes) + ripple(seconds, 4, 6); + const busANoResponse = 0; + + const busBMessageRate = Math.max( + 0, + interpolate(BUS_B_MSG_RATE, minutes) + ripple(seconds, 4, 6, 1) + ); + const busBWordErrors = Math.round(interpolate(BUS_B_WORD_ERRORS, minutes)); + const busBNoResponse = Math.round(interpolate(BUS_B_NO_RESPONSE, minutes)); + + return { + utc: timestamp, + sortie: sortieNumber(timestamp), + elapsedSeconds: seconds, + phase: phaseAt(minutes).key, + altitude: round(altitude, 0), + airspeed: round(airspeed, 1), + aoa: round(aoa, 2), + pitch: round(pitch, 2), + roll: round(roll, 2), + yaw: round(yaw, 2), + nz: round(nz, 3), + n1: round(n1, 1), + n2: round(n2, 1), + egt: round(egt, 0), + fuelFlow: round(fuelFlow, 0), + fuelQuantity: round(fuelQuantity, 0), + latitude: round(sampleTrack(TRACK.latitude, seconds), 6), + longitude: round(sampleTrack(TRACK.longitude, seconds), 6), + tspiAltitude: round(altitude + 45 + ripple(seconds, 23, 6), 0), + groundSpeed: round(groundSpeedKt(minutes) + ripple(seconds, 5, 1.5, 1), 1), + busAMessageRate: round(busAMessageRate, 0), + busAWordErrors, + busANoResponse, + busAStatus: busStatusFor(busAMessageRate, busAWordErrors, busANoResponse), + busBMessageRate: round(busBMessageRate, 0), + busBWordErrors, + busBNoResponse, + busBStatus: busStatusFor(busBMessageRate, busBWordErrors, busBNoResponse) + }; +} + +/** + * Test-card events whose timestamps fall within [start, end], in + * chronological order, spanning as many sorties as the window covers. + */ +export function eventsBetween(start, end) { + const events = []; + + if (!(end >= start)) { + return events; + } + + const firstSortie = sortieNumber(start); + const lastSortie = sortieNumber(end); + + for (let sortie = firstSortie; sortie <= lastSortie; sortie++) { + const base = sortie * SORTIE_DURATION_MS; + + TEST_CARD_EVENTS.forEach((event) => { + const utc = base + Math.round(event.offsetMin * MINUTE_MS); + + if (utc >= start && utc <= end) { + events.push({ + utc, + sortie, + testPoint: event.testPoint, + category: event.category, + message: event.message, + phase: phaseAt(event.offsetMin).key + }); + } + }); + } + + return events; +} diff --git a/example/flightTest/flightProfileSpec.js b/example/flightTest/flightProfileSpec.js new file mode 100644 index 0000000000..dc2d61862a --- /dev/null +++ b/example/flightTest/flightProfileSpec.js @@ -0,0 +1,228 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +import { + BUS_STATUS, + busStatusFor, + eventsBetween, + interpolate, + phaseAt, + PHASES, + sampleFlight, + SORTIE_DURATION_MS, + sortieStart, + TEST_CARD_EVENTS +} from './flightProfile.js'; + +const MINUTE_MS = 60_000; +const SORTIE_BASE = 1_000 * SORTIE_DURATION_MS; + +function at(minutes) { + return SORTIE_BASE + Math.round(minutes * MINUTE_MS); +} + +describe('The flight test profile', () => { + it('interpolates between keyframes and clamps outside them', () => { + const keyframes = [ + [0, 0], + [10, 100] + ]; + + expect(interpolate(keyframes, -5)).toBe(0); + expect(interpolate(keyframes, 0)).toBe(0); + expect(interpolate(keyframes, 5)).toBeCloseTo(50, 6); + expect(interpolate(keyframes, 10)).toBe(100); + expect(interpolate(keyframes, 15)).toBe(100); + expect(interpolate(keyframes, 2.5)).toBeLessThan(25); + expect(interpolate(keyframes, 7.5)).toBeGreaterThan(75); + }); + + it('is deterministic for a given timestamp', () => { + const timestamp = at(13.3); + + expect(sampleFlight(timestamp)).toEqual(sampleFlight(timestamp)); + + const { utc, sortie, ...state } = sampleFlight(timestamp); + const { + utc: nextUtc, + sortie: nextSortie, + ...nextState + } = sampleFlight(timestamp + SORTIE_DURATION_MS); + + expect(nextState).toEqual(state); + expect(nextUtc).toBe(utc + SORTIE_DURATION_MS); + expect(nextSortie).toBe(sortie + 1); + }); + + it('aligns sorties to a fixed period', () => { + expect(sortieStart(SORTIE_BASE + 12345)).toBe(SORTIE_BASE); + expect(sampleFlight(SORTIE_BASE).elapsedSeconds).toBe(0); + expect(sampleFlight(SORTIE_BASE - 1).elapsedSeconds).toBeCloseTo( + SORTIE_DURATION_MS / 1000 - 0.001, + 3 + ); + }); + + it('walks through every flight phase in order', () => { + const observed = []; + + for (let minute = 0; minute < 24; minute += 0.5) { + const phase = sampleFlight(at(minute)).phase; + + if (observed.at(-1) !== phase) { + observed.push(phase); + } + } + + expect(observed).toEqual(PHASES.map((phase) => phase.key)); + expect(phaseAt(30).key).toBe('APPROACH'); + }); + + it('flies a climb, level cruise, and descent', () => { + const onDeck = sampleFlight(at(0)); + const climbing = sampleFlight(at(3)); + const cruise = sampleFlight(at(8)); + const cruiseLater = sampleFlight(at(11)); + const descending = sampleFlight(at(19)); + const landed = sampleFlight(at(23.9)); + + expect(onDeck.altitude).toBeLessThan(200); + expect(climbing.altitude).toBeGreaterThan(onDeck.altitude); + expect(climbing.pitch).toBeGreaterThan(5); + expect(cruise.altitude).toBeGreaterThan(24000); + expect(Math.abs(cruise.altitude - cruiseLater.altitude)).toBeLessThan(500); + expect(descending.pitch).toBeLessThan(0); + expect(descending.altitude).toBeLessThan(cruise.altitude); + expect(landed.altitude).toBeLessThan(200); + expect(landed.airspeed).toBeLessThan(onDeck.airspeed + 50); + }); + + it('drives Nz and AOA through the warning and critical bands during the wind-up turn', () => { + const beforeTurn = sampleFlight(at(11.5)); + const peak = sampleFlight(at(13.35)); + const recovered = sampleFlight(at(15)); + + expect(beforeTurn.nz).toBeLessThan(1.5); + expect(beforeTurn.aoa).toBeLessThan(10); + expect(Math.abs(peak.roll)).toBeGreaterThan(45); + expect(peak.nz).toBeGreaterThanOrEqual(6.5); + expect(peak.aoa).toBeGreaterThanOrEqual(25); + expect(recovered.nz).toBeLessThan(1.5); + expect(recovered.aoa).toBeLessThan(10); + + let sawWarningOnlyNz = false; + for (let minute = 12; minute < 14; minute += 1 / 60) { + const state = sampleFlight(at(minute)); + if (state.nz >= 5.5 && state.nz < 6.5) { + sawWarningOnlyNz = true; + } + } + expect(sawWarningOnlyNz).toBe(true); + }); + + it('keeps engine and fuel values physically plausible', () => { + const cruise = sampleFlight(at(8)); + const later = sampleFlight(at(20)); + + expect(cruise.n1).toBeGreaterThan(50); + expect(cruise.n1).toBeLessThanOrEqual(105); + expect(cruise.n2).toBeGreaterThan(cruise.n1); + expect(cruise.egt).toBeGreaterThan(400); + expect(cruise.egt).toBeLessThan(1000); + expect(cruise.fuelFlow).toBeGreaterThan(0); + expect(later.fuelQuantity).toBeLessThan(cruise.fuelQuantity); + expect(later.fuelQuantity).toBeGreaterThan(0); + }); + + it('produces a moving TSPI track', () => { + const early = sampleFlight(at(2)); + const late = sampleFlight(at(10)); + + expect(early.latitude).not.toBe(late.latitude); + expect(early.longitude).not.toBe(late.longitude); + expect(late.groundSpeed).toBeGreaterThan(300); + expect(Math.abs(late.tspiAltitude - late.altitude)).toBeLessThan(1000); + }); + + it('classifies bus health from the counters', () => { + expect(busStatusFor(1000, 0, 0)).toBe(BUS_STATUS.NOMINAL); + expect(busStatusFor(1000, 5, 0)).toBe(BUS_STATUS.DEGRADED); + expect(busStatusFor(1000, 0, 1)).toBe(BUS_STATUS.DEGRADED); + expect(busStatusFor(1000, 20, 0)).toBe(BUS_STATUS.FAILED); + expect(busStatusFor(1000, 0, 10)).toBe(BUS_STATUS.FAILED); + expect(busStatusFor(50, 0, 0)).toBe(BUS_STATUS.FAILED); + }); + + it('degrades, fails, fails over and restores Bus B while Bus A stays nominal', () => { + const nominal = sampleFlight(at(14)); + const degraded = sampleFlight(at(15.5)); + const failed = sampleFlight(at(16.2)); + const failedOver = sampleFlight(at(16.8)); + const restored = sampleFlight(at(18)); + + expect(nominal.busBStatus).toBe(BUS_STATUS.NOMINAL); + expect(degraded.busBStatus).toBe(BUS_STATUS.DEGRADED); + expect(degraded.busBWordErrors).toBeGreaterThanOrEqual(5); + expect(degraded.busBWordErrors).toBeLessThan(20); + expect(failed.busBStatus).toBe(BUS_STATUS.FAILED); + expect(failed.busBWordErrors).toBeGreaterThanOrEqual(20); + expect(failedOver.busBMessageRate).toBeLessThan(10); + expect(failedOver.busBStatus).toBe(BUS_STATUS.FAILED); + expect(restored.busBStatus).toBe(BUS_STATUS.NOMINAL); + + [nominal, degraded, failed, failedOver, restored].forEach((state) => { + expect(state.busAStatus).toBe(BUS_STATUS.NOMINAL); + expect(state.busAMessageRate).toBeGreaterThan(500); + }); + }); + + it('returns test card events in chronological order within a window', () => { + const events = eventsBetween(at(11), at(17)); + + expect(events.map((event) => event.message)).toEqual([ + 'TP-04 wind-up turn 4g start', + 'TP-04 Nz exceedance, knock it off', + 'TP-04 complete', + 'Bus B degraded, word errors rising', + 'Bus B failed, no-response count rising', + 'Bus B failover', + 'TP-06 descent start' + ]); + events.forEach((event, index) => { + if (index > 0) { + expect(event.utc).toBeGreaterThan(events[index - 1].utc); + } + }); + expect(events[0].testPoint).toBe('TP-04'); + expect(events[0].category).toBe('TEST_POINT'); + expect(events[0].phase).toBe('WIND_UP_TURN'); + }); + + it('spans sortie boundaries and honors inclusive window edges', () => { + const twoSorties = eventsBetween(SORTIE_BASE, SORTIE_BASE + 2 * SORTIE_DURATION_MS - 1); + + expect(twoSorties.length).toBe(TEST_CARD_EVENTS.length * 2); + expect(eventsBetween(at(12), at(12)).length).toBe(1); + expect(eventsBetween(at(12.1), at(12.2)).length).toBe(0); + expect(eventsBetween(at(13), at(12)).length).toBe(0); + }); +}); diff --git a/example/flightTest/parameters.js b/example/flightTest/parameters.js new file mode 100644 index 0000000000..2727c94d59 --- /dev/null +++ b/example/flightTest/parameters.js @@ -0,0 +1,380 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +import { BUS_STATUS_ENUMERATIONS } from './flightProfile.js'; + +export const NAMESPACE = 'flight-test'; +export const ROOT_KEY = 'root'; +export const TEST_ARTICLE_KEY = 'ta-01'; + +export const TYPES = { + PARAMETER: 'flight-test.parameter', + EVENTS: 'flight-test.events' +}; + +export const FOLDERS = [ + { key: 'ta-01.pcm', name: 'PCM Parameters', group: 'pcm' }, + { key: 'ta-01.bus', name: 'MIL-STD-1553 Bus Health', group: 'bus' }, + { key: 'ta-01.tspi', name: 'TSPI', group: 'tspi' }, + { key: 'ta-01.events', name: 'Test Card Events', group: 'events' } +]; + +/** + * Exceedance thresholds. `high` values are inclusive: a value at or above + * the threshold is in that limit band. + */ +const NZ_LIMITS = { WARNING: { high: 5.5 }, CRITICAL: { high: 6.5 } }; +const AOA_LIMITS = { WARNING: { high: 20 }, CRITICAL: { high: 25 } }; +const EGT_LIMITS = { WARNING: { high: 900 }, CRITICAL: { high: 950 } }; +const WORD_ERROR_LIMITS = { WARNING: { high: 5 }, CRITICAL: { high: 20 } }; + +/** + * Every measurand exposed by the test article. `field` names the property + * of the sampled flight state (see flightProfile.js) that feeds the value. + */ +export const PARAMETERS = [ + { + key: 'ta-01.pcm.altitude', + group: 'pcm', + name: 'Pressure Altitude', + field: 'altitude', + unit: 'ft', + formatString: '%0.0f', + description: 'Pressure altitude from the air data computer, 29.92 inHg reference.' + }, + { + key: 'ta-01.pcm.airspeed', + group: 'pcm', + name: 'Indicated Airspeed', + field: 'airspeed', + unit: 'kt', + formatString: '%0.1f', + description: 'Indicated airspeed from the pitot-static system.' + }, + { + key: 'ta-01.pcm.aoa', + group: 'pcm', + name: 'Angle of Attack', + field: 'aoa', + unit: 'deg', + formatString: '%0.2f', + limits: AOA_LIMITS, + description: 'Fuselage-referenced angle of attack from the nose boom vane.' + }, + { + key: 'ta-01.pcm.pitch', + group: 'pcm', + name: 'Pitch Attitude', + field: 'pitch', + unit: 'deg', + formatString: '%0.2f', + description: 'Pitch attitude from the inertial reference unit.' + }, + { + key: 'ta-01.pcm.roll', + group: 'pcm', + name: 'Roll Attitude', + field: 'roll', + unit: 'deg', + formatString: '%0.2f', + description: 'Roll attitude from the inertial reference unit, right wing down positive.' + }, + { + key: 'ta-01.pcm.yaw', + group: 'pcm', + name: 'Yaw / Heading', + field: 'yaw', + unit: 'deg', + formatString: '%0.2f', + description: 'True heading from the inertial reference unit.' + }, + { + key: 'ta-01.pcm.nz', + group: 'pcm', + name: 'Normal Load Factor (Nz)', + field: 'nz', + unit: 'g', + formatString: '%0.3f', + limits: NZ_LIMITS, + description: 'Normal acceleration at the center of gravity.' + }, + { + key: 'ta-01.pcm.n1', + group: 'pcm', + name: 'Engine N1', + field: 'n1', + unit: '%', + formatString: '%0.1f', + description: 'Low-pressure compressor speed, percent of rated.' + }, + { + key: 'ta-01.pcm.n2', + group: 'pcm', + name: 'Engine N2', + field: 'n2', + unit: '%', + formatString: '%0.1f', + description: 'High-pressure compressor speed, percent of rated.' + }, + { + key: 'ta-01.pcm.egt', + group: 'pcm', + name: 'Exhaust Gas Temperature', + field: 'egt', + unit: '°C', + formatString: '%0.0f', + limits: EGT_LIMITS, + description: 'Turbine exhaust gas temperature.' + }, + { + key: 'ta-01.pcm.fuel-flow', + group: 'pcm', + name: 'Fuel Flow', + field: 'fuelFlow', + unit: 'pph', + formatString: '%0.0f', + description: 'Engine fuel flow, pounds per hour.' + }, + { + key: 'ta-01.pcm.fuel-quantity', + group: 'pcm', + name: 'Fuel Quantity', + field: 'fuelQuantity', + unit: 'lb', + formatString: '%0.0f', + description: 'Total usable fuel remaining.' + }, + { + key: 'ta-01.bus.a.message-rate', + group: 'bus', + name: 'Bus A Message Rate', + field: 'busAMessageRate', + bus: 'A', + unit: 'msg/s', + formatString: '%0.0f', + description: 'MIL-STD-1553 messages per second observed on Bus A.' + }, + { + key: 'ta-01.bus.a.word-errors', + group: 'bus', + name: 'Bus A Word Errors', + field: 'busAWordErrors', + bus: 'A', + unit: 'err/s', + formatString: '%0.0f', + limits: WORD_ERROR_LIMITS, + description: 'Manchester, sync and parity word errors per second on Bus A.' + }, + { + key: 'ta-01.bus.a.no-response', + group: 'bus', + name: 'Bus A No-Response Count', + field: 'busANoResponse', + bus: 'A', + unit: 'count/s', + formatString: '%0.0f', + description: 'Remote terminal response timeouts per second on Bus A.' + }, + { + key: 'ta-01.bus.a.status', + group: 'bus', + name: 'Bus A Status', + field: 'busAStatus', + bus: 'A', + format: 'enum', + enumerations: BUS_STATUS_ENUMERATIONS, + description: 'Bus A health summary derived from error and response counters.' + }, + { + key: 'ta-01.bus.b.message-rate', + group: 'bus', + name: 'Bus B Message Rate', + field: 'busBMessageRate', + bus: 'B', + unit: 'msg/s', + formatString: '%0.0f', + description: 'MIL-STD-1553 messages per second observed on Bus B.' + }, + { + key: 'ta-01.bus.b.word-errors', + group: 'bus', + name: 'Bus B Word Errors', + field: 'busBWordErrors', + bus: 'B', + unit: 'err/s', + formatString: '%0.0f', + limits: WORD_ERROR_LIMITS, + description: 'Manchester, sync and parity word errors per second on Bus B.' + }, + { + key: 'ta-01.bus.b.no-response', + group: 'bus', + name: 'Bus B No-Response Count', + field: 'busBNoResponse', + bus: 'B', + unit: 'count/s', + formatString: '%0.0f', + description: 'Remote terminal response timeouts per second on Bus B.' + }, + { + key: 'ta-01.bus.b.status', + group: 'bus', + name: 'Bus B Status', + field: 'busBStatus', + bus: 'B', + format: 'enum', + enumerations: BUS_STATUS_ENUMERATIONS, + description: 'Bus B health summary derived from error and response counters.' + }, + { + key: 'ta-01.tspi.latitude', + group: 'tspi', + name: 'Latitude', + field: 'latitude', + unit: 'deg', + formatString: '%0.6f', + description: 'WGS-84 latitude from the range tracking solution.' + }, + { + key: 'ta-01.tspi.longitude', + group: 'tspi', + name: 'Longitude', + field: 'longitude', + unit: 'deg', + formatString: '%0.6f', + description: 'WGS-84 longitude from the range tracking solution.' + }, + { + key: 'ta-01.tspi.altitude', + group: 'tspi', + name: 'Geometric Altitude', + field: 'tspiAltitude', + unit: 'ft', + formatString: '%0.0f', + description: 'Height above the WGS-84 ellipsoid from the range tracking solution.' + }, + { + key: 'ta-01.tspi.ground-speed', + group: 'tspi', + name: 'Ground Speed', + field: 'groundSpeed', + unit: 'kt', + formatString: '%0.1f', + description: 'Ground speed from the range tracking solution.' + } +]; + +export const EVENT_STREAM = { + key: 'ta-01.events.test-card', + name: 'Test Card Events', + description: 'Test-point marks, exceedance calls and bus events logged by the test conductor.' +}; + +export const PARAMETERS_BY_KEY = new Map(PARAMETERS.map((parameter) => [parameter.key, parameter])); + +export function parametersInGroup(group) { + return PARAMETERS.filter((parameter) => parameter.group === group); +} + +export function parametersWithLimits() { + return PARAMETERS.filter((parameter) => parameter.limits !== undefined); +} + +/** + * Telemetry metadata for a parameter object. Every parameter uses `value` + * as its single range key so the limit evaluator and plot limit lines can + * treat all parameters uniformly. + */ +export function parameterMetadata(parameter) { + const valueMetadata = { + key: 'value', + name: parameter.name, + hints: { range: 1 } + }; + + if (parameter.unit !== undefined) { + valueMetadata.unit = parameter.unit; + } + + if (parameter.formatString !== undefined) { + valueMetadata.formatString = parameter.formatString; + } + + if (parameter.format !== undefined) { + valueMetadata.format = parameter.format; + } + + if (parameter.enumerations !== undefined) { + valueMetadata.enumerations = parameter.enumerations; + } + + return { + values: [ + { + key: 'utc', + source: 'utc', + name: 'Time', + format: 'utc', + hints: { domain: 1 } + }, + valueMetadata, + { + key: 'phase', + name: 'Flight Phase', + format: 'string' + } + ] + }; +} + +export const EVENT_METADATA = { + values: [ + { + key: 'utc', + source: 'utc', + name: 'Time', + format: 'utc', + hints: { domain: 1 } + }, + { + key: 'message', + name: 'Message', + format: 'string', + hints: { label: 0 } + }, + { + key: 'testPoint', + name: 'Test Point', + format: 'string' + }, + { + key: 'category', + name: 'Category', + format: 'string' + }, + { + key: 'phase', + name: 'Flight Phase', + format: 'string' + } + ] +}; diff --git a/example/flightTest/plugin.js b/example/flightTest/plugin.js new file mode 100644 index 0000000000..09c53dc6ba --- /dev/null +++ b/example/flightTest/plugin.js @@ -0,0 +1,69 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +import FlightTestFaultProvider from './FlightTestFaultProvider.js'; +import FlightTestLimitProvider from './FlightTestLimitProvider.js'; +import FlightTestObjectProvider from './FlightTestObjectProvider.js'; +import FlightTestTelemetryProvider from './FlightTestTelemetryProvider.js'; +import { NAMESPACE, ROOT_KEY, TYPES } from './parameters.js'; + +/** + * Example plugin for aircraft flight-test and mission-systems integration + * telemetry. Adds a "Flight Test Telemetry" root containing a fictional test + * article with PCM parameters, MIL-STD-1553 bus health, TSPI and a test-card + * event stream, backed by a deterministic simulated sortie. Exceedances are + * flagged through limits and published to Fault Management. + * + * Enable with: + * + * openmct.install(openmct.plugins.example.FlightTest()); + * + * Fault Management also requires `openmct.install(openmct.plugins.FaultManagement())` + * and a Fault Management object to view the faults. + */ +export default function FlightTestPlugin(options = {}) { + return function install(openmct) { + openmct.types.addType(TYPES.PARAMETER, { + name: 'Flight Test Parameter', + description: 'A measurand recorded from the test article instrumentation system.', + cssClass: 'icon-telemetry' + }); + + openmct.types.addType(TYPES.EVENTS, { + name: 'Test Card Event Stream', + description: 'Test-point marks and events logged during the sortie.', + cssClass: 'icon-generator-events' + }); + + openmct.objects.addRoot({ namespace: NAMESPACE, key: ROOT_KEY }); + openmct.objects.addProvider(NAMESPACE, new FlightTestObjectProvider()); + + openmct.telemetry.addProvider(new FlightTestTelemetryProvider()); + openmct.telemetry.addProvider(new FlightTestLimitProvider()); + + const faultProvider = new FlightTestFaultProvider(options.faults); + openmct.faults.addProvider(faultProvider); + + openmct.on('start', () => faultProvider.start()); + openmct.on('destroy', () => faultProvider.stop()); + }; +} diff --git a/example/flightTest/pluginSpec.js b/example/flightTest/pluginSpec.js new file mode 100644 index 0000000000..83ff826497 --- /dev/null +++ b/example/flightTest/pluginSpec.js @@ -0,0 +1,139 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +import { createOpenMct, resetApplicationState } from '../../src/utils/testing.js'; +import { SORTIE_DURATION_MS } from './flightProfile.js'; +import { NAMESPACE, ROOT_KEY, TYPES } from './parameters.js'; + +const MINUTE_MS = 60_000; +const SORTIE_BASE = 4_000 * SORTIE_DURATION_MS; + +describe('The Flight Test example plugin', () => { + let openmct; + + beforeEach(() => { + openmct = createOpenMct(); + }); + + afterEach(() => { + return resetApplicationState(openmct); + }); + + it('is registered as openmct.plugins.example.FlightTest but not installed by default', () => { + expect(openmct.plugins.example.FlightTest).toEqual(jasmine.any(Function)); + expect(openmct.types.get(TYPES.PARAMETER)).toBe(openmct.types.get('not-a-registered-type')); + expect(openmct.faults.provider).toBeUndefined(); + }); + + describe('once installed', () => { + beforeEach(() => { + openmct.install(openmct.plugins.example.FlightTest()); + }); + + it('registers the parameter and event types', () => { + expect(openmct.types.get(TYPES.PARAMETER).definition.name).toBe('Flight Test Parameter'); + expect(openmct.types.get(TYPES.EVENTS).definition.name).toBe('Test Card Event Stream'); + }); + + it('adds the Flight Test Telemetry root and resolves the tree', async () => { + const roots = await openmct.objects.rootRegistry.getRoots(); + const root = await openmct.objects.get({ namespace: NAMESPACE, key: ROOT_KEY }); + const testArticle = await openmct.objects.get(root.composition[0]); + const composition = openmct.composition.get(testArticle); + const folders = await composition.load(); + + expect(roots).toContain(jasmine.objectContaining({ namespace: NAMESPACE, key: ROOT_KEY })); + expect(root.name).toBe('Flight Test Telemetry'); + expect(testArticle.name).toBe('Test Article TA-01'); + expect(folders.map((folder) => folder.name)).toEqual([ + 'PCM Parameters', + 'MIL-STD-1553 Bus Health', + 'TSPI', + 'Test Card Events' + ]); + }); + + it('serves telemetry, limits and metadata through the telemetry API', async () => { + const nz = await openmct.objects.get({ namespace: NAMESPACE, key: 'ta-01.pcm.nz' }); + const metadata = openmct.telemetry.getMetadata(nz); + const start = SORTIE_BASE + 13 * MINUTE_MS; + const data = await openmct.telemetry.request(nz, { start, end: start + 5000, size: 6 }); + const limits = await openmct.telemetry.getLimits(nz).limits(); + const evaluator = openmct.telemetry.limitEvaluator(nz); + + expect(openmct.telemetry.isTelemetryObject(nz)).toBe(true); + expect(metadata.value('value').unit).toBe('g'); + expect(metadata.valuesForHints(['range'])[0].key).toBe('value'); + expect(metadata.valuesForHints(['domain'])[0].key).toBe('utc'); + expect(data.length).toBe(6); + expect(data[0].utc).toBe(start); + expect(limits.CRITICAL.high.value).toBe(6.5); + expect(evaluator.evaluate({ value: 7 }, metadata.value('value')).cssClass).toContain( + 'is-limit--red' + ); + }); + + it('leaves parameters without limits unevaluated', async () => { + const altitude = await openmct.objects.get({ + namespace: NAMESPACE, + key: 'ta-01.pcm.altitude' + }); + const metadata = openmct.telemetry.getMetadata(altitude); + const evaluator = openmct.telemetry.limitEvaluator(altitude); + const limits = await openmct.telemetry.getLimits(altitude).limits(); + + expect(evaluator.evaluate({ value: 99999 }, metadata.value('value'))).toBeUndefined(); + expect(limits).toBeUndefined(); + }); + + it('registers the fault provider with the Fault Management API', async () => { + const faultManagement = { type: 'faultManagement', identifier: { namespace: '', key: 'fm' } }; + + expect(openmct.faults.provider).toBeDefined(); + expect(openmct.faults.provider.supportsRequest(faultManagement)).toBe(true); + expect(openmct.faults.provider.supportsSubscribe(faultManagement)).toBe(true); + expect(openmct.faults.getShelveDurations().length).toBeGreaterThan(0); + + openmct.faults.provider.evaluate(SORTIE_BASE + 13.35 * MINUTE_MS); + const faults = await openmct.faults.request(faultManagement); + + expect(faults.map((entry) => entry.fault.id).sort()).toEqual([ + 'ta-01.pcm.aoa', + 'ta-01.pcm.nz' + ]); + }); + + it('starts monitoring on start and stops on destroy', () => { + const provider = openmct.faults.provider; + spyOn(provider, 'start').and.callThrough(); + spyOn(provider, 'stop').and.callThrough(); + + openmct.startHeadless(); + expect(provider.start).toHaveBeenCalled(); + expect(provider.interval).toBeDefined(); + + openmct.destroy(); + expect(provider.stop).toHaveBeenCalled(); + expect(provider.interval).toBeUndefined(); + }); + }); +}); diff --git a/src/plugins/plugins.js b/src/plugins/plugins.js index 938bc51c09..008d8e195f 100644 --- a/src/plugins/plugins.js +++ b/src/plugins/plugins.js @@ -26,6 +26,7 @@ import ExampleStaleness from '../../example/exampleStalenessProvider/plugin.js'; import ExampleTags from '../../example/exampleTags/plugin.js'; import ExampleUser from '../../example/exampleUser/plugin.js'; import ExampleFaultSource from '../../example/faultManagement/exampleFaultSource.js'; +import FlightTestPlugin from '../../example/flightTest/plugin.js'; import GeneratorPlugin from '../../example/generator/plugin.js'; import ExampleImagery from '../../example/imagery/plugin.js'; import AutoflowPlugin from './autoflow/AutoflowTabularPlugin.js'; @@ -105,6 +106,7 @@ plugins.example.ExampleDataVisualizationSourcePlugin = ExampleDataVisualizationS plugins.example.ExampleTags = ExampleTags; plugins.example.Generator = () => GeneratorPlugin; plugins.example.ExampleStaleness = ExampleStaleness; +plugins.example.FlightTest = FlightTestPlugin; plugins.UTCTimeSystem = UTCTimeSystem; plugins.LocalTimeSystem = LocalTimeSystem;