Scott Bell 395436a361 works
2024-10-09 09:32:44 +02:00

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
*/