/***************************************************************************** * 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 configStore from '../configuration/ConfigStore.js'; import { MARKER_SHAPES } from '../draw/MarkerShapes.js'; import { symlog } from '../mathUtils.js'; import Model from './Model.js'; /** * Plot series handle interpreting telemetry metadata for a single telemetry * object, querying for that data, and formatting it for display purposes. * * Plot series emit both collection events and model events: * `change` when any property changes * `change:` when a specific property changes. * `destroy`: when series is destroyed * `add`: whenever a data point is added to a series * `remove`: whenever a data point is removed from a series. * `reset`: whenever the collection is emptied. * * Plot series have the following Model properties: * * `name`: name of series. * `identifier`: the Open MCT identifier for the telemetry source for this * series. * `xKey`: the telemetry value key for x values fetched from this series. * `yKey`: the telemetry value key for y values fetched from this series. * `interpolate`: interpolate method, either `undefined` (no interpolation), * `linear` (points are connected via straight lines), or * `stepAfter` (points are connected by steps). * `markers`: boolean, whether or not this series should render with markers. * `markerShape`: string, shape of markers. * `markerSize`: number, size in pixels of markers for this series. * `alarmMarkers`: whether or not to display alarm markers for this series. * `stats`: An object that tracks the min and max y values observed in this * series. This property is checked and updated whenever data is * added. * * Plot series have the following instance properties: * * `metadata`: the Open MCT Telemetry Metadata Manager for the associated * telemetry point. * `formats`: the Open MCT format map for this telemetry point. * * @extends {Model} */ const FLOAT32_MAX = 3.4e38; const FLOAT32_MIN = -3.4e38; export default class PlotSeries extends Model { logMode = false; /** @param {import('./Model').ModelOptions} options */ constructor(options) { super(options); this.logMode = this.getLogMode(options); this.listenTo(this, 'change:xKey', this.onXKeyChange, this); this.listenTo(this, 'change:yKey', this.onYKeyChange, this); this.persistedConfig = options.persistedConfig; this.filters = options.filters; // Model.apply(this, arguments); this.onXKeyChange(this.get('xKey')); this.onYKeyChange(this.get('yKey')); this.unPlottableValues = [undefined, Infinity, -Infinity]; } getLogMode(options) { const yAxisId = this.get('yAxisId'); if (yAxisId === 1) { return options.collection.plot.model.yAxis.logMode; } else { const foundYAxis = options.collection.plot.model.additionalYAxes.find( (yAxis) => yAxis.id === yAxisId ); return foundYAxis ? foundYAxis.logMode : false; } } /** * Set defaults for telemetry series. * @param {import('./Model').ModelOptions} options * @override */ defaultModel(options) { this.metadata = options.openmct.telemetry.getMetadata(options.domainObject); this.formats = options.openmct.telemetry.getFormatMap(this.metadata); //if the object is missing or doesn't have metadata for some reason let range = {}; if (this.metadata) { range = this.metadata.valuesForHints(['range'])[0]; } return { name: options.domainObject.name, unit: range.unit, xKey: options.collection.plot.xAxis.get('key'), yKey: range.key, markers: true, markerShape: 'point', markerSize: 2.0, alarmMarkers: true, limitLines: false, yAxisId: options.model.yAxisId || 1 }; } /** * Remove real-time subscription when destroyed. * @override */ destroy() { //this triggers Model.destroy which in turn triggers destroy methods for other classes. super.destroy(); this.stopListening(); this.openmct.time.off('boundsChanged', this.updateLimits); if (this.unsubscribe) { this.unsubscribe(); } if (this.unsubscribeLimits) { this.unsubscribeLimits(); } if (this.removeMutationListener) { this.removeMutationListener(); } configStore.deleteStore(this.dataStoreId); } /** * Set defaults for telemetry series. * @override * @param {import('./Model').ModelOptions} options */ initialize(options) { this.openmct = options.openmct; this.domainObject = options.domainObject; this.keyString = this.openmct.objects.makeKeyString(this.domainObject.identifier); this.dataStoreId = `data-${options.collection.plot.id}-${this.keyString}`; this.updateSeriesData([]); this.limitEvaluator = this.openmct.telemetry.limitEvaluator(options.domainObject); this.limitDefinition = this.openmct.telemetry.limitDefinition(options.domainObject); this.limits = []; this.openmct.time.on('boundsChanged', this.updateLimits); this.removeMutationListener = this.openmct.objects.observe( this.domainObject, 'name', this.updateName.bind(this) ); } /** * @param {Bounds} bounds */ updateLimits(bounds) { this.emit('limitBounds', bounds); } /** * Fetch historical data and establish a realtime subscription. Returns * a promise that is resolved when all connections have been successfully * established. * * @returns {Promise} */ async fetch(options) { let strategy; if (this.model.interpolate !== 'none') { strategy = 'minmax'; } options = Object.assign( {}, { size: 1000, strategy, filters: this.filters }, options || {} ); if (!this.unsubscribe) { this.unsubscribe = this.openmct.telemetry.subscribe( this.domainObject, (data) => { this.addAll(data, true); }, { filters: this.filters, strategy: this.openmct.telemetry.SUBSCRIBE_STRATEGY.BATCH } ); } try { const points = await this.openmct.telemetry.request(this.domainObject, options); // if derived, we can't use the old data let data = this.getSeriesData(); if (this.metadata.value(this.get('yKey')).derived) { data = []; } // eslint-disable-next-line you-dont-need-lodash-underscore/concat const newPoints = _(data) .concat(points) .sortBy(this.getXVal) .uniq(true, (point) => [this.getXVal(point), this.getYVal(point)].join()) .value(); this.reset(newPoints); } catch (error) { console.warn('Error fetching data', error); } } updateName(name) { if (name !== this.get('name')) { this.set('name', name); } } /** * Update x formatter on x change. */ onXKeyChange(xKey) { const format = this.formats[xKey]; if (format) { this.getXVal = format.parse.bind(format); } } /** * Update y formatter on change, default to stepAfter interpolation if * y range is an enumeration. */ onYKeyChange(newKey, oldKey) { if (newKey === oldKey) { return; } const valueMetadata = this.metadata.value(newKey); //TODO: Should we do this even if there is a persisted config? if (!this.persistedConfig || !this.persistedConfig.interpolate) { if (valueMetadata.format === 'enum') { this.set('interpolate', 'stepAfter'); } else { this.set('interpolate', 'linear'); } } this.evaluate = function (datum) { return this.limitEvaluator.evaluate(datum, valueMetadata); }.bind(this); this.set('unit', valueMetadata.unit); const format = this.formats[newKey]; this.getYVal = (value) => { const y = format.parse(value); return this.logMode ? symlog(y, 10) : y; }; } formatX(point) { return this.formats[this.get('xKey')].format(point); } formatY(point) { return this.formats[this.get('yKey')].format(point); } /** * Clear stats and recalculate from existing data. */ resetStats() { this.unset('stats'); this.getSeriesData().forEach(this.updateStats, this); } /** * Reset plot series. If new data is provided, will add that * data to series after reset. */ reset(newData) { this.updateSeriesData([]); this.resetStats(); this.emit('reset'); if (newData) { this.addAll(newData, true); } } /** * Return the point closest to a given x value. */ nearestPoint(xValue) { const insertIndex = this.sortedIndex(xValue); const data = this.getSeriesData(); const lowPoint = data[insertIndex - 1]; const highPoint = data[insertIndex]; const indexVal = this.getXVal(xValue); const lowDistance = lowPoint ? indexVal - this.getXVal(lowPoint) : Number.POSITIVE_INFINITY; const highDistance = highPoint ? this.getXVal(highPoint) - indexVal : Number.POSITIVE_INFINITY; const nearestPoint = highDistance < lowDistance ? highPoint : lowPoint; return nearestPoint; } /** * Override this to implement plot series loading functionality. Must return * a promise that is resolved when loading is completed. * * @returns {Promise} */ async load(options) { await this.fetch(options); this.emit('load'); await this.loadLimits(); } async loadLimits() { const limitsResponse = await this.limitDefinition.limits(); this.limits = {}; if (!this.unsubscribeLimits) { this.unsubscribeLimits = this.openmct.telemetry.subscribeToLimits( this.domainObject, this.limitsUpdated.bind(this) ); } this.limitsUpdated(limitsResponse); } limitsUpdated(limitsResponse) { if (limitsResponse) { this.limits = limitsResponse; } else { this.limits = {}; } this.emit('limits', this); this.emit('change:limitLines', this); } /** * Find the insert index for a given point to maintain sort order. * @private */ sortedIndex(point) { return _.sortedIndexBy(this.getSeriesData(), point, this.getXVal); } /** * Update min/max stats for the series. * @private */ updateStats(point) { const value = this.getYVal(point); let stats = this.get('stats'); let changed = false; if (!stats) { if ([Infinity, -Infinity].includes(value) || !this.isValidFloat32(value)) { return; } stats = { minValue: value, minPoint: point, maxValue: value, maxPoint: point }; changed = true; } else { if (stats.maxValue < value && value !== Infinity && this.isValidFloat32(value)) { stats.maxValue = value; stats.maxPoint = point; changed = true; } if (stats.minValue > value && value !== -Infinity && this.isValidFloat32(value)) { stats.minValue = value; stats.minPoint = point; changed = true; } } if (changed) { this.set('stats', { minValue: stats.minValue, minPoint: stats.minPoint, maxValue: stats.maxValue, maxPoint: stats.maxPoint }); } } /** * Add a point to the data array while maintaining the sort order of * the array and preventing insertion of points with a duplicate x * value. Can provide an optional argument to append a point without * maintaining sort order and dupe checks, which improves performance * when adding an array of points that are already properly sorted. * * @private * @param {Object} newData a telemetry datum. * @param {boolean} [sorted] default false, if true will append * a point to the end without dupe checking. */ add(newData, sorted = false) { let data = this.getSeriesData(); let insertIndex = data.length; const currentYVal = this.getYVal(newData); const lastYVal = this.getYVal(data[insertIndex - 1]); if (this.isValueInvalid(currentYVal) && this.isValueInvalid(lastYVal)) { console.warn(`[Plot] Invalid Y Values detected: ${currentYVal} ${lastYVal}`); return; } if (!sorted) { insertIndex = this.sortedIndex(newData); if (this.getXVal(data[insertIndex]) === this.getXVal(newData)) { return; } if (this.getXVal(data[insertIndex - 1]) === this.getXVal(newData)) { return; } } this.updateStats(newData); newData.mctLimitState = this.evaluate(newData); data.splice(insertIndex, 0, newData); this.updateSeriesData(data); this.emit('add', newData, insertIndex, this); } addAll(points, sorted = false) { for (let i = 0; i < points.length; i++) { this.add(points[i], sorted); } } /** * * @private */ isValueInvalid(val) { return Number.isNaN(val) || this.unPlottableValues.includes(val) || !this.isValidFloat32(val); } /** * * @private */ isValidFloat32(val) { return val < FLOAT32_MAX && val > FLOAT32_MIN; } /** * Remove a point from the data array and notify listeners. * @private */ remove(point) { let data = this.getSeriesData(); const index = data.indexOf(point); data.splice(index, 1); this.updateSeriesData(data); this.emit('remove', point, index, this); } /** * Purges records outside a given x range. Changes removal method based * on number of records to remove: for large purge, reset data and * rebuild array. for small purge, removes points and emits updates. * * @public * @param {Object} range * @param {number} range.min minimum x value to keep * @param {number} range.max maximum x value to keep. */ purgeRecordsOutsideRange(range) { const startIndex = this.sortedIndex(range.min); const endIndex = this.sortedIndex(range.max) + 1; let data = this.getSeriesData(); const pointsToRemove = startIndex + (data.length - endIndex + 1); if (pointsToRemove > 0) { if (pointsToRemove < 1000) { data.slice(0, startIndex).forEach(this.remove, this); data.slice(endIndex, data.length).forEach(this.remove, this); this.updateSeriesData(data); this.resetStats(); } else { const newData = this.getSeriesData().slice(startIndex, endIndex); this.reset(newData); } } } /** * Updates filters, clears the plot series, unsubscribes and resubscribes * @public */ updateFiltersAndRefresh(updatedFilters) { if (updatedFilters === undefined) { return; } let deepCopiedFilters = JSON.parse(JSON.stringify(updatedFilters)); if (this.filters && !_.isEqual(this.filters, deepCopiedFilters)) { this.filters = deepCopiedFilters; this.reset(); if (this.unsubscribe) { this.unsubscribe(); delete this.unsubscribe; } this.fetch(); } else { this.filters = deepCopiedFilters; } } getDisplayRange(xKey) { const unsortedData = this.getSeriesData(); this.updateSeriesData([]); unsortedData.forEach((point) => this.add(point, false)); let data = this.getSeriesData(); const minValue = this.getXVal(data[0]); const maxValue = this.getXVal(data[data.length - 1]); return { min: minValue, max: maxValue }; } markerOptionsDisplayText() { const showMarkers = this.get('markers'); if (!showMarkers) { return 'Disabled'; } const markerShapeKey = this.get('markerShape'); const markerShape = MARKER_SHAPES[markerShapeKey].label; const markerSize = this.get('markerSize'); return `${markerShape}: ${markerSize}px`; } nameWithUnit() { let unit = this.get('unit'); return this.get('name') + (unit ? ' ' + unit : ''); } /** * Update the series data with the given value. */ updateSeriesData(data) { configStore.add(this.dataStoreId, data); } /** * Update the series data with the given value. * This return type definition is totally wrong, only covers sinewave generator. It needs to be generic. * @return-example {Array<{ cos: number sin: number mctLimitState: { cssClass: string high: number low: {sin: number, cos: number} name: string } utc: number wavelength: number yesterday: number }>} */ getSeriesData() { return configStore.get(this.dataStoreId) || []; } } /** @typedef {any} TODO */ /** @typedef {{key: string, namespace: string}} Identifier */ /** @typedef {{ identifier: Identifier name: string unit: string xKey: string yKey: string markers: boolean markerShape: keyof typeof MARKER_SHAPES markerSize: number alarmMarkers: boolean limitLines: boolean interpolate: boolean stats: TODO }} PlotSeriesModelType */ /** @typedef {{ model: PlotSeriesModelType collection: import('./SeriesCollection').default persistedConfig: PlotSeriesModelType filters: TODO }} PlotSeriesModelOptions */ /** @typedef {import('@/api/time/TimeContext').Bounds} Bounds */