Files
generalFunctions/src/predict/predict_class.js
znetsixe f8f71a4f1c schema + asset menu fixes
- configs/machineGroupControl.json: drop prioritypercentagecontrol mode
  (unused — set.demand became unit-self-describing, so percentage-vs-absolute
  is decided per-message, not by a node-wide scaling mode). Add output.process
  / output.dbase enums + functionality.distance{,Unit,Description} so the
  editor's distance offset persists. Fixes the runtime warnings 'Unknown key
  optimization/scaling/movement/curvePressureUnit etc.' the validator was
  logging on every MGC instantiation.
- configs/measurement.json: same output.process/dbase block + nullable
  position.x for the rare case a measurement has no parent yet.
- datasets/assetData/machine.json -> rotatingmachine.json: rename so
  AssetMenu's softwareType lookup matches. AssetMenu.getActiveCategoryKey
  no longer silently falls back to keys[0] (which mis-showed diffuser models
  for rotatingMachine nodes) — returns null with a console.warn instead.
- menu/asset.js: re-derive supplier/assetType from saved model id on reopen.
  The save handler intentionally discards the denormalized registry copies
  to keep the persisted node small, so the cascade dropdown booted at
  'Select...' even when a model was saved. Walk the registry tree to
  reconstitute.
- predict/predict_class.js: minor.
- configs/index.js: minor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:51:57 +02:00

664 lines
24 KiB
JavaScript

/**
* @file Predict_class.js
*
* Permission is hereby granted to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to use it for personal
* or non-commercial purposes, with the following restrictions:
*
* 1. **No Copying or Redistribution**: The Software or any of its parts may not
* be copied, merged, distributed, sublicensed, or sold without explicit
* prior written permission from the author.
*
* 2. **Commercial Use**: Any use of the Software for commercial purposes requires
* a valid license, obtainable only with the explicit consent of the author.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM,
* OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Ownership of this code remains solely with the original author. Unauthorized
* use of this Software is strictly prohibited.
*
* @summary Class for predicting values based on a multidimensional curve.
* @description Class for predicting values based on a multidimensional curve.
* @module Predict_class
* @requires EventEmitter
* @requires ConfigUtils
* @requires Interpolation
* @requires Logger
* @exports Predict
* @version 0.1.0
* @since 0.1.0
*
* Author:
* - Rene De Ren
* Email:
* - rene@thegoldenbasket.nl
* Future Improvements:
- Add more interpolation types
- **Local Derivative (Slope)**: Instantaneous rate of change (dY/dX) at the current X. Useful for determining if the curve is ascending or descending.
- **Second Derivative (Curvature)**: Curvature (d²Y/dX²) at the current X. Indicates how quickly the slope is changing (e.g., sharp or broad peaks).
- **Distance to Nearest Local Peak or Valley**: X-distance from the current X to the closest local maximum or minimum. Useful for detecting proximity to turning points.
- **Global Statistics (Mean, Median, Std Dev)**:
- Mean: Average of Y.
- Median: Middle Y value (sorted).
- Std Dev: Variability of Y. Provides insight into central tendency and spread, aiding in normalization or anomaly detection.
- **Integrated Area Under the Curve (AUC)**: Numerical integration of Y across the X-range. Useful for total sums or energy-related calculations.
- **Peak “Sharpness” or “Prominence”**: Measure of a peak's height and width relative to surrounding valleys. Important for signal processing or optimization.
- **Nearest Points Around Current X**: Data points (or interpolated values) immediately to the left and right of the current X. Useful for local interpolation or neighbor analysis.
- **Forecast / Extrapolation**: Estimated Y values outside the known X-range. Useful for exploring scenarios slightly beyond the data range (use with caution).
- **Peak Count**: Total number of local maxima in the curve. Useful for identifying all peaks and their prominence.
- **Position Relative to Mean (or Other Reference Lines)**: Distance (in percent or absolute value) of the current Y from a reference line (e.g., mean or median). Provides context relative to average or baseline levels.
- **Local Slope Trend**: Direction of the slope (up, down, or flat) at the current X. Useful for identifying trends or inflection points.
- **Local Curvature Trend**: Direction of the curvature (concave up, concave down, or flat) at the current X. Useful for identifying inflection points or turning points.
- **Local Peak-to-Valley Ratio**: Ratio of the current peak height to the nearest valley depth. Useful for identifying peak prominence or sharpness.
- ** Keep track of previous request and next request to identify slope and curvature
*/
const EventEmitter = require('events');
const Logger = require('../helper/logger.js');
const defaultConfig = require('./predictConfig.json');
const ConfigUtils = require('../helper/configUtils');
const Interpolation = require('./interpolation');
class Predict {
constructor(config = {}) {
// Capture share-source BEFORE config validation strips it (ConfigUtils
// mutates the input config to drop unknown keys, which would remove
// shareInputsFrom because it's not in predictConfig.json's schema).
// Detach on a shallow clone so validateSchema doesn't see the key at all
// — leaving it on the input would emit a `[interpolation] Unknown key
// 'shareInputsFrom'` warning per group-predictor on every curve refresh.
const _sharedSource = (config && config.shareInputsFrom instanceof Predict)
? config.shareInputsFrom
: null;
let _initConfig = config;
if (_initConfig && 'shareInputsFrom' in _initConfig) {
_initConfig = { ..._initConfig };
delete _initConfig.shareInputsFrom;
}
// Initialize dependencies
this.emitter = new EventEmitter(); // Own EventEmitter
this.configUtils = new ConfigUtils(defaultConfig);
this.config = this.configUtils.initConfig(_initConfig);
// Init after config is set
this.logger = new Logger(this.config.general.logging.enabled,this.config.general.logging.logLevel, this.config.general.name);
this.interpolation = new Interpolation(this.config.interpolation);
// Input and state
this.inputCurve = {};
this.currentF = 0;
this.currentX = 0;
this.outputY = 0;
// Curves and Splines
this.normalizedCurve = {};
this.calculatedCurve = {};
this.fCurve = {};
this.currentFxyCurve = {};
this.normalizedSplines = {};
this.fSplines = {};
this.currentFxySplines = {};
// Stored min/max values
this.xValues = {};
this.fValues = {};
this.yValues = {};
this.currentFxyXMin = 0;
this.currentFxyXMax = 0;
this.currentFxyYMin = 0;
this.currentFxyYMax = 0;
// From config
this.normMin = this.config.normalization.parameters.min;
this.normMax = this.config.normalization.parameters.max;
this.calculationPoints = this.config.normalization.parameters.curvePoints;
this.interpolationType = this.config.interpolation.type;
// Load curve if provided.
// shareInputsFrom: an existing Predict instance whose pre-built input
// curves and splines we adopt by reference. Used to create a parallel
// "view" of the same source curves (e.g. an MGC group-scope predict
// that mirrors a pump's individual predict). Per-instance state —
// currentF / currentX / currentFxyCurve / currentFxySplines /
// currentFxyY/X Min/Max / outputY — stays freshly initialised so the
// two views have independent operating points. Curve mutations on the
// source via updateCurve() are propagated through the source's
// "curveUpdated" emitter (see updateCurve below).
if (_sharedSource) {
this._adoptInputsFrom(_sharedSource);
this._sharedInputsSource = _sharedSource;
this._sharedInputsHandler = (newCurve) => {
this._adoptInputsFrom(this._sharedInputsSource);
// Keep our currentF in range; constrain re-uses the new fValues.
this.fDimension = this.constrain(this.currentF, this.fValues.min, this.fValues.max);
};
this._sharedInputsSource.emitter.on('curveUpdated', this._sharedInputsHandler);
// Initialise our own operating point to the source's min, same as
// the standard buildAllFxyCurves flow does at end of curve load.
this.fDimension = this.fValues.min;
} else if (config.curve) {
this.inputCurveData = config.curve;
} else {
this.logger.warn("No curve data provided. Please set curve data using setCurveData method. Using default");
this.inputCurveData = this.config.curve;
}
}
// Adopt another Predict's input curves and splines by reference. Used by
// the shareInputsFrom constructor option and by the curveUpdated emitter
// handler to re-sync after the source's curves change. Does NOT touch
// per-instance state (currentF, currentX, currentFxy* etc.).
//
// Also copies the scalar parameters (calculationPoints, normMin/Max,
// interpolationType) so the clone uses the SAME pointsCount the source
// built fSplines with — otherwise buildSingleFxyCurve can iterate past
// the end of the shared fSplines.
_adoptInputsFrom(source) {
this.inputCurve = source.inputCurve;
this.normalizedCurve = source.normalizedCurve;
this.calculatedCurve = source.calculatedCurve;
this.fCurve = source.fCurve;
this.fSplines = source.fSplines;
this.normalizedSplines = source.normalizedSplines;
this.xValues = source.xValues;
this.fValues = source.fValues;
this.yValues = source.yValues;
this.calculationPoints = source.calculationPoints;
this.normMin = source.normMin;
this.normMax = source.normMax;
this.interpolationType = source.interpolationType;
}
// Improved function to get a local peak in an array by starting in the middle.
// It also handles the case of a tie by preferring the left side (arbitrary choice)
// when array[start] == leftValue or array[start] == rightValue.
getLocalPeak(array) {
if (!Array.isArray(array) || array.length === 0) {
return { peak: null, peakIndex: -1 };
}
let left = 0;
let right = array.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
// Safely retrieve left/right neighbor values (use -Infinity if out of bounds)
const leftVal = mid - 1 >= 0 ? array[mid - 1] : -Infinity;
const rightVal = mid + 1 < array.length ? array[mid + 1] : -Infinity;
const currentVal = array[mid];
// Check if mid is a local peak
if (currentVal >= leftVal && currentVal >= rightVal) {
return { peak: currentVal, peakIndex: mid };
}
// If left neighbor is bigger, move left
if (leftVal > currentVal) {
right = mid - 1;
}
// Otherwise, move right
else {
left = mid + 1;
}
}
// If no local peak is found
return { peak: null, peakIndex: -1 };
}
// Function what uses the peak in the y array to return the yPeak, x value and its procentual value
getPosXofYpeak(curve) {
//find index of y peak
const { peak , peakIndex } = this.getLocalPeak(curve.y);
// Guard against invalid peakIndex (e.g. empty array returns -1)
if (peakIndex < 0 || peakIndex >= curve.x.length) {
return { yPeak: null, x: null, xProcent: null };
}
// scale the x value to procentual value
const yPeak = peak;
const x = curve.x[peakIndex];
const xMin = Math.min(...curve.x);
const xMax = Math.max(...curve.x);
const xProcent = (x - xMin) / (xMax - xMin) * 100;
return { yPeak, x, xProcent };
}
calcRelativePositionToPeak(curve , outputY) {
//find y peak
const { peak } = this.getLocalPeak(curve.y);
if ( peak === null ) {
this.logger.warn("No peak found in curve");
return -1;
}
// Calculate the "peak-only" percentage:
// - Distance from peak, relative to peak itself
// - 0% => outputY == peak, 100% => outputY == 0 (if peak != 0)
let peakOnlyPercentage;
const distanceFromPeak = Math.abs(peak - outputY);
if (peak === 0) {
// If peak is 0, then the concept of "peak-only" percentage is tricky.
// If outputY is also 0 => 0%, otherwise => Infinity.
peakOnlyPercentage = distanceFromPeak === 0 ? 0 : Number.POSITIVE_INFINITY;
} else {
peakOnlyPercentage = (distanceFromPeak / peak) * 100;
}
// Calculate the range-based percentage:
// - Range = [yMin, peak]
// - 0% => outputY == peak, 100% => outputY == yMin
const yMin = Math.min(...curve.y);
let rangeBasedPercentage = -1;
// If peak <= yMin, there is no vertical range for normalization
if (peak > yMin) {
const distanceFromPeakRange = peak - outputY; // Not absolute
const totalRange = peak - yMin;
rangeBasedPercentage = (distanceFromPeakRange / totalRange) * 100;
// Optionally clamp to [0, 100] if outputY goes out of bounds
rangeBasedPercentage = Math.max(0, Math.min(100, rangeBasedPercentage));
}
return {
peakOnlyPercentage: Math.round(peakOnlyPercentage * 100) / 100,
rangeBasedPercentage: Math.round(rangeBasedPercentage * 100) / 100
};
}
// Function to retrieve current curve including the interpolated active point
retrieveActiveCurve(){
// Retreive y values
const yValues = this.currentFxyCurve[this.fDimension].y;
// Retreive normalized x values
const xValues = this.denormalizeXvals( this.currentFxyCurve[this.fDimension].x );
//check what the current x value is
const currentX = this.currentX;
//check current y Output value
const outputY = this.outputY;
//find where the current x value should be in the xValues array
const index = xValues.findIndex((x) => x > currentX);
// push the yOutput value in the yValues array between the current x value
yValues.splice(index, 0, outputY);
xValues.splice(index, 0, currentX);
return { xValues, yValues };
}
set fDimension(newF) {
if (newF < this.fValues.min || newF > this.fValues.max) {
this.logger.warn(`New f =${newF} is constrained to fit between min=${this.fValues.min} and max=${this.fValues.max}`);
newF = this.constrain(newF,this.fValues.min,this.fValues.max);
}
if (newF in this.calculatedCurve) {
this.currentFxyCurve[newF] = this.calculatedCurve[newF];
this.currentFxySplines = this.normalizedSplines;
} else {
this.currentFxyCurve = this.buildSingleFxyCurve(
this.fSplines,
this.calculatedCurve,
newF,
this.calculationPoints
);
this.currentFxySplines = this.buildXySplines(this.currentFxyCurve, this.interpolationType);
}
const yArray = this.currentFxyCurve[newF].y;
this.currentFxyYMin = Math.min(...yArray);
this.currentFxyYMax = Math.max(...yArray);
this.calculateFxyXRange(newF);
this.currentF = newF;
this.logger.debug(`Calculating new yValue using X= ${this.currentX}`);
// Recalculate output y based on currentX
this.y(this.currentX);
}
get fDimension() {
return this.currentF;
}
// Function to predict Y value based on X value
y(x) {
// Clamp value before normalization
if (x > this.currentFxyXMax) x = this.currentFxyXMax;
if (x < this.currentFxyXMin) x = this.currentFxyXMin;
//keep track of current x value
this.currentX = x;
this.logger.debug(`Interpolating x using input=${x} , currentFxyXmin=${this.currentFxyXMin}, currentFxyXMax=${this.currentFxyXMax}, normMin=${this.normMin}, normMax=${this.normMax} `);
const normalizedX = this.interpolation.interpolate_lin_single_point(
x,
this.currentFxyXMin,
this.currentFxyXMax,
this.normMin,
this.normMax
);
this.logger.debug(`Calculating new Y value using ${normalizedX}`);
this.outputY = this.currentFxySplines[this.fDimension].interpolate(normalizedX);
return this.outputY;
}
set yOutput(y) {
this.outputY = y;
//by emitting this one output we dont have to use the entire class
this.emitter.emit('yOutput', this.outputY);
}
get yOutput() {
return this.outputY;
}
set inputCurveData(curve) {
try {
this.inputCurve = curve;
this.buildAllFxyCurves(curve);
} catch (error) {
this.logger.error(`Curve validation failed: ${error.message}`);
this.inputCurve = null; // Reset curve data if validation fails
}
}
get inputCurveData() {
return this.inputCurve;
}
updateCurve(curve) {
this.logger.info("Updating curve data");
// update config with new curve data merged with existing config
const newConfig = {...this.config, curve: curve};
this.config = this.configUtils.updateConfig(newConfig);
const validatedCurve = this.config.curve;
this.inputCurve = validatedCurve;
this.buildAllFxyCurves(validatedCurve);
// Notify shared-input clones (see shareInputsFrom in the constructor).
// They re-adopt our inputs and clamp their own operating point.
this.emitter.emit('curveUpdated', validatedCurve);
}
constrain(value,min,max) {
return Math.min(Math.max(value, min), max);
}
buildAllFxyCurves(curve) {
let globalMinY = Infinity;
let globalMaxY = -Infinity;
for (const fKey of Object.keys(curve)) {
const f = Number(fKey);
this.xValues[f] = {
min: Math.min(...curve[f].x),
max: Math.max(...curve[f].x),
};
const fMinY = Math.min(...curve[f].y);
const fMaxY = Math.max(...curve[f].y);
if (fMinY < globalMinY) globalMinY = fMinY;
if (fMaxY > globalMaxY) globalMaxY = fMaxY;
// Normalize curves
this.normalizedCurve[f] = this.normalizeCurve(curve[f], this.normMin, this.normMax);
}
this.normalizedSplines = this.buildXySplines(this.normalizedCurve, this.interpolationType);
// Build calculated curves (same #points across all f)
for (const f of Object.keys(this.normalizedCurve)) {
this.calculatedCurve[f] = this.buildCalculatedCurve(this.normalizedSplines, f, this.calculationPoints);
}
this.fCurve = this.buildFCurve(this.calculatedCurve, this.calculationPoints);
this.fSplines = this.buildFSplines(this.fCurve, this.interpolationType);
const fKeys = Object.keys(curve).map(Number);
this.fValues.min = Math.min(...fKeys);
this.fValues.max = Math.max(...fKeys);
this.yValues.lowest = globalMinY;
this.yValues.highest = globalMaxY;
// Set initial fDimension to min
this.fDimension = this.fValues.min;
this.logger.debug(` !!! Initial fDimension set to ${this.fValues.min}`);
}
normalizeVal(val, normMin, normMax) {
return this.interpolation.interpolate_lin_single_point(val, normMin, normMax, 1, this.calculationPoints);
}
normalizeCurve(curve, normMin, normMax) {
return {
x: this.interpolation.interpolate_lin_curve_points(curve.x, normMin, normMax),
y: curve.y,
};
}
denormalizeXvals(xValues) {
// Retrieve the normalized x-array from the current Fxy curve
const normalizedX = xValues;
// Map each normalized x to its denormalized value
const denormalizedX = normalizedX.map(nx => {
return this.interpolation.interpolate_lin_single_point(
nx,
this.normMin,
this.normMax,
this.currentFxyXMin,
this.currentFxyXMax
);
});
// Return a new object with denormalized x and the original y array
return denormalizedX;
}
// interpolate input x value to denormalized x value
denormalizeX(x) {
return this.interpolation.interpolate_lin_single_point(
x,
this.normMin,
this.normMax,
this.currentFxyXMin,
this.currentFxyXMax
);
}
buildCalculatedCurve(splines, f, pointsCount) {
const cCurve = { x: [], y: [] };
for (let i = 1; i <= pointsCount; i++) {
const nx = this.interpolation.interpolate_lin_single_point(i, 1, pointsCount, this.normMin, this.normMax);
cCurve.x.push(nx);
cCurve.y.push(splines[f].interpolate(nx));
}
return cCurve;
}
buildFCurve(curve, pointsCount) {
const fCurve = {};
for (let i = 0; i < pointsCount; i++) {
fCurve[i] = { x: [], y: [] };
}
for (let i = 0; i < pointsCount; i++) {
for (const [f, val] of Object.entries(curve)) {
fCurve[i].x.push(Number(f));
fCurve[i].y.push(val.y[i]);
}
}
return fCurve;
}
buildFSplines(fCurve, type) {
const fSplines = {};
for (const i of Object.keys(fCurve)) {
fSplines[i] = this.loadSpline(fCurve[i], type);
}
return fSplines;
}
buildSingleFxyCurve(fSplines, cCurve, f, pointsCount) {
const singleCurve = { [f]: { x: [], y: [] } };
const keys = Object.keys(cCurve);
const firstKey = keys[0];
for (let i = 0; i < pointsCount; i++) {
singleCurve[f].x.push(cCurve[firstKey].x[i]);
singleCurve[f].y.push(fSplines[i].interpolate(f));
}
return singleCurve;
}
buildXySplines(curves, type) {
const xySplines = {};
for (const f of Object.keys(curves)) {
xySplines[f] = this.loadSpline(curves[f], type);
}
return xySplines;
}
loadSpline(curve, type) {
const splineObj = new Interpolation();
splineObj.load_spline(curve.x, curve.y, type);
return splineObj;
}
calculateFxyXRange(value) {
const keys = Object.keys(this.inputCurve).map(Number).sort((a, b) => a - b);
for (let i = 0; i < keys.length; i++) {
const cur = keys[i];
const next = keys[i + 1];
if (value === cur) {
this.currentFxyXMin = this.xValues[cur].min;
this.currentFxyXMax = this.xValues[cur].max;
return;
}
if (next && value > cur && value < next) {
this.currentFxyXMin = this.interpolation.interpolate_lin_single_point(
value, cur, next, this.xValues[cur].min, this.xValues[next].min
);
this.currentFxyXMax = this.interpolation.interpolate_lin_single_point(
value, cur, next, this.xValues[cur].max, this.xValues[next].max
);
return;
}
}
}
getOutput() {
return {
x: this.currentX,
y: this.yOutput,
f: this.currentF,
yOutputPosVsPeak: {
peakOnlyPercentage: this.calcRelativePositionToPeak(this.currentFxyCurve[this.fDimension], this.outputY).peakOnlyPercentage,
rangeBasedPercentage: this.calcRelativePositionToPeak(this.currentFxyCurve[this.fDimension], this.outputY).rangeBasedPercentage
},
posXyPeak: this.getPosXofYpeak(this.currentFxyCurve[this.fDimension]),
xRange: { min: this.currentFxyXMin, max: this.currentFxyXMax },
yRange: { min: this.currentFxyYMin, max: this.currentFxyYMax },
};
}
}
module.exports = Predict;
/*
// Example usage
let example =
{
0:
{
x:[1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
y:[5, 15, 25, 35, 45, 55, 45, 35, 25, 15],
},
100:
{
x:[1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
y:[50, 150, 250, 350, 450, 550, 450, 350, 250, 150],
}
}
//set curve data in config
let config = {curve:example};
var predict = new Predict(config=config);
console.log(" showing curve data");
console.log(predict.inputCurveData);
console.log(" showing config data");
console.log(predict.config);
// specify dimension f if there is no dim f then specify 0 as example 2
console.log(" showing config data");
console.log(predict.config);
console.log(`lowest y value ever seen : ${predict.yValues.lowest}`);
console.log(`higehst y value ever seen : ${predict.yValues.highest}`);
predict.fDimension = 0;
console.log(`default x : ${predict.currentX}`);
console.log(`min x : ${predict.currentFxyXMin} , max x : ${predict.currentFxyXMax} for f : ${predict.fDimension}`);
console.log(`min y : ${predict.currentFxyYMin} , max y : ${predict.currentFxyYMax} for f : ${predict.fDimension}`);
console.log(`Y prediction is= ${predict.outputY} @ f : ${predict.fDimension} `);
// specify x value to predict y
const yVal = predict.y(x=0);
console.log(`For x : ${predict.currentX} is the predicted value ${yVal} @ f : ${predict.fDimension} `);
console.log(predict.retrieveActiveCurve());
const peak = predict.getLocalPeak(predict.currentFxyCurve[predict.fDimension].y);
console.log(predict.getPosXofYpeak(predict.currentFxyCurve[predict.fDimension]));
const { peakOnlyPercentage, rangeBasedPercentage } = predict.calcRelativePositionToPeak(predict.currentFxyCurve[predict.fDimension], predict.outputY);
console.log(`Peak-only percentage: ${peakOnlyPercentage}%, Range-based percentage: ${rangeBasedPercentage}%`);
//*/