mirror of
https://github.com/nasa/openmct.git
synced 2025-06-03 08:00:52 +00:00
642 lines
18 KiB
JavaScript
642 lines
18 KiB
JavaScript
/*****************************************************************************
|
|
* 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:<prop_name>` 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<PlotSeriesModelType, PlotSeriesModelOptions>}
|
|
*/
|
|
|
|
const FLOAT32_MAX = 3.4e38;
|
|
const FLOAT32_MIN = -3.4e38;
|
|
|
|
export default class PlotSeries extends Model {
|
|
logMode = false;
|
|
|
|
/**
|
|
@param {import('./Model').ModelOptions<PlotSeriesModelType, PlotSeriesModelOptions>} 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<PlotSeriesModelType, PlotSeriesModelOptions>} 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<PlotSeriesModelType, PlotSeriesModelOptions>} 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
|
|
*/
|