diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 6607a1c553..73b902a78e 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -14,7 +14,8 @@ const config = { __OPENMCT_VERSION__: 'readonly', __OPENMCT_BUILD_DATE__: 'readonly', __OPENMCT_REVISION__: 'readonly', - __OPENMCT_BUILD_BRANCH__: 'readonly' + __OPENMCT_BUILD_BRANCH__: 'readonly', + __OPENMCT_ROOT_RELATIVE__: 'readonly' }, plugins: ['prettier', 'unicorn', 'simple-import-sort'], extends: [ diff --git a/.webpack/webpack.common.mjs b/.webpack/webpack.common.mjs index 7290ce999e..8a745cb464 100644 --- a/.webpack/webpack.common.mjs +++ b/.webpack/webpack.common.mjs @@ -48,6 +48,7 @@ const config = { generatorWorker: './example/generator/generatorWorker.js', couchDBChangesFeed: './src/plugins/persistence/couch/CouchChangesFeed.js', inMemorySearchWorker: './src/api/objects/InMemorySearchWorker.js', + compsMathWorker: './src/plugins/comps/CompsMathWorker.js', espressoTheme: './src/plugins/themes/espresso-theme.scss', snowTheme: './src/plugins/themes/snow-theme.scss', darkmatterTheme: './src/plugins/themes/darkmatter-theme.scss' @@ -89,7 +90,8 @@ const config = { __OPENMCT_REVISION__: `'${gitRevision}'`, __OPENMCT_BUILD_BRANCH__: `'${gitBranch}'`, __VUE_OPTIONS_API__: true, // enable/disable Options API support, default: true - __VUE_PROD_DEVTOOLS__: false // enable/disable devtools support in production, default: false + __VUE_PROD_DEVTOOLS__: false, // enable/disable devtools support in production, default: false + __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false, // enable/disable hydration mismatch details in production, default: false }), new VueLoaderPlugin(), new CopyWebpackPlugin({ diff --git a/e2e/appActions.js b/e2e/appActions.js index 7bb82726f2..bdc56a9fa0 100644 --- a/e2e/appActions.js +++ b/e2e/appActions.js @@ -116,6 +116,22 @@ async function createDomainObjectWithDefaults(page, { type, name, parent = 'mine }; } +/** + * Retrieves the properties of an OpenMCT domain object by its identifier. + * + * @param {import('@playwright/test').Page} page - The Playwright page object. + * @param {string | identifier - The identifier or UUID of the domain object. + * @returns {Promise} An object containing the properties of the domain object. + */ +async function getDomainObject(page, identifier) { + const domainObject = await page.evaluate(async (objIdentifier) => { + const object = await window.openmct.objects.get(objIdentifier); + return object; + }, identifier); + + return domainObject; +} + /** * Generate a notification with the given options. * @param {import('@playwright/test').Page} page @@ -705,6 +721,7 @@ export { createStableStateTelemetry, expandEntireTree, getCanvasPixels, + getDomainObject, linkParameterToObject, navigateToObjectWithFixedTimeBounds, navigateToObjectWithRealTime, diff --git a/e2e/tests/functional/plugins/comps/comps.e2e.spec.js b/e2e/tests/functional/plugins/comps/comps.e2e.spec.js new file mode 100644 index 0000000000..72ce7d86e9 --- /dev/null +++ b/e2e/tests/functional/plugins/comps/comps.e2e.spec.js @@ -0,0 +1,111 @@ +/***************************************************************************** + * 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 { + createDomainObjectWithDefaults, + createExampleTelemetryObject, + setRealTimeMode +} from '../../../../appActions.js'; +import { expect, test } from '../../../../pluginFixtures.js'; + +test.describe('Comps', () => { + test.use({ failOnConsoleError: false }); + test.beforeEach(async ({ page }) => { + // Open a browser, navigate to the main page, and wait until all networkevents to resolve + await page.goto('./', { waitUntil: 'domcontentloaded' }); + }); + + test('Basic Functionality Works', async ({ page, openmctConfig }) => { + const folder = await createDomainObjectWithDefaults(page, { + type: 'Folder' + }); + + // Create the comps with defaults + const comp = await createDomainObjectWithDefaults(page, { + type: 'Derived Telemetry', + parent: folder.uuid + }); + + const telemetryObject = await createExampleTelemetryObject(page, comp.uuid); + + // Check that expressions can be edited + await page.goto(comp.url); + await page.getByLabel('Edit Object').click(); + await page.getByPlaceholder('Enter an expression').fill('a*2'); + await page.getByText('Current Output').click(); + await expect(page.getByText('Expression valid')).toBeVisible(); + + // Check that expressions are marked invalid + await page.getByLabel('Reference Name Input for a').fill('b'); + await page.getByText('Current Output').click(); + await expect(page.getByText('Invalid: Undefined symbol a')).toBeVisible(); + + // Check that test data works + await page.getByPlaceholder('Enter an expression').fill('b*2'); + await page.getByLabel('Reference Test Value for b').fill('5'); + await page.getByLabel('Apply Test Data').click(); + let testValue = await page.getByLabel('Current Output Value').textContent(); + expect(testValue).toBe('10'); + + // Check that real data works + await page.getByLabel('Apply Test Data').click(); + await setRealTimeMode(page); + testValue = await page.getByLabel('Current Output Value').textContent(); + expect(testValue).not.toBe('10'); + // should be a number + expect(parseFloat(testValue)).not.toBeNaN(); + + // Check that object path is correct + const { myItemsFolderName } = openmctConfig; + let objectPath = await page.getByLabel(`${telemetryObject.name} Object Path`).textContent(); + const expectedObjectPath = `/${myItemsFolderName}/${folder.name}/${comp.name}/${telemetryObject.name}`; + expect(objectPath).toBe(expectedObjectPath); + + // Check that the comps are saved + await page.getByLabel('Save').click(); + await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); + const expression = await page.getByLabel('Expression', { exact: true }).textContent(); + expect(expression).toBe('b*2'); + + // Check that object path is still correct after save + objectPath = await page.getByLabel(`${telemetryObject.name} Object Path`).textContent(); + expect(objectPath).toBe(expectedObjectPath); + + // Check that comps work after being saved + testValue = await page.getByLabel('Current Output Value').textContent(); + expect(testValue).not.toBe('10'); + // should be a number + expect(parseFloat(testValue)).not.toBeNaN(); + + // Check that output format can be changed + await page.getByLabel('Edit Object').click(); + await page.getByRole('tab', { name: 'Config' }).click(); + await page.getByLabel('Output Format').click(); + await page.getByLabel('Output Format').fill('%d'); + await page.getByRole('tab', { name: 'Config' }).click(); + // Ensure we only have one digit + await expect(page.getByLabel('Current Output Value')).toHaveText(/^-1$|^0$|^1$/); + // And that it persists post save + await page.getByLabel('Save').click(); + await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); + await expect(page.getByLabel('Current Output Value')).toHaveText(/^-1$|^0$|^1$/); + }); +}); diff --git a/e2e/tests/functional/plugins/conditionSet/conditionSet.e2e.spec.js b/e2e/tests/functional/plugins/conditionSet/conditionSet.e2e.spec.js index 6b76088b39..9ebe193431 100644 --- a/e2e/tests/functional/plugins/conditionSet/conditionSet.e2e.spec.js +++ b/e2e/tests/functional/plugins/conditionSet/conditionSet.e2e.spec.js @@ -29,7 +29,8 @@ import { fileURLToPath } from 'url'; import { createDomainObjectWithDefaults, - createExampleTelemetryObject + createExampleTelemetryObject, + getDomainObject } from '../../../../appActions.js'; import { expect, test } from '../../../../pluginFixtures.js'; @@ -468,6 +469,34 @@ test.describe('Basic Condition Set Use', () => { description: 'https://github.com/nasa/openmct/issues/7484' }); }); + + test('should toggle shouldFetchHistorical property in inspector', async ({ page }) => { + await page.goto(conditionSet.url); + await page.getByLabel('Edit Object').click(); + await page.getByRole('tab', { name: 'Config' }).click(); + let toggleSwitch = page.getByLabel('condition-historical-toggle'); + const initialState = await toggleSwitch.isChecked(); + expect(initialState).toBe(false); + + await toggleSwitch.click(); + let toggledState = await toggleSwitch.isChecked(); + expect(toggledState).toBe(true); + await page.click('button[title="Save"]'); + await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); + let conditionSetObject = await getDomainObject(page, conditionSet.uuid); + expect(conditionSetObject.configuration.shouldFetchHistorical).toBe(true); + + await page.getByLabel('Edit Object').click(); + await page.getByRole('tab', { name: 'Config' }).click(); + toggleSwitch = page.getByLabel('condition-historical-toggle'); + await toggleSwitch.click(); + toggledState = await toggleSwitch.isChecked(); + expect(toggledState).toBe(false); + await page.click('button[title="Save"]'); + await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); + conditionSetObject = await getDomainObject(page, conditionSet.uuid); + expect(conditionSetObject.configuration.shouldFetchHistorical).toBe(false); + }); }); test.describe('Condition Set Composition', () => { diff --git a/karma.conf.cjs b/karma.conf.cjs index e96836acbb..4304662402 100644 --- a/karma.conf.cjs +++ b/karma.conf.cjs @@ -66,6 +66,10 @@ module.exports = async (config) => { { pattern: 'dist/generatorWorker.js*', included: false + }, + { + pattern: 'dist/historicalTelemetryWorker.js*', + included: false } ], port: 9876, diff --git a/package-lock.json b/package-lock.json index 7dc0829bb9..ab90e82caa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,6 +62,7 @@ "location-bar": "3.0.1", "lodash": "4.17.21", "marked": "12.0.0", + "mathjs": "13.1.1", "mini-css-extract-plugin": "2.7.6", "moment": "2.30.1", "moment-duration-format": "2.3.2", @@ -643,6 +644,18 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.6.tgz", + "integrity": "sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==", + "dev": true, + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.24.0", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", @@ -3088,6 +3101,19 @@ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "dev": true }, + "node_modules/complex.js": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.1.1.tgz", + "integrity": "sha512-8njCHOTtFFLtegk6zQo0kkVX1rngygb/KQI6z1qZxlFI3scluC+LVTCFbrkWjBv4vvLlbQ9t88IPMC6k95VTTg==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://www.patreon.com/infusion" + } + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -4033,6 +4059,12 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "dev": true + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4483,6 +4515,12 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "dev": true }, + "node_modules/escape-latex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz", + "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==", + "dev": true + }, "node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -5817,6 +5855,19 @@ "node": ">= 0.6" } }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -7063,6 +7114,12 @@ "integrity": "sha512-UrzO3fL7nnxlQXlvTynNAenL+21oUQRlzqQFsA2U11ryb4+NLOCOePZ70PTojEaUKhiFugh7dG0Q+I58xlPdWg==", "dev": true }, + "node_modules/javascript-natural-sort": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", + "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", + "dev": true + }, "node_modules/jest-worker": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", @@ -7708,6 +7765,29 @@ "node": ">= 18" } }, + "node_modules/mathjs": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-13.1.1.tgz", + "integrity": "sha512-duaSAy7m4F+QtP1Dyv8MX2XuxcqpNDDlGly0SdVTCqpAmwdOFWilDdQKbLdo9RfD6IDNMOdo9tIsEaTXkconlQ==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.25.4", + "complex.js": "^2.1.1", + "decimal.js": "^10.4.3", + "escape-latex": "^1.2.0", + "fraction.js": "^4.3.7", + "javascript-natural-sort": "^0.7.1", + "seedrandom": "^3.0.5", + "tiny-emitter": "^2.1.0", + "typed-function": "^4.2.1" + }, + "bin": { + "mathjs": "bin/cli.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -9491,6 +9571,12 @@ "node": ">= 0.10" } }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true + }, "node_modules/regex-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.0.tgz", @@ -9847,6 +9933,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", + "dev": true + }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -10833,6 +10925,15 @@ "node": ">= 0.6" } }, + "node_modules/typed-function": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.1.tgz", + "integrity": "sha512-EGjWssW7Tsk4DGfE+5yluuljS1OGYWiI1J6e8puZz9nTMM51Oug8CD5Zo4gWMsOhq5BI+1bF+rWTm4Vbj3ivRA==", + "dev": true, + "engines": { + "node": ">= 18" + } + }, "node_modules/typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", diff --git a/package.json b/package.json index 6e96eace12..be1e65a21b 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "location-bar": "3.0.1", "lodash": "4.17.21", "marked": "12.0.0", + "mathjs": "13.1.1", "mini-css-extract-plugin": "2.7.6", "moment": "2.30.1", "moment-duration-format": "2.3.2", diff --git a/src/MCT.js b/src/MCT.js index 6c16c3e8c2..4c875263e2 100644 --- a/src/MCT.js +++ b/src/MCT.js @@ -306,6 +306,7 @@ export class MCT extends EventEmitter { this.install(this.plugins.UserIndicator()); this.install(this.plugins.Gauge()); this.install(this.plugins.InspectorViews()); + this.install(this.plugins.Comps()); } /** * Set path to where assets are hosted. This should be the path to main.js. diff --git a/src/api/telemetry/TelemetryAPI.js b/src/api/telemetry/TelemetryAPI.js index e5be70590f..81fa577550 100644 --- a/src/api/telemetry/TelemetryAPI.js +++ b/src/api/telemetry/TelemetryAPI.js @@ -760,6 +760,15 @@ export default class TelemetryAPI { return this.metadataCache.get(domainObject); } + /** + * Remove a domain object from the telemetry metadata cache. + * @param {import('openmct').DomainObject} domainObject + */ + + removeMetadataFromCache(domainObject) { + this.metadataCache.delete(domainObject); + } + /** * Get a value formatter for a given valueMetadata. * diff --git a/src/api/telemetry/TelemetryCollection.js b/src/api/telemetry/TelemetryCollection.js index 602eb5ce87..ed1463933e 100644 --- a/src/api/telemetry/TelemetryCollection.js +++ b/src/api/telemetry/TelemetryCollection.js @@ -86,14 +86,23 @@ export default class TelemetryCollection extends EventEmitter { } this._setTimeSystem(this.options.timeContext.getTimeSystem()); this.lastBounds = this.options.timeContext.getBounds(); + // prioritize passed options over time bounds + if (this.options.start) { + this.lastBounds.start = this.options.start; + } + if (this.options.end) { + this.lastBounds.end = this.options.end; + } this._watchBounds(); this._watchTimeSystem(); this._watchTimeModeChange(); - this._requestHistoricalTelemetry(); + const historicalTelemetryLoadedPromise = this._requestHistoricalTelemetry(); this._initiateSubscriptionTelemetry(); this.loaded = true; + + return historicalTelemetryLoadedPromise; } /** @@ -113,6 +122,7 @@ export default class TelemetryCollection extends EventEmitter { } this.removeAllListeners(); + this.loaded = false; } /** @@ -168,7 +178,7 @@ export default class TelemetryCollection extends EventEmitter { return; } - this._processNewTelemetry(historicalData); + this._processNewTelemetry(historicalData, false); } /** @@ -182,10 +192,9 @@ export default class TelemetryCollection extends EventEmitter { const options = { ...this.options }; //We always want to receive all available values in telemetry tables. options.strategy = this.openmct.telemetry.SUBSCRIBE_STRATEGY.BATCH; - this.unsubscribe = this.openmct.telemetry.subscribe( this.domainObject, - (datum) => this._processNewTelemetry(datum), + (datum) => this._processNewTelemetry(datum, true), options ); } @@ -196,9 +205,10 @@ export default class TelemetryCollection extends EventEmitter { * * @param {(Object|Object[])} telemetryData - telemetry data object or * array of telemetry data objects + * @param {boolean} isSubscriptionData - `true` if the telemetry data is new subscription data, * @private */ - _processNewTelemetry(telemetryData) { + _processNewTelemetry(telemetryData, isSubscriptionData = false) { if (telemetryData === undefined) { return; } @@ -213,12 +223,19 @@ export default class TelemetryCollection extends EventEmitter { let hasDataBeforeStartBound = false; let size = this.options.size; let enforceSize = size !== undefined && this.options.enforceSize; + const boundsToUse = this.lastBounds; + if (!isSubscriptionData && this.options.start) { + boundsToUse.start = this.options.start; + } + if (!isSubscriptionData && this.options.end) { + boundsToUse.end = this.options.end; + } // loop through, sort and dedupe for (let datum of data) { parsedValue = this.parseTime(datum); - beforeStartOfBounds = parsedValue < this.lastBounds.start; - afterEndOfBounds = parsedValue > this.lastBounds.end; + beforeStartOfBounds = parsedValue < boundsToUse.start; + afterEndOfBounds = parsedValue > boundsToUse.end; if ( !afterEndOfBounds && @@ -397,7 +414,10 @@ export default class TelemetryCollection extends EventEmitter { this.emit('add', added, [this.boundedTelemetry.length]); } } else { - // user bounds change, reset + // user bounds change, reset and remove initial requested bounds (we're using new bounds) + delete this.options?.start; + delete this.options?.end; + this.lastBounds = bounds; this._reset(); } } @@ -477,9 +497,9 @@ export default class TelemetryCollection extends EventEmitter { this.boundedTelemetry = []; this.futureBuffer = []; - this.emit('clear'); + const telemetryLoadPromise = this._requestHistoricalTelemetry(); - this._requestHistoricalTelemetry(); + this.emit('clear', telemetryLoadPromise); } /** diff --git a/src/plugins/comps/CompsInspectorViewProvider.js b/src/plugins/comps/CompsInspectorViewProvider.js new file mode 100644 index 0000000000..d2241ef2db --- /dev/null +++ b/src/plugins/comps/CompsInspectorViewProvider.js @@ -0,0 +1,85 @@ +/***************************************************************************** + * 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 mount from 'utils/mount'; + +import CompsInspectorView from './components/CompsInspectorView.vue'; + +export default class ConditionSetViewProvider { + constructor(openmct, compsManagerPool) { + this.openmct = openmct; + this.name = 'Config'; + this.key = 'comps-configuration'; + this.compsManagerPool = compsManagerPool; + } + + canView(selection) { + if (selection.length !== 1 || selection[0].length === 0) { + return false; + } + + let object = selection[0][0].context.item; + return object && object.type === 'comps'; + } + + view(selection) { + let _destroy = null; + const domainObject = selection[0][0].context.item; + const openmct = this.openmct; + const compsManagerPool = this.compsManagerPool; + + return { + show: function (element) { + const { destroy } = mount( + { + el: element, + components: { + CompsInspectorView: CompsInspectorView + }, + provide: { + openmct, + domainObject, + compsManagerPool + }, + template: '' + }, + { + app: openmct.app, + element + } + ); + _destroy = destroy; + }, + showTab: function (isEditing) { + return isEditing; + }, + priority: function () { + return 1; + }, + destroy: function () { + if (_destroy) { + _destroy(); + } + } + }; + } +} diff --git a/src/plugins/comps/CompsManager.js b/src/plugins/comps/CompsManager.js new file mode 100644 index 0000000000..0fc0651619 --- /dev/null +++ b/src/plugins/comps/CompsManager.js @@ -0,0 +1,379 @@ +import { EventEmitter } from 'eventemitter3'; + +export default class CompsManager extends EventEmitter { + #openmct; + #domainObject; + #composition; + #telemetryObjects = {}; + #telemetryCollections = {}; + #telemetryLoadedPromises = []; + #telemetryOptions = {}; + #loaded = false; + #compositionLoaded = false; + #telemetryProcessors = {}; + #loadVersion = 0; + #currentLoadPromise = null; + + constructor(openmct, domainObject) { + super(); + this.#openmct = openmct; + this.#domainObject = domainObject; + this.clearData = this.clearData.bind(this); + } + + #getNextAlphabeticalParameterName() { + const parameters = this.#domainObject.configuration.comps.parameters; + const existingNames = new Set(parameters.map((p) => p.name)); + const alphabet = 'abcdefghijklmnopqrstuvwxyz'; + let suffix = ''; + // eslint-disable-next-line no-constant-condition + while (true) { + for (let letter of alphabet) { + const proposedName = letter + suffix; + if (!existingNames.has(proposedName)) { + return proposedName; + } + } + // Increment suffix after exhausting the alphabet + suffix = (parseInt(suffix, 10) || 0) + 1; + } + } + + addParameter(telemetryObject) { + const keyString = this.#openmct.objects.makeKeyString(telemetryObject.identifier); + const metaData = this.#openmct.telemetry.getMetadata(telemetryObject); + const timeSystem = this.#openmct.time.getTimeSystem(); + const domains = metaData?.valuesForHints(['domain']); + const timeMetaData = domains.find((d) => d.key === timeSystem.key); + // in the valuesMetadata, find the first numeric data type + const rangeItems = metaData.valueMetadatas.filter( + (metaDatum) => metaDatum.hints && metaDatum.hints.range + ); + rangeItems.sort((a, b) => a.hints.range - b.hints.range); + let valueToUse = rangeItems[0]?.key; + if (!valueToUse) { + // if no numeric data type, just use the first one + valueToUse = metaData.valueMetadatas[0]?.key; + } + this.#domainObject.configuration.comps.parameters.push({ + keyString, + name: `${this.#getNextAlphabeticalParameterName()}`, + valueToUse, + testValue: 0, + timeMetaData, + accumulateValues: false, + sampleSize: 10 + }); + this.emit('parameterAdded', this.#domainObject); + } + + getParameters() { + const parameters = this.#domainObject.configuration.comps.parameters; + const parametersWithTimeKey = parameters.map((parameter) => { + return { + ...parameter, + timeKey: this.#telemetryCollections[parameter.keyString]?.timeKey + }; + }); + return parametersWithTimeKey; + } + + getTelemetryObjectForParameter(keyString) { + return this.#telemetryObjects[keyString]; + } + + getMetaDataValuesForParameter(keyString) { + const telemetryObject = this.getTelemetryObjectForParameter(keyString); + const metaData = this.#openmct.telemetry.getMetadata(telemetryObject); + return metaData.valueMetadatas; + } + + deleteParameter(keyString) { + this.#domainObject.configuration.comps.parameters = + this.#domainObject.configuration.comps.parameters.filter( + (parameter) => parameter.keyString !== keyString + ); + // if there are no parameters referencing this parameter keyString, remove the telemetry object too + const parameterExists = this.#domainObject.configuration.comps.parameters.some( + (parameter) => parameter.keyString === keyString + ); + if (!parameterExists) { + this.emit('parameterRemoved', this.#domainObject); + } + } + + setDomainObject(passedDomainObject) { + this.#domainObject = passedDomainObject; + } + + isReady() { + return this.#loaded; + } + + async load(telemetryOptions) { + // Increment the load version to mark a new load operation + const loadVersion = ++this.#loadVersion; + + if (!_.isEqual(this.#telemetryOptions, telemetryOptions)) { + this.#destroy(); + } + + this.#telemetryOptions = telemetryOptions; + + // Start the load process and store the promise + this.#currentLoadPromise = (async () => { + // Load composition if not already loaded + if (!this.#compositionLoaded) { + await this.#loadComposition(); + // Check if a newer load has been initiated + if (loadVersion !== this.#loadVersion) { + await this.#currentLoadPromise; + return; + } + this.#compositionLoaded = true; + } + + // Start listening to telemetry if not already done + if (!this.#loaded) { + await this.#startListeningToUnderlyingTelemetry(); + // Check again for newer load + if (loadVersion !== this.#loadVersion) { + await this.#currentLoadPromise; + return; + } + this.#loaded = true; + } + })(); + + // Await the load process + await this.#currentLoadPromise; + } + + async #startListeningToUnderlyingTelemetry() { + Object.keys(this.#telemetryCollections).forEach((keyString) => { + if (!this.#telemetryCollections[keyString].loaded) { + this.#telemetryCollections[keyString].on('add', this.#getTelemetryProcessor(keyString)); + this.#telemetryCollections[keyString].on('clear', this.clearData); + const telemetryLoadedPromise = this.#telemetryCollections[keyString].load(); + this.#telemetryLoadedPromises.push(telemetryLoadedPromise); + } + }); + await Promise.all(this.#telemetryLoadedPromises); + this.#telemetryLoadedPromises = []; + } + + #destroy() { + this.stopListeningToUnderlyingTelemetry(); + this.#composition = null; + this.#telemetryCollections = {}; + this.#compositionLoaded = false; + this.#loaded = false; + this.#telemetryObjects = {}; + } + + stopListeningToUnderlyingTelemetry() { + this.#loaded = false; + Object.keys(this.#telemetryCollections).forEach((keyString) => { + const specificTelemetryProcessor = this.#telemetryProcessors[keyString]; + delete this.#telemetryProcessors[keyString]; + this.#telemetryCollections[keyString].off('add', specificTelemetryProcessor); + this.#telemetryCollections[keyString].off('clear', this.clearData); + this.#telemetryCollections[keyString].destroy(); + }); + } + + getTelemetryObjects() { + return this.#telemetryObjects; + } + + async #loadComposition() { + this.#composition = this.#openmct.composition.get(this.#domainObject); + if (this.#composition) { + this.#composition.on('add', this.#addTelemetryObject); + this.#composition.on('remove', this.#removeTelemetryObject); + await this.#composition.load(); + } + } + + #getParameterForKeyString(keyString) { + return this.#domainObject.configuration.comps.parameters.find( + (parameter) => parameter.keyString === keyString + ); + } + + #getImputedDataUsingLOCF(datum, telemetryCollection) { + const telemetryCollectionData = telemetryCollection.getAll(); + let insertionPointForNewData = telemetryCollection._sortedIndex(datum); + if (insertionPointForNewData && insertionPointForNewData >= telemetryCollectionData.length) { + insertionPointForNewData = telemetryCollectionData.length - 1; + } + // get the closest datum to the new datum + const closestDatum = telemetryCollectionData[insertionPointForNewData]; + // clone the closest datum and replace the time key with the new time + const imputedData = { + ...closestDatum, + [telemetryCollection.timeKey]: datum[telemetryCollection.timeKey] + }; + return imputedData; + } + + getDataFrameForRequest() { + // Step 1: Collect all unique timestamps from all telemetry collections + const allTimestampsSet = new Set(); + + Object.values(this.#telemetryCollections).forEach((collection) => { + collection.getAll().forEach((dataPoint) => { + allTimestampsSet.add(dataPoint.timestamp); + }); + }); + + // Convert the set to a sorted array + const allTimestamps = Array.from(allTimestampsSet).sort((a, b) => a - b); + + // Step 2: Initialize the result object + const telemetryForComps = {}; + + // Step 3: Iterate through each telemetry collection to align data + Object.keys(this.#telemetryCollections).forEach((keyString) => { + const telemetryCollection = this.#telemetryCollections[keyString]; + const alignedValues = []; + + // Iterate through each common timestamp + allTimestamps.forEach((timestamp) => { + const timeKey = telemetryCollection.timeKey; + const fakeData = { [timeKey]: timestamp }; + const imputedDatum = this.#getImputedDataUsingLOCF(fakeData, telemetryCollection); + if (imputedDatum) { + alignedValues.push(imputedDatum); + } + }); + + telemetryForComps[keyString] = alignedValues; + }); + + return telemetryForComps; + } + + getDataFrameForSubscription(newTelemetry) { + const telemetryForComps = {}; + const newTelemetryKey = Object.keys(newTelemetry)[0]; + const newTelemetryParameter = this.#getParameterForKeyString(newTelemetryKey); + const newTelemetryData = newTelemetry[newTelemetryKey]; + const otherTelemetryKeys = Object.keys(this.#telemetryCollections).slice(0); + if (newTelemetryParameter.accumulateValues) { + telemetryForComps[newTelemetryKey] = this.#telemetryCollections[newTelemetryKey].getAll(); + } else { + telemetryForComps[newTelemetryKey] = newTelemetryData; + } + otherTelemetryKeys.forEach((keyString) => { + telemetryForComps[keyString] = []; + }); + + const otherTelemetryKeysNotAccumulating = otherTelemetryKeys.filter( + (keyString) => !this.#getParameterForKeyString(keyString).accumulateValues + ); + const otherTelemetryKeysAccumulating = otherTelemetryKeys.filter( + (keyString) => this.#getParameterForKeyString(keyString).accumulateValues + ); + + // if we're accumulating, just add all the data + otherTelemetryKeysAccumulating.forEach((keyString) => { + telemetryForComps[keyString] = this.#telemetryCollections[keyString].getAll(); + }); + + // for the others, march through the new telemetry data and add data to the frame from the other telemetry objects + // using LOCF + newTelemetryData.forEach((newDatum) => { + otherTelemetryKeysNotAccumulating.forEach((otherKeyString) => { + const otherCollection = this.#telemetryCollections[otherKeyString]; + const imputedDatum = this.#getImputedDataUsingLOCF(newDatum, otherCollection); + if (imputedDatum) { + telemetryForComps[otherKeyString].push(imputedDatum); + } + }); + }); + return telemetryForComps; + } + + #removeTelemetryObject = (telemetryObjectIdentifier) => { + const keyString = this.#openmct.objects.makeKeyString(telemetryObjectIdentifier); + delete this.#telemetryObjects[keyString]; + this.#telemetryCollections[keyString]?.destroy(); + delete this.#telemetryCollections[keyString]; + // remove all parameters that reference this telemetry object + this.deleteParameter(keyString); + }; + + #requestUnderlyingTelemetry() { + const underlyingTelemetry = {}; + Object.keys(this.#telemetryCollections).forEach((collectionKey) => { + const collection = this.#telemetryCollections[collectionKey]; + underlyingTelemetry[collectionKey] = collection.getAll(); + }); + return underlyingTelemetry; + } + + #getTelemetryProcessor(keyString) { + if (this.#telemetryProcessors[keyString]) { + return this.#telemetryProcessors[keyString]; + } + + const telemetryProcessor = (newTelemetry) => { + this.emit('underlyingTelemetryUpdated', { [keyString]: newTelemetry }); + }; + this.#telemetryProcessors[keyString] = telemetryProcessor; + return telemetryProcessor; + } + + #telemetryProcessor = (newTelemetry, keyString) => { + this.emit('underlyingTelemetryUpdated', { [keyString]: newTelemetry }); + }; + + clearData(telemetryLoadedPromise) { + this.#loaded = false; + if (telemetryLoadedPromise) { + this.#telemetryLoadedPromises.push(telemetryLoadedPromise); + } + } + + setOutputFormat(outputFormat) { + this.#domainObject.configuration.comps.outputFormat = outputFormat; + this.emit('outputFormatChanged', outputFormat); + } + + getOutputFormat() { + return this.#domainObject.configuration.comps.outputFormat; + } + + getExpression() { + return this.#domainObject.configuration.comps.expression; + } + + #addTelemetryObject = (telemetryObject) => { + const keyString = this.#openmct.objects.makeKeyString(telemetryObject.identifier); + this.#telemetryObjects[keyString] = telemetryObject; + this.#telemetryCollections[keyString] = this.#openmct.telemetry.requestCollection( + telemetryObject, + this.#telemetryOptions + ); + + // check to see if we have a corresponding parameter + // if not, add one + const parameterExists = this.#domainObject.configuration.comps.parameters.some( + (parameter) => parameter.keyString === keyString + ); + if (!parameterExists) { + this.addParameter(telemetryObject); + } + }; + + static getCompsManager(domainObject, openmct, compsManagerPool) { + const id = openmct.objects.makeKeyString(domainObject.identifier); + + if (!compsManagerPool[id]) { + compsManagerPool[id] = new CompsManager(openmct, domainObject); + } + + return compsManagerPool[id]; + } +} diff --git a/src/plugins/comps/CompsMathWorker.js b/src/plugins/comps/CompsMathWorker.js new file mode 100644 index 0000000000..a0097c00ef --- /dev/null +++ b/src/plugins/comps/CompsMathWorker.js @@ -0,0 +1,139 @@ +import { evaluate } from 'mathjs'; + +// eslint-disable-next-line no-undef +onconnect = function (e) { + const port = e.ports[0]; + + port.onmessage = function (event) { + const { type, callbackID, telemetryForComps, expression, parameters, newTelemetry } = + event.data; + let responseType = 'unknown'; + let error = null; + let result = []; + try { + if (type === 'calculateRequest') { + responseType = 'calculationRequestResult'; + console.debug(`📫 Received new calculation request with callback ID ${callbackID}`); + result = calculateRequest(telemetryForComps, parameters, expression); + } else if (type === 'calculateSubscription') { + responseType = 'calculationSubscriptionResult'; + result = calculateSubscription(telemetryForComps, newTelemetry, parameters, expression); + } else if (type === 'init') { + port.postMessage({ type: 'ready' }); + return; + } else { + throw new Error('Invalid message type'); + } + } catch (errorInCalculation) { + error = errorInCalculation; + } + console.debug(`📭 Sending response for callback ID ${callbackID}`, result); + port.postMessage({ type: responseType, callbackID, result, error }); + }; +}; + +function getFullDataFrame(telemetryForComps, parameters) { + const dataFrame = {}; + Object.keys(telemetryForComps)?.forEach((key) => { + const parameter = parameters.find((p) => p.keyString === key); + const dataSet = telemetryForComps[key]; + const telemetryMap = new Map(dataSet.map((item) => [item[parameter.timeKey], item])); + dataFrame[key] = telemetryMap; + }); + return dataFrame; +} + +function calculateSubscription(telemetryForComps, newTelemetry, parameters, expression) { + const dataFrame = getFullDataFrame(telemetryForComps, parameters); + const calculation = calculate(dataFrame, parameters, expression); + const newTelemetryKey = Object.keys(newTelemetry)[0]; + const newTelemetrySize = newTelemetry[newTelemetryKey].length; + let trimmedCalculation = calculation; + if (calculation.length > newTelemetrySize) { + trimmedCalculation = calculation.slice(calculation.length - newTelemetrySize); + } + return trimmedCalculation; +} + +function calculateRequest(telemetryForComps, parameters, expression) { + const dataFrame = getFullDataFrame(telemetryForComps, parameters); + return calculate(dataFrame, parameters, expression); +} + +function calculate(dataFrame, parameters, expression) { + const sumResults = []; + // ensure all parameter keyStrings have corresponding telemetry data + if (!expression) { + return sumResults; + } + // set up accumulated data structure + const accumulatedData = {}; + parameters.forEach((parameter) => { + if (parameter.accumulateValues) { + accumulatedData[parameter.name] = []; + } + }); + + // take the first parameter keyString as the reference + const referenceParameter = parameters[0]; + const otherParameters = parameters.slice(1); + // iterate over the reference telemetry data + const referenceTelemetry = dataFrame[referenceParameter.keyString]; + referenceTelemetry?.forEach((referenceTelemetryItem) => { + let referenceValue = referenceTelemetryItem[referenceParameter.valueToUse]; + if (referenceParameter.accumulateValues) { + accumulatedData[referenceParameter.name].push(referenceValue); + referenceValue = accumulatedData[referenceParameter.name]; + } + if ( + referenceParameter.accumulateValues && + referenceParameter.sampleSize && + referenceParameter.sampleSize > 0 + ) { + // enforce sample size by ensuring referenceValue has the latest n elements + // if we don't have at least the sample size, skip this iteration + if (!referenceValue.length || referenceValue.length < referenceParameter.sampleSize) { + return; + } + referenceValue = referenceValue.slice(-referenceParameter.sampleSize); + } + + const scope = { + [referenceParameter.name]: referenceValue + }; + const referenceTime = referenceTelemetryItem[referenceParameter.timeKey]; + // iterate over the other parameters to set the scope + let missingData = false; + otherParameters.forEach((parameter) => { + const otherDataFrame = dataFrame[parameter.keyString]; + const otherTelemetry = otherDataFrame.get(referenceTime); + if (otherTelemetry === undefined || otherTelemetry === null) { + missingData = true; + return; + } + let otherValue = otherTelemetry[parameter.valueToUse]; + if (parameter.accumulateValues) { + accumulatedData[parameter.name].push(referenceValue); + otherValue = accumulatedData[referenceParameter.name]; + } + scope[parameter.name] = otherValue; + }); + if (missingData) { + console.debug('🤦‍♂️ Missing data for some parameters, skipping calculation'); + return; + } + const rawComputedValue = evaluate(expression, scope); + let computedValue = rawComputedValue; + if (computedValue.entries) { + // if there aren't any entries, return with nothing + if (computedValue.entries.length === 0) { + return; + } + console.debug('📊 Computed value is an array of entries', computedValue.entries); + // make array of arrays of entries + computedValue = computedValue.entries?.[0]; + } + sumResults.push({ [referenceParameter.timeKey]: referenceTime, value: computedValue }); + }); + return sumResults; +} diff --git a/src/plugins/comps/CompsMetadataProvider.js b/src/plugins/comps/CompsMetadataProvider.js new file mode 100644 index 0000000000..1b4a489277 --- /dev/null +++ b/src/plugins/comps/CompsMetadataProvider.js @@ -0,0 +1,79 @@ +/***************************************************************************** + * 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 CompsManager from './CompsManager.js'; + +export default class CompsMetadataProvider { + #openmct = null; + #compsManagerPool = null; + + constructor(openmct, compsManagerPool) { + this.#openmct = openmct; + this.#compsManagerPool = compsManagerPool; + } + + supportsMetadata(domainObject) { + return domainObject.type === 'comps'; + } + + getDefaultDomains(domainObject) { + return this.#openmct.time.getAllTimeSystems().map(function (ts, i) { + return { + key: ts.key, + name: ts.name, + format: ts.timeFormat, + hints: { + domain: i + } + }; + }); + } + + getMetadata(domainObject) { + const specificCompsManager = CompsManager.getCompsManager( + domainObject, + this.#openmct, + this.#compsManagerPool + ); + // if there are any parameters, grab the first one's timeMetaData + const timeMetaData = specificCompsManager?.getParameters()[0]?.timeMetaData; + const metaDataToReturn = { + values: [ + { + key: 'value', + name: 'Value', + derived: true, + formatString: specificCompsManager.getOutputFormat(), + hints: { + range: 1 + } + } + ] + }; + if (timeMetaData) { + metaDataToReturn.values.push(timeMetaData); + } else { + const defaultDomains = this.getDefaultDomains(domainObject); + metaDataToReturn.values.push(...defaultDomains); + } + return metaDataToReturn; + } +} diff --git a/src/plugins/comps/CompsTelemetryProvider.js b/src/plugins/comps/CompsTelemetryProvider.js new file mode 100644 index 0000000000..c53676d80d --- /dev/null +++ b/src/plugins/comps/CompsTelemetryProvider.js @@ -0,0 +1,175 @@ +/***************************************************************************** + * 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 CompsManager from './CompsManager.js'; + +export default class CompsTelemetryProvider { + #openmct = null; + #sharedWorker = null; + #compsManagerPool = null; + #lastUniqueID = 1; + #requestPromises = {}; + #subscriptionCallbacks = {}; + // id is random 4 digit number + #id = Math.floor(Math.random() * 9000) + 1000; + + constructor(openmct, compsManagerPool) { + this.#openmct = openmct; + this.#compsManagerPool = compsManagerPool; + this.#openmct.on('start', this.#startSharedWorker.bind(this)); + } + + isTelemetryObject(domainObject) { + return domainObject.type === 'comps'; + } + + supportsRequest(domainObject) { + return domainObject.type === 'comps'; + } + + supportsSubscribe(domainObject) { + return domainObject.type === 'comps'; + } + + #getCallbackID() { + return this.#lastUniqueID++; + } + + request(domainObject, options) { + return new Promise((resolve, reject) => { + const specificCompsManager = CompsManager.getCompsManager( + domainObject, + this.#openmct, + this.#compsManagerPool + ); + specificCompsManager.load(options).then(() => { + const callbackID = this.#getCallbackID(); + const telemetryForComps = JSON.parse( + JSON.stringify(specificCompsManager.getDataFrameForRequest()) + ); + const expression = specificCompsManager.getExpression(); + const parameters = JSON.parse(JSON.stringify(specificCompsManager.getParameters())); + if (!expression || !parameters) { + resolve([]); + return; + } + this.#requestPromises[callbackID] = { resolve, reject }; + const payload = { + type: 'calculateRequest', + telemetryForComps, + expression, + parameters, + callbackID + }; + this.#sharedWorker.port.postMessage(payload); + }); + }); + } + + #computeOnNewTelemetry(specificCompsManager, callbackID, newTelemetry) { + if (!specificCompsManager.isReady()) { + return; + } + const expression = specificCompsManager.getExpression(); + const telemetryForComps = specificCompsManager.getDataFrameForSubscription(newTelemetry); + const parameters = JSON.parse(JSON.stringify(specificCompsManager.getParameters())); + if (!expression || !parameters) { + return; + } + const payload = { + type: 'calculateSubscription', + telemetryForComps, + newTelemetry, + expression, + parameters, + callbackID + }; + this.#sharedWorker.port.postMessage(payload); + } + + subscribe(domainObject, callback) { + const specificCompsManager = CompsManager.getCompsManager( + domainObject, + this.#openmct, + this.#compsManagerPool + ); + const callbackID = this.#getCallbackID(); + this.#subscriptionCallbacks[callbackID] = callback; + const boundComputeOnNewTelemetry = this.#computeOnNewTelemetry.bind( + this, + specificCompsManager, + callbackID + ); + specificCompsManager.on('underlyingTelemetryUpdated', boundComputeOnNewTelemetry); + const telemetryOptions = { + strategy: 'latest', + size: 1 + }; + specificCompsManager.load(telemetryOptions); + return () => { + delete this.#subscriptionCallbacks[callbackID]; + specificCompsManager.stopListeningToUnderlyingTelemetry(); + specificCompsManager.off('underlyingTelemetryUpdated', boundComputeOnNewTelemetry); + }; + } + + #startSharedWorker() { + if (this.#sharedWorker) { + throw new Error('Shared worker already started'); + } + const sharedWorkerURL = `${this.#openmct.getAssetPath()}${__OPENMCT_ROOT_RELATIVE__}compsMathWorker.js`; + + this.#sharedWorker = new SharedWorker(sharedWorkerURL, `Comps Math Worker`); + this.#sharedWorker.port.onmessage = this.onSharedWorkerMessage.bind(this); + this.#sharedWorker.port.onmessageerror = this.onSharedWorkerMessageError.bind(this); + this.#sharedWorker.port.start(); + + this.#sharedWorker.port.postMessage({ type: 'init' }); + + this.#openmct.on('destroy', () => { + this.#sharedWorker.port.close(); + }); + } + + onSharedWorkerMessage(event) { + const { type, result, callbackID, error } = event.data; + if ( + type === 'calculationSubscriptionResult' && + this.#subscriptionCallbacks[callbackID] && + result.length + ) { + this.#subscriptionCallbacks[callbackID](result); + } else if (type === 'calculationRequestResult' && this.#requestPromises[callbackID]) { + if (error) { + console.error('📝 Error calculating request:', event.data); + this.#requestPromises[callbackID].resolve([]); + } else { + this.#requestPromises[callbackID].resolve(result); + } + delete this.#requestPromises[callbackID]; + } + } + + onSharedWorkerMessageError(event) { + console.error('❌ Shared worker message error:', event); + } +} diff --git a/src/plugins/comps/CompsViewProvider.js b/src/plugins/comps/CompsViewProvider.js new file mode 100644 index 0000000000..7939670e7f --- /dev/null +++ b/src/plugins/comps/CompsViewProvider.js @@ -0,0 +1,98 @@ +/***************************************************************************** + * 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 mount from 'utils/mount'; + +import CompsView from './components/CompsView.vue'; + +const DEFAULT_VIEW_PRIORITY = 100; + +export default class ConditionSetViewProvider { + constructor(openmct, compsManagerPool) { + this.openmct = openmct; + this.name = 'Comps View'; + this.key = 'comps.view'; + this.cssClass = 'icon-derived-telemetry'; + this.compsManagerPool = compsManagerPool; + } + + canView(domainObject, objectPath) { + return domainObject.type === 'comps' && this.openmct.router.isNavigatedObject(objectPath); + } + + canEdit(domainObject, objectPath) { + return domainObject.type === 'comps' && this.openmct.router.isNavigatedObject(objectPath); + } + + view(domainObject, objectPath) { + let _destroy = null; + let component = null; + + return { + show: (container, isEditing) => { + const { vNode, destroy } = mount( + { + el: container, + components: { + CompsView + }, + provide: { + openmct: this.openmct, + domainObject, + objectPath, + compsManagerPool: this.compsManagerPool + }, + data() { + return { + isEditing + }; + }, + template: '' + }, + { + app: this.openmct.app, + element: container + } + ); + _destroy = destroy; + component = vNode.componentInstance; + }, + onEditModeChange: (isEditing) => { + component.isEditing = isEditing; + }, + destroy: () => { + if (_destroy) { + _destroy(); + } + component = null; + } + }; + } + + priority(domainObject) { + if (domainObject.type === 'comps') { + return Number.MAX_VALUE; + } else { + return DEFAULT_VIEW_PRIORITY; + } + } +} diff --git a/src/plugins/comps/components/CompsInspectorView.vue b/src/plugins/comps/components/CompsInspectorView.vue new file mode 100644 index 0000000000..ff02eb94e7 --- /dev/null +++ b/src/plugins/comps/components/CompsInspectorView.vue @@ -0,0 +1,77 @@ + + + + diff --git a/src/plugins/comps/components/CompsView.vue b/src/plugins/comps/components/CompsView.vue new file mode 100644 index 0000000000..1c2b599e4d --- /dev/null +++ b/src/plugins/comps/components/CompsView.vue @@ -0,0 +1,399 @@ + + + + + diff --git a/src/plugins/comps/components/comps.scss b/src/plugins/comps/components/comps.scss new file mode 100644 index 0000000000..988abd34ee --- /dev/null +++ b/src/plugins/comps/components/comps.scss @@ -0,0 +1,153 @@ +/***************************************************************************** + * 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. + *****************************************************************************/ +@mixin expressionMsg($fg, $bg) { + $op: 0.4; + color: rgba($fg, $op * 1.5); + background: rgba($bg, $op); +} + +.c-comps { + display: flex; + flex-direction: column; + gap: $interiorMarginLg; + + .is-editing & { + padding: $interiorMargin; + } + + &__output { + display: flex; + align-items: baseline; + gap: $interiorMargin; + + &-label { + flex: 0 0 auto; + text-transform: uppercase; + } + + &-value { + flex: 0 1 auto; + } + } + + &__section, + &__refs { + display: flex; + flex-direction: column; + gap: $interiorMarginSm; + } + + + &__apply-test-data-control { + padding: $interiorMargin 0; + } + + &__refs { + + } + + &__ref { + @include discreteItem(); + align-items: start; + display: flex; + flex-direction: column; + padding: 0 $interiorMargin; + line-height: 170%; // Aligns text with controls like selects + + > * + * { + border-top: 1px solid $colorInteriorBorder; + } + } + + &__ref-section { + align-items: baseline; + display: flex; + flex-wrap: wrap; + gap: $interiorMargin; + padding: $interiorMargin 0; + width: 100%; + } + + &__ref-sub-section { + align-items: baseline; + display: flex; + flex: 1 1 auto; + gap: $interiorMargin; + + &.ref-and-path { + flex: 0 1 auto; + flex-wrap: wrap; + } + } + + &__path-and-field { + align-items: start; + display: flex; + flex-wrap: wrap; + gap: $interiorMargin; + + .c-comp__ref-path { + word-break: break-all; + } + } + + &__label, + &__value { + white-space: nowrap; + } + + &__expression { + *[class*=value] { + font-family: monospace; + resize: vertical; // Only applies to textarea + } + div[class*=value] { + padding: $interiorMargin; + } + } + + &__expression-msg { + @include expressionMsg($colorOkFg, $colorOk); + border-radius: $basicCr; + display: flex; // Creates hanging indent from :before icon + padding: $interiorMarginSm $interiorMarginLg $interiorMarginSm $interiorMargin; + max-width: max-content; + + &:before { + content: $glyph-icon-check; + font-family: symbolsfont; + margin-right: $interiorMarginSm; + } + + &.--bad { + @include expressionMsg($colorErrorFg, $colorError); + + &:before { + content: $glyph-icon-alert-triangle; + } + } + } + + .--em { + color: $colorBodyFgEm; + } +} diff --git a/src/plugins/comps/plugin.js b/src/plugins/comps/plugin.js new file mode 100644 index 0000000000..19c1676954 --- /dev/null +++ b/src/plugins/comps/plugin.js @@ -0,0 +1,60 @@ +/***************************************************************************** + * 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 CompsInspectorViewProvider from './CompsInspectorViewProvider.js'; +import CompsMetadataProvider from './CompsMetadataProvider.js'; +import CompsTelemetryProvider from './CompsTelemetryProvider.js'; +import CompsViewProvider from './CompsViewProvider.js'; + +export default function CompsPlugin() { + const compsManagerPool = {}; + + return function install(openmct) { + openmct.types.addType('comps', { + name: 'Derived Telemetry', + key: 'comps', + description: + 'Add one or more telemetry end points, apply a mathematical operation to them, and output the result as new telemetry.', + creatable: true, + cssClass: 'icon-derived-telemetry', + initialize: function (domainObject) { + domainObject.configuration = { + comps: { + expression: '', + parameters: [] + } + }; + domainObject.composition = []; + domainObject.telemetry = {}; + } + }); + openmct.composition.addPolicy((parent, child) => { + if (parent.type === 'comps' && !openmct.telemetry.isTelemetryObject(child)) { + return false; + } + return true; + }); + openmct.telemetry.addProvider(new CompsMetadataProvider(openmct, compsManagerPool)); + openmct.telemetry.addProvider(new CompsTelemetryProvider(openmct, compsManagerPool)); + openmct.objectViews.addProvider(new CompsViewProvider(openmct, compsManagerPool)); + openmct.inspectorViews.addProvider(new CompsInspectorViewProvider(openmct, compsManagerPool)); + }; +} diff --git a/src/plugins/condition/ConditionInspectorViewProvider.js b/src/plugins/condition/ConditionInspectorViewProvider.js new file mode 100644 index 0000000000..fc972f60ea --- /dev/null +++ b/src/plugins/condition/ConditionInspectorViewProvider.js @@ -0,0 +1,53 @@ +// src/plugins/condition/ConditionInspectorView.js + +import mount from 'utils/mount'; + +import ConditionConfigView from './components/ConditionInspectorConfigView.vue'; + +export default function ConditionInspectorView(openmct) { + return { + key: 'condition-config', + name: 'Config', + canView: function (selection) { + return selection.length > 0 && selection[0][0].context.item.type === 'conditionSet'; + }, + view: function (selection) { + let _destroy = null; + const domainObject = selection[0][0].context.item; + + return { + show: function (element) { + const { destroy } = mount( + { + el: element, + components: { + ConditionConfigView: ConditionConfigView + }, + provide: { + openmct, + domainObject + }, + template: '' + }, + { + app: openmct.app, + element + } + ); + _destroy = destroy; + }, + showTab: function (isEditing) { + return isEditing; + }, + priority: function () { + return 1; + }, + destroy: function () { + if (_destroy) { + _destroy(); + } + } + }; + } + }; +} diff --git a/src/plugins/condition/ConditionManager.js b/src/plugins/condition/ConditionManager.js index 4a18140cf7..10b7409eb6 100644 --- a/src/plugins/condition/ConditionManager.js +++ b/src/plugins/condition/ConditionManager.js @@ -24,6 +24,7 @@ import { EventEmitter } from 'eventemitter3'; import { v4 as uuid } from 'uuid'; import Condition from './Condition.js'; +import HistoricalTelemetryProvider from './HistoricalTelemetryProvider.js'; import { getLatestTimestamp } from './utils/time.js'; export default class ConditionManager extends EventEmitter { @@ -46,6 +47,8 @@ export default class ConditionManager extends EventEmitter { applied: false }; this.initialize(); + this.telemetryBuffer = []; + this.isProcessing = false; } subscribeToTelemetry(telemetryObject) { @@ -320,69 +323,18 @@ export default class ConditionManager extends EventEmitter { return currentCondition; } - getCurrentConditionLAD(conditionResults) { - const conditionCollection = this.conditionSetDomainObject.configuration.conditionCollection; - let currentCondition = conditionCollection[conditionCollection.length - 1]; - - for (let i = 0; i < conditionCollection.length - 1; i++) { - if (conditionResults[conditionCollection[i].id]) { - //first condition to be true wins - currentCondition = conditionCollection[i]; - break; - } - } - - return currentCondition; - } - - async requestLADConditionSetOutput(options) { - if (!this.conditions.length) { + async getHistoricalData(options) { + if (!this.conditionSetDomainObject.configuration.shouldFetchHistorical) { return []; } - - await this.compositionLoad; - - let latestTimestamp; - let conditionResults = {}; - let nextLegOptions = { ...options }; - delete nextLegOptions.onPartialResponse; - - const results = await Promise.all( - this.conditions.map((condition) => condition.requestLADConditionResult(nextLegOptions)) + const historicalTelemetry = new HistoricalTelemetryProvider( + this.openmct, + this.conditions, + this.conditionSetDomainObject, + options ); - - results.forEach((resultObj) => { - const { - id, - data, - data: { result } - } = resultObj; - - if (this.findConditionById(id)) { - conditionResults[id] = Boolean(result); - } - - latestTimestamp = getLatestTimestamp( - latestTimestamp, - data, - this.timeSystems, - this.openmct.time.getTimeSystem() - ); - }); - - if (!Object.values(latestTimestamp).some((timeSystem) => timeSystem)) { - return []; - } - - const currentCondition = this.getCurrentConditionLAD(conditionResults); - const currentOutput = { - output: currentCondition.configuration.output, - id: this.conditionSetDomainObject.identifier, - conditionId: currentCondition.id, - ...latestTimestamp - }; - - return [currentOutput]; + const historicalData = await historicalTelemetry.getHistoricalData(); + return historicalData; } isTelemetryUsed(endpoint) { @@ -409,13 +361,16 @@ export default class ConditionManager extends EventEmitter { const datum = data[0]; const normalizedDatum = this.createNormalizedDatum(datum, endpoint); - const timeSystemKey = this.openmct.time.getTimeSystem().key; - let timestamp = {}; - const currentTimestamp = normalizedDatum[timeSystemKey]; - timestamp[timeSystemKey] = currentTimestamp; - if (this.shouldEvaluateNewTelemetry(currentTimestamp)) { + const keyString = this.openmct.objects.makeKeyString(endpoint.identifier); + const associatedTelemetryCollection = this.telemetryCollections[keyString]; + const timeKey = associatedTelemetryCollection.timeKey; + const formattedTimeStamp = associatedTelemetryCollection.parseTime(datum); + const rawTimestamp = { + [timeKey]: datum[timeKey] + }; + if (this.shouldEvaluateNewTelemetry(formattedTimeStamp)) { this.updateConditionResults(normalizedDatum); - this.updateCurrentCondition(timestamp); + this.updateCurrentCondition(rawTimestamp, endpoint, datum); } } @@ -428,20 +383,75 @@ export default class ConditionManager extends EventEmitter { }); } - updateCurrentCondition(timestamp) { - const currentCondition = this.getCurrentCondition(); + emitConditionSetResult(currentCondition, timestamp, outputValue, result) { + const conditionSetResult = { + id: this.conditionSetDomainObject.identifier, + conditionId: currentCondition.id, + value: outputValue, + result, + ...timestamp + }; + this.emit('conditionSetResultUpdated', conditionSetResult); + } - this.emit( - 'conditionSetResultUpdated', - Object.assign( - { - output: currentCondition.configuration.output, - id: this.conditionSetDomainObject.identifier, - conditionId: currentCondition.id - }, + updateCurrentCondition(timestamp, telemetryObject, telemetryData) { + this.telemetryBuffer.push({ timestamp, telemetryObject, telemetryData }); + + if (!this.isProcessing) { + this.processBuffer(); + } + } + + async processBuffer() { + this.isProcessing = true; + + while (this.telemetryBuffer.length > 0) { + const { timestamp, telemetryObject, telemetryData } = this.telemetryBuffer.shift(); + await this.processCondition(timestamp, telemetryObject, telemetryData); + } + + this.isProcessing = false; + } + + fetchUnderlyingTelemetry(currentCondition, telemetryObject, telemetryData, timestamp) { + let telemetryValue; + const selectedOutputIdentifier = currentCondition?.configuration?.outputTelemetry; + const outputMetadata = currentCondition?.configuration?.outputMetadata; + const telemetryKeystring = this.openmct.objects.makeKeyString(telemetryObject.identifier); + + if (selectedOutputIdentifier === telemetryKeystring) { + telemetryValue = telemetryData[outputMetadata]; + } else { + // grab it from the associated telemetry collection + const outputTelemetryCollection = this.telemetryCollections[selectedOutputIdentifier]; + const latestTelemetryData = outputTelemetryCollection.getAll(); + const lastValue = latestTelemetryData[latestTelemetryData.length - 1]; + if (lastValue && lastValue?.[outputMetadata]) { + telemetryValue = lastValue?.[outputMetadata]; + } else { + telemetryValue = null; + } + } + return telemetryValue; + } + + processCondition(timestamp, telemetryObject, telemetryData) { + const currentCondition = this.getCurrentCondition(); + const conditionDetails = this.conditions.filter( + (condition) => condition.id === currentCondition.id + )?.[0]; + const conditionResult = currentCondition?.isDefault ? false : conditionDetails?.result; + let telemetryValue = currentCondition.configuration.output; + if (currentCondition?.configuration?.outputTelemetry) { + telemetryValue = this.fetchUnderlyingTelemetry( + currentCondition, + telemetryObject, + telemetryData, timestamp - ) - ); + ); + } + + this.emitConditionSetResult(currentCondition, timestamp, telemetryValue, conditionResult); } getTestData(metadatum) { @@ -460,18 +470,7 @@ export default class ConditionManager extends EventEmitter { createNormalizedDatum(telemetryDatum, endpoint) { const id = this.openmct.objects.makeKeyString(endpoint.identifier); - const metadata = this.openmct.telemetry.getMetadata(endpoint).valueMetadatas; - - const normalizedDatum = Object.values(metadata).reduce((datum, metadatum) => { - const testValue = this.getTestData(metadatum); - const formatter = this.openmct.telemetry.getValueFormatter(metadatum); - datum[metadatum.key] = - testValue !== undefined - ? formatter.parse(testValue) - : formatter.parse(telemetryDatum[metadatum.source]); - - return datum; - }, {}); + const normalizedDatum = { ...telemetryDatum }; normalizedDatum.id = id; diff --git a/src/plugins/condition/ConditionSetMetadataProvider.js b/src/plugins/condition/ConditionSetMetadataProvider.js index 0d3190d49b..60bc434e1c 100644 --- a/src/plugins/condition/ConditionSetMetadataProvider.js +++ b/src/plugins/condition/ConditionSetMetadataProvider.js @@ -29,7 +29,7 @@ export default class ConditionSetMetadataProvider { return domainObject.type === 'conditionSet'; } - getDomains(domainObject) { + getDefaultDomains(domainObject) { return this.openmct.time.getAllTimeSystems().map(function (ts, i) { return { key: ts.key, @@ -43,34 +43,59 @@ export default class ConditionSetMetadataProvider { } getMetadata(domainObject) { - const enumerations = domainObject.configuration.conditionCollection.map((condition, index) => { - return { - string: condition.configuration.output, - value: index - }; + const format = {}; + domainObject.configuration.conditionCollection.forEach((condition, index) => { + if (condition?.configuration?.valueMetadata?.enumerations) { + delete format.formatString; + format.format = 'enum'; + format.enumerations = condition?.configuration?.valueMetadata?.enumerations; + } }); - return { - values: this.getDomains().concat([ + const resultEnum = [ + { + string: 'true', + value: true + }, + { + string: 'false', + value: false + } + ]; + + const metaDataToReturn = { + values: [ { - key: 'state', - source: 'output', - name: 'State', - format: 'enum', - enumerations: enumerations, + key: 'value', + name: 'Value', hints: { range: 1 } }, { - key: 'output', - name: 'Value', - format: 'string', + key: 'result', + source: 'result', + name: 'Result', + format: 'enum', + enumerations: resultEnum, hints: { range: 2 } } - ]) + ] }; + + // if there are any parameters, grab the first one's timeMetaData + const timeMetaData = + domainObject?.configuration?.conditionCollection[0]?.configuration.timeMetadata; + + if (timeMetaData) { + metaDataToReturn.values.push(timeMetaData); + } else { + const defaultDomains = this.getDefaultDomains(domainObject); + metaDataToReturn.values.push(...defaultDomains); + } + + return metaDataToReturn; } } diff --git a/src/plugins/condition/ConditionSetTelemetryProvider.js b/src/plugins/condition/ConditionSetTelemetryProvider.js index 38e7d4cb83..8a0d9e17e1 100644 --- a/src/plugins/condition/ConditionSetTelemetryProvider.js +++ b/src/plugins/condition/ConditionSetTelemetryProvider.js @@ -42,15 +42,17 @@ export default class ConditionSetTelemetryProvider { async request(domainObject, options) { let conditionManager = this.getConditionManager(domainObject); - let latestOutput = await conditionManager.requestLADConditionSetOutput(options); - return latestOutput; + const formattedHistoricalData = await conditionManager.getHistoricalData(options); + return formattedHistoricalData; } subscribe(domainObject, callback) { let conditionManager = this.getConditionManager(domainObject); conditionManager.on('conditionSetResultUpdated', (data) => { - callback(data); + if (data?.result) { + callback(data); + } }); return this.destroyConditionManager.bind( diff --git a/src/plugins/condition/HistoricalTelemetryProvider.js b/src/plugins/condition/HistoricalTelemetryProvider.js new file mode 100644 index 0000000000..6d866d5af1 --- /dev/null +++ b/src/plugins/condition/HistoricalTelemetryProvider.js @@ -0,0 +1,285 @@ +import { evaluateResults } from './utils/evaluator.js'; + +export default class HistoricalTelemetryProvider { + #telemetryOptions; + #telemetryObjects = {}; + #conditions; + #conditionSetDomainObject; + #openmct; + #telemetryCollections = {}; + #composition; + #outputTelemetryDetails = {}; + + constructor(openmct, conditions, conditionSetDomainObject, telemetryOptions) { + this.#openmct = openmct; + this.#conditions = conditions; + this.#conditionSetDomainObject = conditionSetDomainObject; + this.#telemetryOptions = telemetryOptions; + this.addTelemetryObject = this.addTelemetryObject.bind(this); + this.removeTelemetryObject = this.removeTelemetryObject.bind(this); + } + + #evaluateTrueCondition() { + return null; + } + + #getInputTelemetry(conditionCriteria, dataFrame, timestamp) { + if (conditionCriteria?.telemetry === 'all') { + // if the criteria is 'all', return all telemetry data + const allTelemetry = []; + Object.keys(dataFrame).forEach((key) => { + const telemetryData = dataFrame[key][timestamp]; + telemetryData.id = key; + if (telemetryData) { + allTelemetry.push(telemetryData); + } + }); + return allTelemetry; + } + if (!conditionCriteria?.telemetry) { + console.debug('🚨 Missing telemetry key in condition criteria - are we the default?'); + return null; + } + const conditionInputTelemetryKeyString = this.#openmct.objects.makeKeyString( + conditionCriteria?.telemetry + ); + + const inputTelemetryByDate = dataFrame[conditionInputTelemetryKeyString]; + if (!inputTelemetryByDate) { + console.debug(`🚨 Missing ALL data for ${conditionInputTelemetryKeyString}`); + return null; + } + const specificDatum = inputTelemetryByDate[timestamp]; + + if (!specificDatum) { + console.debug(`🚨 Missing data for ${conditionInputTelemetryKeyString} at ${timestamp}`); + return null; + } + specificDatum.id = conditionInputTelemetryKeyString; + return specificDatum; + } + + #formatDatumForOutput(datum, metadata, result) { + const formattedDatum = { + ...datum, + value: datum[metadata], + result + }; + return formattedDatum; + } + + #computeHistoricalDatum(timestamp, dataFrame, timekey) { + for (let conditionIndex = 0; conditionIndex < this.#conditions.length; conditionIndex++) { + const condition = this.#conditions[conditionIndex]; + const { id } = condition; + const conditionCriteria = condition?.criteria.length > 0; + let result = false; + let defaultHit = false; + if (conditionCriteria) { + result = evaluateResults( + condition.criteria.map((criterion) => { + const inputTelemetry = this.#getInputTelemetry(criterion, dataFrame, timestamp); + return criterion.computeResult({ id, ...inputTelemetry }); + }), + condition?.trigger + ); + } else { + // default criteria is 'all' + defaultHit = true; + } + if (result || defaultHit) { + // generate the output telemetry object if available + const outputTelmetryDetail = this.#outputTelemetryDetails[id]; + if ( + outputTelmetryDetail?.outputTelemetryKeyString && + outputTelmetryDetail?.outputMetadata + ) { + const outputTelmetryDatum = + dataFrame[outputTelmetryDetail.outputTelemetryKeyString][timestamp]; + const formattedDatum = this.#formatDatumForOutput( + outputTelmetryDatum, + outputTelmetryDetail.outputMetadata, + result + ); + return formattedDatum; + } else if (outputTelmetryDetail?.staticOutputValue) { + const staticOutput = { + output: outputTelmetryDetail?.staticOutputValue, + [timekey]: timestamp, + result + }; + return staticOutput; + } + } + } + } + + async #loadTelemetryCollections() { + await Promise.all( + Object.entries(this.#telemetryObjects).map(async ([keystring, telemetryObject]) => { + // clone telemetry options without size as we need to load all data + const telemetryOptionsWithoutSize = { ...this.#telemetryOptions }; + delete telemetryOptionsWithoutSize.size; + const telemetryCollection = this.#openmct.telemetry.requestCollection( + telemetryObject, + telemetryOptionsWithoutSize + ); + await telemetryCollection.load(); + this.#telemetryCollections[keystring] = telemetryCollection; + }) + ); + } + + #computeHistoricalData(dataFrame) { + const historicalData = []; + if (Object.keys(dataFrame).length === 0) { + // if we have no telemetry data, return an empty object + return historicalData; + } + + // use the first telemetry collection as the reference for the frame + const referenceTelemetryKeyString = Object.keys(dataFrame)[0]; + const referenceTelemetryCollection = this.#telemetryCollections[referenceTelemetryKeyString]; + const referenceTelemetryData = referenceTelemetryCollection.getAll(); + referenceTelemetryData.forEach((datum) => { + const timestamp = datum[referenceTelemetryCollection.timeKey]; + const historicalDatum = this.#computeHistoricalDatum( + timestamp, + dataFrame, + referenceTelemetryCollection.timeKey + ); + if (historicalDatum) { + historicalData.push(historicalDatum); + } + }); + return historicalData; + } + + #getImputedDataUsingLOCF(datum, telemetryCollection) { + const telemetryCollectionData = telemetryCollection.getAll(); + let insertionPointForNewData = telemetryCollection._sortedIndex(datum); + if (insertionPointForNewData && insertionPointForNewData >= telemetryCollectionData.length) { + insertionPointForNewData = telemetryCollectionData.length - 1; + } + // get the closest datum to the new datum + const closestDatum = telemetryCollectionData[insertionPointForNewData]; + // clone the closest datum and replace the time key with the new time + const imputedData = { + ...closestDatum, + [telemetryCollection.timeKey]: datum[telemetryCollection.timeKey] + }; + return imputedData; + } + + #createDataFrame() { + // Step 1: Collect all unique timestamps from all telemetry collections + const allTimestampsSet = new Set(); + + Object.values(this.#telemetryCollections).forEach((collection) => { + collection.getAll().forEach((dataPoint) => { + const timeKey = collection.timeKey; + allTimestampsSet.add(dataPoint[timeKey]); + }); + }); + + // Convert the set to a sorted array + const allTimestamps = Array.from(allTimestampsSet).sort((a, b) => a - b); + + // Step 2: Initialize the result object + const dataFrame = {}; + + // Step 3: Iterate through each telemetry collection to align data + Object.keys(this.#telemetryCollections)?.forEach((keyString) => { + const telemetryCollection = this.#telemetryCollections[keyString]; + const alignedValues = {}; + + // Iterate through each common timestamp + allTimestamps.forEach((timestamp) => { + const timeKey = telemetryCollection.timeKey; + const fakeData = { [timeKey]: timestamp }; + const imputedDatum = this.#getImputedDataUsingLOCF(fakeData, telemetryCollection); + if (imputedDatum) { + alignedValues[timestamp] = imputedDatum; + } else { + console.debug(`🚨 Missing data for ${keyString} at ${timestamp}`); + } + }); + + dataFrame[keyString] = alignedValues; + }); + return dataFrame; + } + + addTelemetryObject(telemetryObjectToAdd) { + const keyString = this.#openmct.objects.makeKeyString(telemetryObjectToAdd.identifier); + this.#telemetryObjects[keyString] = telemetryObjectToAdd; + } + + removeTelemetryObject(telemetryIdentifierToRemove) { + const keyStringToRemove = this.#openmct.objects.makeKeyString(telemetryIdentifierToRemove); + this.#telemetryObjects = this.#telemetryObjects.filter((existingTelemetryObject) => { + const existingKeyString = this.#openmct.objects.makeKeyString( + existingTelemetryObject.identifier + ); + return keyStringToRemove !== existingKeyString; + }); + } + + destroy() { + this.#composition.off('add', this.addTelemetryObject); + this.#composition.off('remove', this.removeTelemetryObject); + Object.keys(this.#telemetryCollections).forEach((key) => { + this.#telemetryCollections[key]?.destroy(); + }); + } + + async #loadComposition() { + // load the composition of the condition set + this.#composition = this.#openmct.composition.get(this.#conditionSetDomainObject); + if (this.#composition) { + this.#composition.on('add', this.addTelemetryObject); + this.#composition.on('remove', this.removeTelemetryObject); + await this.#composition.load(); + } + } + + #processConditionSet() { + const conditionCollection = this.#conditionSetDomainObject.configuration.conditionCollection; + conditionCollection.forEach((condition, index) => { + const { outputTelemetry, outputMetadata, output } = condition.configuration; + if (outputTelemetry && outputMetadata) { + this.#outputTelemetryDetails[condition.id] = { + outputTelemetryKeyString: outputTelemetry, + outputMetadata + }; + } else if (output) { + this.#outputTelemetryDetails[condition.id] = { + staticOutputValue: output + }; + } + }); + } + + async getHistoricalData() { + console.debug('🍯 Getting historical data'); + // get the telemetry objects from the condition set + await this.#loadComposition(); + console.debug('🍯 Loaded telemetry objects', this.#telemetryObjects); + if (!this.#telemetryObjects) { + console.debug('🚨 No telemetry objects found in condition set'); + return []; + } + console.debug('🍯 Processed Condition Set', this.#outputTelemetryDetails); + this.#processConditionSet(); + // load telemetry collections for each telemetry object + await this.#loadTelemetryCollections(); + console.debug('🍯 Loaded Telemetry Collections', this.#telemetryCollections); + // create data frame from telemetry collections + const dataFrame = this.#createDataFrame(); + console.debug('🍯 Created data frame', dataFrame); + // compute historical data from data frame + const computedHistoricalData = this.#computeHistoricalData(dataFrame); + console.debug('🍯 Computed historical data', computedHistoricalData); + return computedHistoricalData; + } +} diff --git a/src/plugins/condition/components/ConditionCollection.vue b/src/plugins/condition/components/ConditionCollection.vue index 84b5aff008..146955002f 100644 --- a/src/plugins/condition/components/ConditionCollection.vue +++ b/src/plugins/condition/components/ConditionCollection.vue @@ -235,10 +235,11 @@ export default { return arr; }, - addTelemetryObject(domainObject) { + async addTelemetryObject(domainObject) { const keyString = this.openmct.objects.makeKeyString(domainObject.identifier); + const telemetryPath = await this.getFullTelemetryPath(domainObject); - this.telemetryObjs.push(domainObject); + this.telemetryObjs.push({ ...domainObject, path: telemetryPath }); this.$emit('telemetry-updated', this.telemetryObjs); this.subscribeToStaleness(domainObject, (stalenessResponse) => { @@ -248,6 +249,19 @@ export default { }); }); }, + async getFullTelemetryPath(telemetry) { + const keyString = this.openmct.objects.makeKeyString(telemetry.identifier); + const originalPathObjects = await this.openmct.objects.getOriginalPath(keyString, []); + + const telemetryPath = originalPathObjects.reverse().map((pathObject) => { + if (pathObject.type !== 'root') { + return pathObject.name; + } + return undefined; + }); + + return telemetryPath.join('/'); + }, removeTelemetryObject(identifier) { const keyString = this.openmct.objects.makeKeyString(identifier); const index = this.telemetryObjs.findIndex((obj) => { diff --git a/src/plugins/condition/components/ConditionInspectorConfigView.vue b/src/plugins/condition/components/ConditionInspectorConfigView.vue new file mode 100644 index 0000000000..02c0539ec0 --- /dev/null +++ b/src/plugins/condition/components/ConditionInspectorConfigView.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/src/plugins/condition/components/ConditionItem.vue b/src/plugins/condition/components/ConditionItem.vue index b24045c59a..0554c96c21 100644 --- a/src/plugins/condition/components/ConditionItem.vue +++ b/src/plugins/condition/components/ConditionItem.vue @@ -99,13 +99,13 @@ @change="setOutputValue" > + + + + + + -
Match @@ -183,7 +216,12 @@ {{ condition.configuration.name }} - Output: {{ condition.configuration.output }} + + Output: + {{ + condition.configuration.output === undefined ? 'none' : condition.configuration.output + }} +
@@ -197,6 +235,7 @@ import { v4 as uuid } from 'uuid'; import { TRIGGER, TRIGGER_LABEL } from '@/plugins/condition/utils/constants'; +import { TELEMETRY_VALUE } from '../utils/constants.js'; import ConditionDescription from './ConditionDescription.vue'; import Criterion from './CriterionItem.vue'; @@ -252,10 +291,13 @@ export default { expanded: true, trigger: 'all', selectedOutputSelection: '', - outputOptions: ['false', 'true', 'string'], + telemetryValueString: TELEMETRY_VALUE, + outputOptions: ['none', 'false', 'true', 'string', TELEMETRY_VALUE], criterionIndex: 0, draggingOver: false, - isDefault: this.condition.isDefault + isDefault: this.condition.isDefault, + telemetryMetadataOptions: {}, + telemetryFormats: new Map() }; }, computed: { @@ -289,32 +331,64 @@ export default { return false; } }, + watch: { + condition: { + handler() { + const config = this.condition?.configuration; + if (config?.output !== TELEMETRY_VALUE) { + config.outputTelemetry = null; + config.outputMetadata = null; + config.timeMetadata = null; + } + }, + deep: true + }, + telemetry: { + handler() { + this.initializeMetadata(); + }, + deep: true + } + }, unmounted() { this.destroy(); }, mounted() { this.setOutputSelection(); + this.initializeMetadata(); }, methods: { setOutputSelection() { let conditionOutput = this.condition.configuration.output; - if (conditionOutput) { - if (conditionOutput !== 'false' && conditionOutput !== 'true') { - this.selectedOutputSelection = 'string'; - } else { - this.selectedOutputSelection = conditionOutput; - } + if (conditionOutput === undefined) { + this.selectedOutputSelection = 'none'; + } else if (['false', 'true', TELEMETRY_VALUE].includes(conditionOutput)) { + this.selectedOutputSelection = conditionOutput; + } else { + this.selectedOutputSelection = 'string'; } }, setOutputValue() { if (this.selectedOutputSelection === 'string') { this.condition.configuration.output = ''; + } else if (this.selectedOutputSelection === 'none') { + this.condition.configuration.output = undefined; } else { this.condition.configuration.output = this.selectedOutputSelection; } this.persist(); }, + getOutputMetadata() { + const config = this.condition.configuration; + let valueMetadata; + if (config?.outputTelemetry && config?.outputMetadata) { + valueMetadata = this.telemetryFormats.get( + `${config?.outputTelemetry}_${config?.outputMetadata}` + ); + } + return valueMetadata; + }, addCriteria() { const criteriaObject = { id: uuid(), @@ -396,13 +470,56 @@ export default { this.condition.configuration.criteria.splice(index + 1, 0, clonedCriterion); this.persist(); }, + persistTimeMetadata() { + if (!this.condition.configuration.outputTelemetry) { + return; + } + const outputTelemetryObject = this.telemetry.find( + (telemetryItem) => + this.openmct.objects.makeKeyString(telemetryItem.identifier) === + this.condition.configuration.outputTelemetry + ); + const timeSystem = this.openmct.time.getTimeSystem(); + const telemetryMetadata = this.openmct.telemetry.getMetadata(outputTelemetryObject); + const domains = telemetryMetadata?.valuesForHints(['domain']); + const timeMetaData = domains.find((d) => d.key === timeSystem.key); + if (telemetryMetadata) { + this.condition.configuration.timeMetadata = timeMetaData; + } + }, persist() { + const valueMetadata = this.getOutputMetadata(); + if (valueMetadata) { + this.condition.configuration.valueMetadata = valueMetadata; + this.persistTimeMetadata(); + } this.$emit('update-condition', { condition: this.condition }); }, initCap(str) { return str.charAt(0).toUpperCase() + str.slice(1); + }, + initializeMetadata() { + this.telemetry.forEach((telemetryObject) => { + const id = this.openmct.objects.makeKeyString(telemetryObject.identifier); + let telemetryMetadata = this.openmct.telemetry.getMetadata(telemetryObject); + if (telemetryMetadata) { + this.telemetryMetadataOptions[id] = telemetryMetadata.values().slice(); + telemetryMetadata.values().forEach((telemetryValue) => { + this.telemetryFormats.set(`${id}_${telemetryValue.key}`, telemetryValue); + }); + } else { + this.telemetryMetadataOptions[id] = []; + } + }); + }, + getId(identifier) { + if (identifier) { + return this.openmct.objects.makeKeyString(identifier); + } + + return []; } } }; diff --git a/src/plugins/condition/components/ConditionSet.vue b/src/plugins/condition/components/ConditionSet.vue index bdefc5ff5c..bb1a03b454 100644 --- a/src/plugins/condition/components/ConditionSet.vue +++ b/src/plugins/condition/components/ConditionSet.vue @@ -23,9 +23,9 @@