/** * @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 = {}) { // Initialize dependencies this.emitter = new EventEmitter(); // Own EventEmitter this.configUtils = new ConfigUtils(defaultConfig); this.config = this.configUtils.initConfig(config); // 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 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; } } // 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); // 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); } 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}%`); //*/