changed the folder and added index.js

This commit is contained in:
znetsixe
2025-06-10 12:36:39 +02:00
parent fda8cb33db
commit bc9e3cda90
24 changed files with 3848 additions and 2 deletions

3
src/helper/assetUtils.js Normal file
View File

@@ -0,0 +1,3 @@
export function getAssetVariables() {
}

View File

@@ -0,0 +1,243 @@
// ChildRegistrationUtils.js
class ChildRegistrationUtils {
constructor(mainClass) {
this.mainClass = mainClass; // Reference to the main class
this.logger = mainClass.logger;
}
async registerChild(child, positionVsParent) {
const { softwareType } = child.config.functionality;
const { name, id, unit } = child.config.general;
const { type = "", subType = "" } = child.config.asset || {};
const emitter = child.emitter;
//define position vs parent in child
child.positionVsParent = positionVsParent;
child.parent = this.mainClass;
if (!this.mainClass.child) this.mainClass.child = {};
if (!this.mainClass.child[softwareType])
this.mainClass.child[softwareType] = {};
if (!this.mainClass.child[softwareType][type])
this.mainClass.child[softwareType][type] = {};
if (!this.mainClass.child[softwareType][type][subType])
this.mainClass.child[softwareType][type][subType] = {};
// Use an array to handle multiple subtypes
if (!Array.isArray(this.mainClass.child[softwareType][type][subType])) {
this.mainClass.child[softwareType][type][subType] = [];
}
// Update the child in the cloud when available and supply the new child on base of tagcode OLIFANT WE NEED TO FIX THIS SO ITS DYNAMIC!
/*
try{
const url = "https://pimmoerman.nl/rdlab/tagcode.app/v2.1/api/asset/create_asset.php?";
const TagCode = child.config.asset.tagCode;
//console.log(`Register child => ${TagCode}`);
const completeURL = url + `asset_product_model_id=1&asset_product_model_uuid=123456789&asset_name=AssetNaam&asset_description=Beschrijving&asset_status=actief&asset_profile_id=1&asset_location_id=1&asset_process_id=11&asset_tag_number=${TagCode}&child_assets=[L6616]`;
await fetch(completeURL, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
}catch(e){
console.log("Error saving assetID and tagnumber", e);
}*/
// Push the new child to the array of the mainclass so we can track the childs
this.mainClass.child[softwareType][type][subType].push({
name,
id,
unit,
emitter,
});
//then connect the child depending on the type subtype etc..
this.connectChild(
id,
softwareType,
emitter,
type,
child,
subType,
positionVsParent
);
}
connectChild(
id,
softwareType,
emitter,
type,
child,
subType,
positionVsParent
) {
this.logger.debug(
`Connecting child id=${id}: desc=${softwareType}, type=${type},subType=${subType}, position=${positionVsParent}`
);
switch (softwareType) {
case "measurement":
this.logger.debug(
`Registering measurement child: ${id} with type=${type}`
);
this.connectMeasurement(child, subType, positionVsParent);
break;
case "machine":
this.logger.debug(`Registering complete machine child: ${id}`);
this.connectMachine(child);
break;
case "valve":
this.logger.debug(`Registering complete valve child: ${id}`);
this.connectValve(child);
break;
case "actuator":
this.logger.debug(`Registering linear actuator child: ${id}`);
this.connectActuator(child,positionVsParent);
break;
default:
this.logger.error(`Child registration unrecognized desc: ${desc}`);
this.logger.error(`Unrecognized softwareType: ${softwareType}`);
}
}
connectMeasurement(child, subType, position) {
this.logger.debug(
`Connecting measurement child: ${subType} with position=${position}`
);
// Check if subType is valid
if (!subType) {
this.logger.error(`Invalid subType for measurement: ${subType}`);
return;
}
// initialize the measurement to a number - logging each step for debugging
try {
this.logger.debug(
`Initializing measurement: ${subType}, position: ${position} value: 0`
);
const typeResult = this.mainClass.measurements.type(subType);
const variantResult = typeResult.variant("measured");
const positionResult = variantResult.position(position);
positionResult.value(0);
this.logger.debug(
`Subscribing on mAbs event for measurement: ${subType}, position: ${position}`
);
// Listen for the mAbs event and update the measurement
this.logger.debug(
`Successfully initialized measurement: ${subType}, position: ${position}`
);
} catch (error) {
this.logger.error(`Failed to initialize measurement: ${error.message}`);
return;
}
child.emitter.on("mAbs", (value) => {
// Use the same method chaining approach that worked during initialization
this.mainClass.measurements
.type(subType)
.variant("measured")
.position(position)
.value(value);
this.mainClass.updateMeasurement("measured", subType, value, position);
//this.logger.debug(`--------->>>>>>>>>Updated measurement: ${subType}, value: ${value}, position: ${position}`);
});
}
connectMachine(machine) {
if (!machine) {
this.logger.error("Invalid machine provided.");
return;
}
const machineId = Object.keys(this.mainClass.machines).length + 1;
this.mainClass.machines[machineId] = machine;
this.logger.info(
`Setting up pressureChange listener for machine ${machineId}`
);
machine.emitter.on("pressureChange", () =>
this.mainClass.handlePressureChange(machine)
);
//update of child triggers the handler
this.mainClass.handleChildChange();
this.logger.info(`Machine ${machineId} registered successfully.`);
}
connectValve(valve) {
if (!valve) {
this.logger.warn("Invalid valve provided.");
return;
}
const valveId = Object.keys(this.mainClass.valves).length + 1;
this.mainClass.valves[valveId] = valve; // Gooit valve object in de valves attribute met valve objects
valve.state.emitter.on("positionChange", (data) => {
//ValveGroupController abboneren op klepstand verandering
this.mainClass.logger.debug(`Position change of valve detected: ${data}`);
this.mainClass.calcValveFlows();
}); //bepaal nieuwe flow per valve
valve.emitter.on("deltaPChange", () => {
this.mainClass.logger.debug("DeltaP change of valve detected");
this.mainClass.calcMaxDeltaP();
}); //bepaal nieuwe max deltaP
this.logger.info(`Valve ${valveId} registered successfully.`);
}
connectActuator(actuator, positionVsParent) {
if (!actuator) {
this.logger.warn("Invalid actuator provided.");
return;
}
//Special case gateGroupControl
if (
this.mainClass.config.functionality.softwareType == "gateGroupControl"
) {
if (Object.keys(this.mainClass.actuators).length < 2) {
if (positionVsParent == "downstream") {
this.mainClass.actuators[0] = actuator;
}
if (positionVsParent == "upstream") {
this.mainClass.actuators[1] = actuator;
}
//define emitters
actuator.state.emitter.on("positionChange", (data) => {
this.mainClass.logger.debug(`Position change of actuator detected: ${data}`);
this.mainClass.eventUpdate();
});
//define emitters
actuator.state.emitter.on("stateChange", (data) => {
this.mainClass.logger.debug(`State change of actuator detected: ${data}`);
this.mainClass.eventUpdate();
});
} else {
this.logger.error(
"Too many actuators registered. Only two are allowed."
);
}
}
}
//wanneer hij deze ontvangt is deltaP van een van de valves veranderd (kan ook zijn niet child zijn, maar dat maakt niet uit)
}
module.exports = ChildRegistrationUtils;

94
src/helper/configUtils.js Normal file
View File

@@ -0,0 +1,94 @@
/**
* @file configUtils.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 Utility for managing and validating configuration values.
* @description Utility for managing and validating configuration values.
* @module ConfigUtils
* @requires ValidationUtils
* @requires Logger
* @exports ConfigUtils
* @version 0.1.0
* @since 0.1.0
*/
const ValidationUtils = require("./validationUtils");
const Logger = require("./logger");
class ConfigUtils {
constructor(defaultConfig, IloggerEnabled , IloggerLevel) {
const loggerEnabled = IloggerEnabled || true;
const loggerLevel = IloggerLevel || "warn";
this.logger = new Logger(loggerEnabled, loggerLevel, 'ConfigUtils');
this.defaultConfig = defaultConfig;
this.validationUtils = new ValidationUtils(loggerEnabled, loggerLevel);
}
// Initialize configuration
initConfig(config) {
this.logger.info("Initializing configuration...");
// Validate the provided configuration
const validatedConfig = this.validationUtils.validateSchema(config, this.defaultConfig, this.defaultConfig.functionality.softwareType.default);
this.logger.info("Configuration initialized successfully.");
return validatedConfig;
}
// Update configuration
updateConfig(currentConfig, newConfig) {
this.logger.info("Updating configuration...");
// Merge 2 configs and validate the result
const mergedConfig = this.mergeObjects(currentConfig, newConfig);
// Merge current configuration with new values
const updatedConfig = this.validationUtils.validateSchema(mergedConfig, this.defaultConfig, this.defaultConfig.functionality.softwareType.default);
this.logger.info("Configuration updated successfully.");
return updatedConfig;
}
// loop through objects and merge them obj1 will be updated with obj2 values
mergeObjects(obj1, obj2) {
for (let key in obj2) {
if (obj2.hasOwnProperty(key)) {
if (typeof obj2[key] === 'object') {
if (!obj1[key]) {
obj1[key] = {};
}
this.mergeObjects(obj1[key], obj2[key]);
} else {
obj1[key] = obj2[key];
}
}
}
return obj1;
}
}
module.exports = ConfigUtils;

57
src/helper/logger.js Normal file
View File

@@ -0,0 +1,57 @@
class Logger {
constructor(logging = true, logLevel = 'debug', nameModule = 'N/A') {
this.logging = logging; // Boolean flag to enable/disable logging
this.logLevel = logLevel; // Default log level: 'debug', 'info', 'warn', 'error'
this.levels = ['debug', 'info', 'warn', 'error']; // Log levels in order of severity
this.nameModule = nameModule; // Name of the module that uses the logger
}
// Helper function to check if a log message should be displayed based on current log level
shouldLog(level) {
return this.levels.indexOf(level) >= this.levels.indexOf(this.logLevel);
}
// Log a debug message
debug(message) {
if (this.logging && this.shouldLog('debug')) {
console.debug(`[DEBUG] -> ${this.nameModule}: ${message}`);
}
}
// Log an info message
info(message) {
if (this.logging && this.shouldLog('info')) {
console.info(`[INFO] -> ${this.nameModule}: ${message}`);
}
}
// Log a warning message
warn(message) {
if (this.logging && this.shouldLog('warn')) {
console.warn(`[WARN] -> ${this.nameModule}: ${message}`);
}
}
// Log an error message
error(message) {
if (this.logging && this.shouldLog('error')) {
console.error(`[ERROR] -> ${this.nameModule}: ${message}`);
}
}
// Set the log level dynamically
setLogLevel(level) {
if (this.levels.includes(level)) {
this.logLevel = level;
} else {
console.error(`[ERROR ${nameModule}]: Invalid log level: ${level}`);
}
}
// Toggle the logging on or off
toggleLogging() {
this.logging = !this.logging;
}
}
module.exports = Logger;

484
src/helper/menuUtils.js Normal file
View File

@@ -0,0 +1,484 @@
export function initBasicToggles(elements) {
// Toggle visibility for log level
elements.logCheckbox.addEventListener("change", function () {
elements.rowLogLevel.style.display = this.checked ? "block" : "none";
});
elements.rowLogLevel.style.display = elements.logCheckbox.checked
? "block"
: "none";
}
// Define the initialize toggles function within scope
export function initMeasurementToggles(elements) {
// Toggle visibility for scaling inputs
elements.scalingCheckbox.addEventListener("change", function () {
elements.rowInputMin.style.display = this.checked ? "block" : "none";
elements.rowInputMax.style.display = this.checked ? "block" : "none";
});
// Set initial states
elements.rowInputMin.style.display = elements.scalingCheckbox.checked
? "block"
: "none";
elements.rowInputMax.style.display = elements.scalingCheckbox.checked
? "block"
: "none";
}
export function initTensionToggles(elements, node) {
const currentMethod = node.interpolationMethod;
elements.rowTension.style.display =
currentMethod === "monotone_cubic_spline" ? "block" : "none";
console.log(
"Initial tension row display: ",
elements.rowTension.style.display
);
elements.interpolationMethodInput.addEventListener("change", function () {
const selectedMethod = this.value;
console.log(`Interpolation method changed: ${selectedMethod}`);
node.interpolationMethod = selectedMethod;
// Toggle visibility for tension input
elements.rowTension.style.display =
selectedMethod === "monotone_cubic_spline" ? "block" : "none";
console.log("Tension row display: ", elements.rowTension.style.display);
});
}
// Define the smoothing methods population function within scope
export function populateSmoothingMethods(configUrls, elements, node) {
fetchData(configUrls.cloud.config, configUrls.local.config)
.then((configData) => {
const smoothingMethods =
configData.smoothing?.smoothMethod?.rules?.values?.map(
(o) => o.value
) || [];
populateDropdown(
elements.smoothMethod,
smoothingMethods,
node,
"smooth_method"
);
})
.catch((err) => {
console.error("Error loading smoothing methods", err);
});
}
export function populateInterpolationMethods(configUrls, elements, node) {
fetchData(configUrls.cloud.config, configUrls.local.config)
.then((configData) => {
const interpolationMethods =
configData?.interpolation?.type?.rules?.values.map((m) => m.value) ||
[];
populateDropdown(
elements.interpolationMethodInput,
interpolationMethods,
node,
"interpolationMethod"
);
// Find the selected method and use it to spawn 1 more field to fill in tension
//const selectedMethod = interpolationMethods.find(m => m === node.interpolationMethod);
initTensionToggles(elements, node);
})
.catch((err) => {
console.error("Error loading interpolation methods", err);
});
}
export function populateLogLevelOptions(logLevelSelect, configData, node) {
// debug log level
//console.log("Displaying configData => ", configData) ;
const logLevels =
configData?.general?.logging?.logLevel?.rules?.values?.map(
(l) => l.value
) || [];
//console.log("Displaying logLevels => ", logLevels);
// Reuse your existing generic populateDropdown helper
populateDropdown(logLevelSelect, logLevels, node.logLevel);
}
//cascade dropdowns for asset type, supplier, subType, model, unit
export function fetchAndPopulateDropdowns(configUrls, elements, node) {
fetchData(configUrls.cloud.config, configUrls.local.config)
.then((configData) => {
const assetType = configData.asset?.type?.default;
const localSuppliersUrl = constructUrl(configUrls.local.taggcodeAPI,`${assetType}s`,"suppliers.json");
const cloudSuppliersUrl = constructCloudURL(configUrls.cloud.taggcodeAPI, "/vendor/get_vendors.php");
return fetchData(cloudSuppliersUrl, localSuppliersUrl)
.then((supplierData) => {
const suppliers = supplierData.map((supplier) => supplier.name);
// Populate suppliers dropdown and set up its change handler
return populateDropdown(
elements.supplier,
suppliers,
node,
"supplier",
function (selectedSupplier) {
if (selectedSupplier) {
populateSubTypes(configUrls, elements, node, selectedSupplier);
}
}
);
})
.then(() => {
// If we have a saved supplier, trigger subTypes population
if (node.supplier) {
populateSubTypes(configUrls, elements, node, node.supplier);
}
});
})
.catch((error) => {
console.error("Error in initial dropdown population:", error);
});
}
export function getSpecificConfigUrl(nodeName,cloudAPI) {
const cloudConfigURL = cloudAPI + "/config/" + nodeName + ".json";
const localConfigURL = "http://localhost:1880/"+ nodeName + "/dependencies/"+ nodeName + "/" + nodeName + "Config.json";
return { cloudConfigURL, localConfigURL };
}
// Save changes to API
export async function apiCall(node) {
try{
// OLFIANT when a browser refreshes the tag code is lost!!! fix this later!!!!!
// FIX UUID ALSO LATER
if(node.assetTagCode !== "" || node.assetTagCode !== null){ }
// API call to register or check asset in central database
let assetregisterAPI = node.configUrls.cloud.taggcodeAPI + "/asset/create_asset.php";
const assetModelId = node.modelMetadata.id; //asset_product_model_id
const uuid = node.uuid; //asset_product_model_uuid
const assetName = node.assetType; //asset_name / type?
const description = node.name; // asset_description
const assetStatus = "actief"; //asset_status -> koppel aan enable / disable node ? or make dropdown ?
const assetProfileId = 1; //asset_profile_id these are the rules to check if the childs are valid under this node (parent / child id?)
const child_assets = ["63247"]; //child_assets tagnummer of id?
const assetProcessId = node.processId; //asset_process_id
const assetLocationId = node.locationId; //asset_location_id
const tagCode = node.assetTagCode; // if already exists in the node information use it to tell the api it exists and it will update else we will get it from the api call
//console.log(`this is my tagCode: ${tagCode}`);
// Build base URL with required parameters
let apiUrl = `?asset_product_model_id=${assetModelId}&asset_product_model_uuid=${uuid}&asset_name=${assetName}&asset_description=${description}&asset_status=${assetStatus}&asset_profile_id=${assetProfileId}&asset_location_id=${assetLocationId}&asset_process_id=${assetProcessId}&child_assets=${child_assets}`;
// Only add tagCode to URL if it exists
if (tagCode) {
apiUrl += `&asset_tag_number=${tagCode}`;
console.log('hello there');
}
assetregisterAPI += apiUrl;
console.log("API call to register asset in central database", assetregisterAPI);
const response = await fetch(assetregisterAPI, {
method: "POST"
});
// Get the response text first
const responseText = await response.text();
console.log("Raw API response:", responseText);
// Try to parse the JSON, handling potential parsing errors
let jsonResponse;
try {
jsonResponse = JSON.parse(responseText);
} catch (parseError) {
console.error("JSON Parsing Error:", parseError);
console.error("Response that could not be parsed:", responseText);
throw new Error("Failed to parse API response");
}
console.log(jsonResponse);
if(jsonResponse.success){
console.log(`${jsonResponse.message}, tag number: ${jsonResponse.asset_tag_number}, asset id: ${jsonResponse.asset_id}`);
// Save the asset tag number and id to the node
} else {
console.log("Asset not registered in central database");
}
return jsonResponse;
} catch (error) {
console.log("Error saving changes to asset register API", error);
}
}
export async function fetchData(url, fallbackUrl) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const responsData = await response.json();
//responsData
const data = responsData.data;
/* .map(item => {
const { vendor_name, ...rest } = item;
return {
name: vendor_name,
...rest
};
}); */
console.log(url);
console.log("Response Data: ", data);
return data;
} catch (err) {
console.warn(
`Primary URL failed: ${url}. Trying fallback URL: ${fallbackUrl}`,
err
);
try {
const response = await fetch(fallbackUrl);
if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`);
return await response.json();
} catch (fallbackErr) {
console.error("Both primary and fallback URLs failed:", fallbackErr);
return [];
}
}
}
export async function fetchProjectData(url) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const responsData = await response.json();
console.log("Response Data: ", responsData);
return responsData;
} catch (err) {
}
}
async function populateDropdown(
htmlElement,
options,
node,
property,
callback
) {
generateHtml(htmlElement, options, node[property]);
htmlElement.addEventListener("change", async (e) => {
const newValue = e.target.value;
console.log(`Dropdown changed: ${property} = ${newValue}`);
node[property] = newValue;
RED.nodes.dirty(true);
if (callback) await callback(newValue); // Ensure async callback completion
});
}
// Helper function to construct a URL from a base and path internal
function constructUrl(base, ...paths) {
// Remove trailing slash from base and leading slashes from paths
const sanitizedBase = (base || "").replace(/\/+$/, "");
const sanitizedPaths = paths.map((path) => path.replace(/^\/+|\/+$/g, ""));
// Join sanitized base and paths
const url = `${sanitizedBase}/${sanitizedPaths.join("/")}`;
console.log("Base:", sanitizedBase);
console.log("Paths:", sanitizedPaths);
console.log("Constructed URL:", url);
return url;
}
//Adjust for API Gateway
function constructCloudURL(base, ...paths) {
// Remove trailing slash from base and leading slashes from paths
const sanitizedBase = base.replace(/\/+$/, "");
const sanitizedPaths = paths.map((path) => path.replace(/^\/+|\/+$/g, ""));
// Join sanitized base and paths
const url = `${sanitizedBase}/${sanitizedPaths.join("/")}`;
return url;
}
function populateSubTypes(configUrls, elements, node, selectedSupplier) {
fetchData(configUrls.cloud.config, configUrls.local.config)
.then((configData) => {
const assetType = configData.asset?.type?.default;
const supplierFolder = constructUrl( configUrls.local.taggcodeAPI, `${assetType}s`, selectedSupplier );
const localSubTypesUrl = constructUrl(supplierFolder, "subtypes.json");
const cloudSubTypesUrl = constructCloudURL(configUrls.cloud.taggcodeAPI, "/product/get_subtypesFromVendor.php?vendor_name=" + selectedSupplier);
return fetchData(cloudSubTypesUrl, localSubTypesUrl)
.then((subTypeData) => {
const subTypes = subTypeData.map((subType) => subType.name);
return populateDropdown(
elements.subType,
subTypes,
node,
"subType",
function (selectedSubType) {
if (selectedSubType) {
// When subType changes, update both models and units
populateModels(
configUrls,
elements,
node,
selectedSupplier,
selectedSubType
);
populateUnitsForSubType(
configUrls,
elements,
node,
selectedSubType
);
}
}
);
})
.then(() => {
// If we have a saved subType, trigger both models and units population
if (node.subType) {
populateModels(
configUrls,
elements,
node,
selectedSupplier,
node.subType
);
populateUnitsForSubType(configUrls, elements, node, node.subType);
}
//console.log("In fetch part of subtypes ");
// Store all data from selected model
/* node["modelMetadata"] = modelData.find(
(model) => model.name === node.model
);
console.log("Model Metadata: ", node["modelMetadata"]); */
});
})
.catch((error) => {
console.error("Error populating subtypes:", error);
});
}
function populateUnitsForSubType(configUrls, elements, node, selectedSubType) {
// Fetch the units data
fetchData(configUrls.cloud.units, configUrls.local.units)
.then((unitsData) => {
// Find the category that matches the subType name
const categoryData = unitsData.units.find(
(category) =>
category.category.toLowerCase() === selectedSubType.toLowerCase()
);
if (categoryData) {
// Extract just the unit values and descriptions
const units = categoryData.values.map((unit) => ({
value: unit.value,
description: unit.description,
}));
// Create the options array with descriptions as labels
const options = units.map((unit) => ({
value: unit.value,
label: `${unit.value} - ${unit.description}`,
}));
// Populate the units dropdown
populateDropdown(
elements.unit,
options.map((opt) => opt.value),
node,
"unit"
);
// If there's no currently selected unit but we have options, select the first one
if (!node.unit && options.length > 0) {
node.unit = options[0].value;
elements.unit.value = options[0].value;
}
} else {
// If no matching category is found, provide a default % option
const defaultUnits = [{ value: "%", description: "Percentage" }];
populateDropdown(
elements.unit,
defaultUnits.map((unit) => unit.value),
node,
"unit"
);
console.warn(
`No matching unit category found for subType: ${selectedSubType}`
);
}
})
.catch((error) => {
console.error("Error fetching units:", error);
});
}
function populateModels(
configUrls,
elements,
node,
selectedSupplier,
selectedSubType
) {
fetchData(configUrls.cloud.config, configUrls.local.config)
.then((configData) => {
const assetType = configData.asset?.type?.default;
// save assetType to fetch later
node.assetType = assetType;
const supplierFolder = constructUrl( configUrls.local.taggcodeAPI,`${assetType}s`,selectedSupplier);
const subTypeFolder = constructUrl(supplierFolder, selectedSubType);
const localModelsUrl = constructUrl(subTypeFolder, "models.json");
const cloudModelsUrl = constructCloudURL(configUrls.cloud.taggcodeAPI, "/product/get_product_models.php?vendor_name=" + selectedSupplier + "&product_subtype_name=" + selectedSubType);
return fetchData(cloudModelsUrl, localModelsUrl).then((modelData) => {
const models = modelData.map((model) => model.name); // use this to populate the dropdown
// If a model is already selected, store its metadata immediately
if (node.model) {
node["modelMetadata"] = modelData.find((model) => model.name === node.model);
}
populateDropdown(elements.model, models, node, "model", (selectedModel) => {
// Store only the metadata for the selected model
node["modelMetadata"] = modelData.find((model) => model.name === selectedModel);
});
/*
console.log('hello here I am:');
console.log(node["modelMetadata"]);
*/
});
})
.catch((error) => {
console.error("Error populating models:", error);
});
}
function generateHtml(htmlElement, options, savedValue) {
htmlElement.innerHTML = options.length
? `<option value="">Select...</option>${options
.map((opt) => `<option value="${opt}">${opt}</option>`)
.join("")}`
: "<option value=''>No options available</option>";
if (savedValue && options.includes(savedValue)) {
htmlElement.value = savedValue;
}
}

View File

@@ -0,0 +1,56 @@
const nodeTemplates = {
asset: {
category: "digital asset",
color: "#4f8582",
defaults: {
name: { value: "", required: true },
enableLog: { value: false },
logLevel: { value: "error" },
parent: { value: "downstream" }, // indicates the position vs the parent in the process downstream,upstream or none.
supplier: { value: "" },
subType: { value: "" },
model: { value: "" },
unit: { value: "" },
},
inputs: 1,
outputs: 3,
inputLabels: ["Machine Input"],
outputLabels: ["process", "dbase", "parent"],
icon: "font-awesome/fa-cogs",
elements: {
// Basic fields
name: "node-input-name",
// Logging fields
logCheckbox: "node-input-enableLog",
logLevelSelect: "node-input-logLevel",
rowLogLevel: "row-logLevel",
// Asset fields
supplier: "node-input-supplier",
subType: "node-input-subType",
model: "node-input-model",
unit: "node-input-unit",
//position vs parent
parent: "node-input-parent",
},
projectSettingsURL:
"http://localhost:1880/generalFunctions/settings/projectSettings.json",
},
exampleTemplate: {
category: "digital twin",
color: "#004080",
defaults: {
name: { value: "", required: true },
foo: { value: 42 },
},
inputs: 2,
outputs: 2,
inputLabels: ["In A", "In B"],
outputLabels: ["Out A", "Out B"],
icon: "font-awesome/fa-gears",
},
// …add more node “templates” here…
};
export default nodeTemplates;

132
src/helper/outputUtils.js Normal file
View File

@@ -0,0 +1,132 @@
//this class will handle the output events for the node red node
class OutputUtils {
constructor() {
this.output ={};
this.output['influxdb'] = {};
this.output['process'] = {};
}
checkForChanges(output, format) {
const changedFields = {};
for (const key in output) {
if (output.hasOwnProperty(key) && output[key] !== this.output[format][key]) {
let value = output[key];
// For fields: if the value is an object (and not a Date), stringify it.
if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
changedFields[key] = JSON.stringify(value);
} else {
changedFields[key] = value;
}
}
}
// Update the saved output state.
this.output[format] = { ...this.output[format], ...changedFields };
return changedFields;
}
formatMsg(output, config, format) {
//define emtpy message
let msg = {};
// Compare output with last output and only include changed values
const changedFields = this.checkForChanges(output,format);
if (Object.keys(changedFields).length > 0) {
switch (format) {
case 'influxdb':
// Extract the relevant config properties.
const relevantConfig = this.extractRelevantConfig(config);
// Flatten the tags so that no nested objects are passed on.
const flatTags = this.flattenTags(relevantConfig);
msg = this.influxDBFormat(changedFields, config, flatTags);
break;
case 'process':
// Compare output with last output and only include changed values
msg = this.processFormat(changedFields,config);
//console.log(msg);
break;
default:
console.log('Unknown format in output utils');
break;
}
return msg;
}
}
influxDBFormat(changedFields, config , flatTags) {
// Create the measurement and topic using softwareType and name config.functionality.softwareType + .
const measurement = config.general.name;
const payload = {
measurement: measurement,
fields: changedFields,
tags: flatTags,
timestamp: new Date(),
};
const topic = measurement;
const msg = { topic: topic, payload: payload };
return msg;
}
flattenTags(obj) {
const result = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const value = obj[key];
if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
// Recursively flatten the nested object.
const flatChild = this.flattenTags(value);
for (const childKey in flatChild) {
if (flatChild.hasOwnProperty(childKey)) {
result[`${key}_${childKey}`] = String(flatChild[childKey]);
}
}
} else {
// InfluxDB tags must be strings.
result[key] = String(value);
}
}
}
return result;
}
extractRelevantConfig(config) {
return {
// general properties
id: config.general?.id,
name: config.general?.name,
unit: config.general?.unit,
// functionality properties
softwareType: config.functionality?.softwareType,
role: config.functionality?.role,
// asset properties (exclude machineCurve)
uuid: config.asset?.uuid,
geoLocation: config.asset?.geoLocation,
supplier: config.asset?.supplier,
type: config.asset?.type,
subType: config.asset?.subType,
model: config.asset?.model,
};
}
processFormat(changedFields,config) {
// Create the measurement and topic using softwareType and name config.functionality.softwareType + .
const measurement = config.general.name;
const payload = changedFields;
const topic = measurement;
const msg = { topic: topic, payload: payload };
return msg;
}
}
module.exports = OutputUtils;

View File

@@ -0,0 +1,528 @@
/**
* @file validation.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 Validation utility for validating and constraining configuration values.
* @description Validation utility for validating and constraining configuration values.
* @module ValidationUtils
* @requires Logger
* @exports ValidationUtils
* @version 0.1.0
* @since 0.1.0
*/
const Logger = require("./logger");
class ValidationUtils {
constructor(IloggerEnabled, IloggerLevel) {
const loggerEnabled = IloggerEnabled || true;
const loggerLevel = IloggerLevel || "warn";
this.logger = new Logger(loggerEnabled, loggerLevel, 'ValidationUtils');
}
constrain(value, min, max) {
if (typeof value !== "number") {
this.logger?.warn(`Value '${value}' is not a number. Defaulting to ${min}.`);
return min;
}
return Math.min(Math.max(value, min), max);
}
validateSchema(config, schema, name) {
const validatedConfig = {};
let configValue;
// 1. Remove any unknown keys (keys not defined in the schema).
// Log a warning and omit them from the final config.
for (const key of Object.keys(config)) {
if (!(key in schema)) {
this.logger.warn(
`[${name}] Unknown key '${key}' found in config. Removing it.`
);
delete config[key];
}
}
// Validate each key in the schema and loop over wildcards if they are not in schema
for ( const key in schema ) {
if (key === "rules" || key === "description" || key === "schema") {
continue;
}
const fieldSchema = schema[key];
const { rules = {} } = fieldSchema;
// Default to the schema's default value if the key is missing
if (config[key] === undefined) {
if (fieldSchema.default === undefined) {
// If there's a nested schema, go deeper with an empty object rather than logging "no rule"
if (rules.schema) {
this.logger.warn(`${name}.${key} has no default, but has a nested schema.`);
validatedConfig[key] = this.validateSchema({}, rules.schema, `${name}.${key}`);
}
else {
this.logger.info(
`There is no rule for ${name}.${key} and no default value. ` +
`Using full schema value but validating deeper levels first...`
);
const SubObject = this.validateSchema({}, fieldSchema, `${name}.${key}`);
validatedConfig[key] = SubObject;
continue;
}
} else {
this.logger.info(`There is no value provided for ${name}.${key}. Using default value.`);
configValue = fieldSchema.default;
}
//continue;
} else {
// Use the provided value if it exists, otherwise use the default value
configValue = config[key] !== undefined ? config[key] : fieldSchema.default;
}
// Attempt to parse the value to the expected type if possible
switch (rules.type) {
case "number":
configValue = this.validateNumber(configValue, rules, fieldSchema, name, key);
break;
case "boolean":
configValue = this.validateBoolean(configValue, name, key);
break;
case "string":
configValue = this.validateString(configValue,rules,fieldSchema, name, key);
break;
case "array":
configValue = this.validateArray(configValue, rules, fieldSchema, name, key);
break;
case "set":
configValue = this.validateSet(configValue, rules, fieldSchema, name, key);
break;
case "object":
configValue = this.validateObject(configValue, rules, fieldSchema, name, key);
break;
case "enum":
configValue = this.validateEnum(configValue, rules, fieldSchema, name, key);
break;
case "curve":
validatedConfig[key] = this.validateCurve(configValue,fieldSchema.default);
continue;
case "machineCurve":
validatedConfig[key] = this.validateMachineCurve(configValue,fieldSchema.default);
continue;
case "integer":
validatedConfig[key] = this.validateInteger(configValue, rules, fieldSchema, name, key);
continue;
case undefined:
// If we see 'rules.schema' but no 'rules.type', treat it like an object:
if (rules.schema && !rules.type) {
// Log a warning and skip the extra pass for nested schema
this.logger.warn(
`${name}.${key} has a nested schema but no type. ` +
`Treating it as type="object" to skip extra pass.`
);
} else {
// Otherwise, fallback to your existing "validateUndefined" logic
validatedConfig[key] = this.validateUndefined(configValue, fieldSchema, name, key);
}
continue;
default:
this.logger.warn(`${name}.${key} has an unknown validation type: ${rules.type}. Skipping validation.`);
validatedConfig[key] = fieldSchema.default;
continue;
}
// Assign the validated or converted value
validatedConfig[key] = configValue;
}
// Ignore unknown keys by not processing them at all
this.logger.info(`Validation completed for ${name}.`);
return validatedConfig;
}
removeUnwantedKeys(obj) {
if (Array.isArray(obj)) {
return obj.map((item) => this.removeUnwantedKeys(item));
}
if (obj && typeof obj === "object") {
const newObj = {};
for (const [k, v] of Object.entries(obj)) {
// Skip or remove keys like 'default', 'rules', 'description', etc.
if (["rules", "description"].includes(k)) {
continue;
}
if("default" in v){
//put the default value in the object
newObj[k] = v.default;
continue;
}
newObj[k] = this.removeUnwantedKeys(v);
}
return newObj;
}
return obj;
}
validateMachineCurve(curve, defaultCurve) {
if (!curve || typeof curve !== "object" || Object.keys(curve).length === 0) {
this.logger.warn("Curve is missing or invalid. Defaulting to basic curve.");
return defaultCurve;
}
// Validate that nq and np exist and are objects
const { nq, np } = curve;
if (!nq || typeof nq !== "object" || !np || typeof np !== "object") {
this.logger.warn("Curve must contain valid 'nq' and 'np' objects. Defaulting to basic curve.");
return defaultCurve;
}
// Validate that each dimension key points to a valid object with x and y arrays
const validatedNq = this.validateDimensionStructure(nq, "nq");
const validatedNp = this.validateDimensionStructure(np, "np");
if (!validatedNq || !validatedNp) {
return defaultCurve;
}
return { nq: validatedNq, np: validatedNp }; // Return the validated curve
}
validateCurve(curve, defaultCurve) {
if (!curve || typeof curve !== "object" || Object.keys(curve).length === 0) {
this.logger.warn("Curve is missing or invalid. Defaulting to basic curve.");
return defaultCurve;
}
// Validate that each dimension key points to a valid object with x and y arrays
const validatedCurve = this.validateDimensionStructure(curve, "curve");
if (!validatedCurve) {
return defaultCurve;
}
return validatedCurve; // Return the validated curve
}
validateDimensionStructure(dimension, name) {
const validatedDimension = {};
for (const [key, value] of Object.entries(dimension)) {
// Validate that each key points to an object with x and y arrays
if (typeof value !== "object") {
this.logger.warn(`Dimension '${name}' key '${key}' is not valid. Returning to default.`);
return false;
}
// Validate that x and y are arrays
else if (!Array.isArray(value.x) || !Array.isArray(value.y)) {
this.logger.warn(`Dimension '${name}' key '${key}' is missing x or y arrays. Converting to arrays.`);
// Try to convert to arrays first
value.x = Object.values(value.x);
value.y = Object.values(value.y);
// If still not arrays return false
if (!Array.isArray(value.x) || !Array.isArray(value.y)) {
this.logger.warn(`Dimension '${name}' key '${key}' is not valid. Returning to default.`);
return false;
}
}
// Validate that x and y arrays are the same length
else if (value.x.length !== value.y.length) {
this.logger.warn(`Dimension '${name}' key '${key}' has mismatched x and y lengths. Ignoring this key.`);
return false;
}
// Validate that x values are in ascending order
else if (!this.isSorted(value.x)) {
this.logger.warn(`Dimension '${name}' key '${key}' has unsorted x values. Sorting...`);
return false;
}
// Validate that x values are unique
else if (!this.isUnique(value.x)) {
this.logger.warn(`Dimension '${name}' key '${key}' has duplicate x values. Removing duplicates...`);
return false;
}
// Validate that y values are numbers
else if (!this.areNumbers(value.y)) {
this.logger.warn(`Dimension '${name}' key '${key}' has non-numeric y values. Ignoring this key.`);
return false;
}
validatedDimension[key] = value;
}
return validatedDimension;
}
isSorted(arr) {
return arr.every((_, i) => i === 0 || arr[i] >= arr[i - 1]);
}
isUnique(arr) {
return new Set(arr).size === arr.length;
}
areNumbers(arr) {
return arr.every((x) => typeof x === "number");
}
validateNumber(configValue, rules, fieldSchema, name, key) {
if (typeof configValue !== "number") {
const parsedValue = parseFloat(configValue);
if (!isNaN(parsedValue)) {
this.logger.warn(`${name}.${key} was parsed to a number: ${configValue} -> ${parsedValue}`);
configValue = parsedValue;
}
}
if (rules.min !== undefined && configValue < rules.min) {
this.logger.warn(
`${name}.${key} is below the minimum (${rules.min}). Using default value.`
);
return fieldSchema.default;
}
if (rules.max !== undefined && configValue > rules.max) {
this.logger.warn(
`${name}.${key} exceeds the maximum (${rules.max}). Using default value.`
);
return fieldSchema.default;
}
this.logger.debug(`${name}.${key} is a valid number: ${configValue}`);
return configValue;
}
validateInteger(configValue, rules, fieldSchema, name, key) {
if (typeof configValue !== "number" || !Number.isInteger(configValue)) {
const parsedValue = parseInt(configValue, 10);
if (!isNaN(parsedValue) && Number.isInteger(parsedValue)) {
this.logger.warn(`${name}.${key} was parsed to an integer: ${configValue} -> ${parsedValue}`);
configValue = parsedValue;
} else {
this.logger.warn(`${name}.${key} is not a valid integer. Using default value.`);
return fieldSchema.default;
}
}
if (rules.min !== undefined && configValue < rules.min) {
this.logger.warn(`${name}.${key} is below the minimum integer value (${rules.min}). Using default value.`);
return fieldSchema.default;
}
if (rules.max !== undefined && configValue > rules.max) {
this.logger.warn(`${name}.${key} exceeds the maximum integer value (${rules.max}). Using default value.`);
return fieldSchema.default;
}
this.logger.debug(`${name}.${key} is a valid integer: ${configValue}`);
return configValue;
}
validateBoolean(configValue, name, key) {
if (typeof configValue !== "boolean") {
if (configValue === "true" || configValue === "false") {
const parsedValue = configValue === "true";
this.logger.debug(`${name}.${key} was parsed to a boolean: ${configValue} -> ${parsedValue}`);
configValue = parsedValue;
}
}
return configValue;
}
validateString(configValue, rules, fieldSchema, name, key) {
let newConfigValue = configValue;
if (typeof configValue !== "string") {
//check if the value is nullable
if(rules.nullable){
if(configValue === null){
return null;
}
}
this.logger.warn(`${name}.${key} is not a string. Trying to convert to string.`);
newConfigValue = String(configValue); // Coerce to string if not already
}
//check if the string is a valid string after conversion
if (typeof newConfigValue !== "string") {
this.logger.warn(`${name}.${key} is not a valid string. Using default value.`);
return fieldSchema.default;
}
return newConfigValue;
}
validateSet(configValue, rules, fieldSchema, name, key) {
// 1. Ensure we have a Set. If not, use default.
if (!(configValue instanceof Set)) {
this.logger.info(`${name}.${key} is not a Set. Converting to one using default value.`);
return new Set(fieldSchema.default);
}
// 2. Convert the Set to an array for easier filtering.
const validatedArray = [...configValue]
.filter((item) => {
// 3. Filter based on `rules.itemType`.
switch (rules.itemType) {
case "number":
return typeof item === "number";
case "string":
return typeof item === "string";
case "null":
// "null" might mean no type restriction (your usage may vary).
return true;
default:
// Fallback if itemType is something else
return typeof item === rules.itemType;
}
})
.slice(0, rules.maxLength || Infinity);
// 4. Check if the filtered array meets the minimum length.
if (validatedArray.length < (rules.minLength || 1)) {
this.logger.warn(
`${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.`
);
return new Set(fieldSchema.default);
}
// 5. Return a new Set containing only the valid items.
return new Set(validatedArray);
}
validateArray(configValue, rules, fieldSchema, name, key) {
if (!Array.isArray(configValue)) {
this.logger.info(`${name}.${key} is not an array. Using default value.`);
return fieldSchema.default;
}
// Validate individual items in the array
const validatedArray = configValue
.filter((item) => {
switch (rules.itemType) {
case "number":
return typeof item === "number";
case "string":
return typeof item === "string";
case "null":
// anything goes
return true;
default:
return typeof item === rules.itemType;
}
})
.slice(0, rules.maxLength || Infinity);
if (validatedArray.length < (rules.minLength || 1)) {
this.logger.warn(
`${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.`
);
return fieldSchema.default;
}
return validatedArray;
}
validateObject(configValue, rules, fieldSchema, name, key) {
if (typeof configValue !== "object" || Array.isArray(configValue)) {
this.logger.warn(`${name}.${key} is not a valid object. Using default value.`);
return fieldSchema.default;
}
if (rules.schema) {
// Recursively validate nested objects if a schema is defined
return this.validateSchema(configValue || {}, rules.schema, `${name}.${key}`);
} else {
// If no schema is defined, log a warning and use the default
this.logger.warn(`${name}.${key} is an object with no schema. Using default value.`);
return fieldSchema.default;
}
}
validateEnum(configValue, rules, fieldSchema, name, key) {
if (Array.isArray(rules.values)) {
//if value is null take default
if(configValue === null){
this.logger.warn(`${name}.${key} is null. Using default value.`);
return fieldSchema.default;
}
const validValues = rules.values.map(e => e.value.toLowerCase());
//remove caps
configValue = configValue.toLowerCase();
if (!validValues.includes(configValue)) {
this.logger.warn(
`${name}.${key} has an invalid value : ${configValue}. Allowed values: [${validValues.join(", ")}]. Using default value.`
);
return fieldSchema.default;
}
} else {
this.logger.warn(
`${name}.${key} is an enum with no 'values' array. Using default value.`
);
return fieldSchema.default;
}
return configValue;
}
validateUndefined(configValue, fieldSchema, name, key) {
if (typeof configValue === "object" && !Array.isArray(configValue)) {
this.logger.debug(`${name}.${key} has no defined rules but is an object going 1 level deeper.`);
// Recursively validate the nested object
return this.validateSchema( configValue || {}, fieldSchema, `${name}.${key}`);
}
else {
this.logger.warn(`${name}.${key} has no defined rules. Using default value.`);
return fieldSchema.default;
}
}
}
module.exports = ValidationUtils;

View File

@@ -0,0 +1,187 @@
// Add unit conversion support
const convertModule = require('../../../convert/dependencies/index');
class Measurement {
constructor(type, variant, position, windowSize) {
this.type = type; // e.g. 'pressure', 'flow', etc.
this.variant = variant; // e.g. 'predicted' or 'measured', etc..
this.position = position; // Downstream or upstream of parent object
this.windowSize = windowSize; // Rolling window size
// Place all data inside an array
this.values = []; // Array to store all values
this.timestamps = []; // Array to store all timestamps
// Unit tracking
this.unit = null; // Current unit of measurement
// For tracking differences if this is a calculated difference measurement
this.isDifference = false;
this.sourcePositions = [];
}
// -- Updater methods --
setType(type) {
this.type = type;
return this;
}
setVariant(variant) {
this.variant = variant;
return this;
}
setPosition(position) {
this.position = position;
return this;
}
setValue(value, timestamp = Date.now()) {
/*
if (value === undefined || value === null) {
value = null ;
//throw new Error('Value cannot be null or undefined');
}
*/
//shift the oldest value
if(this.values.length >= this.windowSize){
this.values.shift();
this.timestamps.shift();
}
//push the new value
this.values.push(value);
this.timestamps.push(timestamp);
return this;
}
setUnit(unit) {
this.unit = unit;
return this;
}
// -- Getter methods --
getCurrentValue() {
if (this.values.length === 0) return null;
return this.values[this.values.length - 1];
}
getAverage() {
if (this.values.length === 0) return null;
const sum = this.values.reduce((acc, val) => acc + val, 0);
return sum / this.values.length;
}
getMin() {
if (this.values.length === 0) return null;
return Math.min(...this.values);
}
getMax() {
if (this.values.length === 0) return null;
return Math.max(...this.values);
}
getAllValues() {
return {
values: [...this.values],
timestamps: [...this.timestamps],
unit: this.unit
};
}
// -- Position-based methods --
// Create a new measurement that is the difference between two positions
static createDifference(upstreamMeasurement, downstreamMeasurement) {
console.log('hello:');
if (upstreamMeasurement.type !== downstreamMeasurement.type ||
upstreamMeasurement.variant !== downstreamMeasurement.variant) {
throw new Error('Cannot calculate difference between different measurement types or variants');
}
// Ensure units match
let downstream = downstreamMeasurement;
if (upstreamMeasurement.unit && downstream.unit &&
upstreamMeasurement.unit !== downstream.unit) {
downstream = downstream.convertTo(upstreamMeasurement.unit);
}
// Create a new difference measurement
const diffMeasurement = new Measurement(
upstreamMeasurement.type,
upstreamMeasurement.variant,
'difference',
Math.min(upstreamMeasurement.windowSize, downstream.windowSize)
);
// Mark as a difference measurement and keep track of sources
diffMeasurement.isDifference = true;
diffMeasurement.sourcePositions = ['upstream', 'downstream'];
// Calculate all differences where timestamps match
const upValues = upstreamMeasurement.getAllValues();
const downValues = downstream.getAllValues();
// Match timestamps and calculate differences
for (let i = 0; i < upValues.timestamps.length; i++) {
const upTimestamp = upValues.timestamps[i];
const downIndex = downValues.timestamps.indexOf(upTimestamp);
if (downIndex !== -1) {
const diff = upValues.values[i] - downValues.values[downIndex];
diffMeasurement.setValue(diff, upTimestamp);
}
}
diffMeasurement.setUnit(upstreamMeasurement.unit);
return diffMeasurement;
}
// -- Additional getter methods --
getLatestTimestamp() {
if (this.timestamps.length === 0) return null;
return this.timestamps[this.timestamps.length - 1];
}
getValueAtTimestamp(timestamp) {
const index = this.timestamps.indexOf(timestamp);
if (index === -1) return null;
return this.values[index];
}
// -- Unit conversion methods --
convertTo(targetUnit) {
if (!this.unit) {
throw new Error('Current unit not set, cannot convert');
}
try {
const convertedValues = this.values.map(value =>
convertModule.convert(value).from(this.unit).to(targetUnit)
);
const newMeasurement = new Measurement(
this.type,
this.variant,
this.position,
this.windowSize
);
// Copy values and timestamps
newMeasurement.values = convertedValues;
newMeasurement.timestamps = [...this.timestamps];
newMeasurement.unit = targetUnit;
return newMeasurement;
} catch (error) {
throw new Error(`Unit conversion failed: ${error.message}`);
}
}
}
module.exports = Measurement;

View File

@@ -0,0 +1,56 @@
const Measurement = require('./Measurement');
class MeasurementBuilder {
constructor() {
this.type = null;
this.variant = null;
this.position = null;
this.windowSize = 10; // Default window size
}
// e.g. 'pressure', 'flow', etc.
setType(type) {
this.type = type;
return this;
}
// e.g. 'predicted' or 'measured', etc..
setVariant(variant) {
this.variant = variant;
return this;
}
// Downstream or upstream of parent object
setPosition(position) {
this.position = position;
return this;
}
// default size of the data that gets tracked
setWindowSize(windowSize) {
this.windowSize = windowSize;
return this;
}
build() {
// Validate required fields
if (!this.type) {
throw new Error('Measurement type is required');
}
if (!this.variant) {
throw new Error('Measurement variant is required');
}
if (!this.position) {
throw new Error('Measurement position is required');
}
return new Measurement(
this.type,
this.variant,
this.position,
this.windowSize
);
}
}
module.exports = MeasurementBuilder;

View File

@@ -0,0 +1,200 @@
const MeasurementBuilder = require('./MeasurementBuilder');
class MeasurementContainer {
constructor(options = {}, logger) {
this.logger = logger;
this.measurements = {};
this.windowSize = options.windowSize || 10; // Default window size
// For chaining context
this._currentType = null;
this._currentVariant = null;
this._currentPosition = null;
}
// Chainable methods
type(typeName) {
this._currentType = typeName;
this._currentVariant = null;
this._currentPosition = null;
return this;
}
variant(variantName) {
if (!this._currentType) {
throw new Error('Type must be specified before variant');
}
this._currentVariant = variantName;
this._currentPosition = null;
return this;
}
position(positionName) {
if (!this._currentVariant) {
throw new Error('Variant must be specified before position');
}
this._currentPosition = positionName;
return this;
}
// Core methods that complete the chain
value(val, timestamp = Date.now()) {
if (!this._ensureChainIsValid()) return this;
const measurement = this._getOrCreateMeasurement();
measurement.setValue(val, timestamp);
return this;
}
unit(unitName) {
if (!this._ensureChainIsValid()) return this;
const measurement = this._getOrCreateMeasurement();
measurement.setUnit(unitName);
return this;
}
// Terminal operations - get data out
get() {
if (!this._ensureChainIsValid()) return null;
return this._getOrCreateMeasurement();
}
getCurrentValue() {
const measurement = this.get();
return measurement ? measurement.getCurrentValue() : null;
}
getAverage() {
const measurement = this.get();
return measurement ? measurement.getAverage() : null;
}
getMin() {
const measurement = this.get();
return measurement ? measurement.getMin() : null;
}
getMax() {
const measurement = this.get();
return measurement ? measurement.getMax() : null;
}
getAllValues() {
const measurement = this.get();
return measurement ? measurement.getAllValues() : null;
}
// Difference calculations between positions
difference() {
if (!this._currentType || !this._currentVariant) {
throw new Error('Type and variant must be specified for difference calculation');
}
// Save position to restore chain state after operation
const savedPosition = this._currentPosition;
// Get upstream measurement
this._currentPosition = 'upstream';
const upstream = this.get();
// Get downstream measurement
this._currentPosition = 'downstream';
const downstream = this.get();
// Restore chain state
this._currentPosition = savedPosition;
if (!upstream || !downstream || upstream.values.length === 0 || downstream.values.length === 0) {
return null;
}
// Ensure units match
let downstreamForCalc = downstream;
if (upstream.unit && downstream.unit && upstream.unit !== downstream.unit) {
try {
downstreamForCalc = downstream.convertTo(upstream.unit);
} catch (error) {
if (this.logger) {
this.logger.error(`Unit conversion failed: ${error.message}`);
}
return null;
}
}
return {
value: downstreamForCalc.getCurrentValue() - upstream.getCurrentValue() ,
avgDiff: downstreamForCalc.getAverage() - upstream.getAverage() ,
unit: upstream.unit
};
}
// Helper methods
_ensureChainIsValid() {
if (!this._currentType || !this._currentVariant || !this._currentPosition) {
if (this.logger) {
this.logger.error('Incomplete measurement chain, required: type, variant, and position');
}
return false;
}
return true;
}
_getOrCreateMeasurement() {
// Initialize nested structure if needed
if (!this.measurements[this._currentType]) {
this.measurements[this._currentType] = {};
}
if (!this.measurements[this._currentType][this._currentVariant]) {
this.measurements[this._currentType][this._currentVariant] = {};
}
if (!this.measurements[this._currentType][this._currentVariant][this._currentPosition]) {
this.measurements[this._currentType][this._currentVariant][this._currentPosition] =
new MeasurementBuilder()
.setType(this._currentType)
.setVariant(this._currentVariant)
.setPosition(this._currentPosition)
.setWindowSize(this.windowSize)
.build();
}
return this.measurements[this._currentType][this._currentVariant][this._currentPosition];
}
// Additional utility methods
getTypes() {
return Object.keys(this.measurements);
}
getVariants() {
if (!this._currentType) {
throw new Error('Type must be specified before listing variants');
}
return this.measurements[this._currentType] ?
Object.keys(this.measurements[this._currentType]) : [];
}
getPositions() {
if (!this._currentType || !this._currentVariant) {
throw new Error('Type and variant must be specified before listing positions');
}
if (!this.measurements[this._currentType] ||
!this.measurements[this._currentType][this._currentVariant]) {
return [];
}
return Object.keys(this.measurements[this._currentType][this._currentVariant]);
}
clear() {
this.measurements = {};
this._currentType = null;
this._currentVariant = null;
this._currentPosition = null;
}
}
module.exports = MeasurementContainer;

View File

@@ -0,0 +1,89 @@
# Measurement System Documentation
This system provides a flexible way to store, retrieve, and analyze measurement data using a chainable API.
## Basic Usage
```javascript
const { MeasurementContainer } = require('./index');
const container = new MeasurementContainer({ windowSize: 20 });
// Set values
container.type('pressure').variant('measured').position('upstream').value(100).unit('psi');
// Get values
const upstreamPressure = container.type('pressure').variant('measured').position('upstream').getCurrentValue();
console.log(`Upstream pressure: ${upstreamPressure}`);
```
## Chainable API Methods
### Setting Context
- `type(typeName)` - Set the measurement type (pressure, flow, etc.)
- `variant(variantName)` - Set the variant (measured, predicted, etc.)
- `position(positionName)` - Set the position (upstream, downstream, etc.)
### Setting Data
- `value(val, [timestamp])` - Add a value with optional timestamp
- `unit(unitName)` - Set the measurement unit
### Getting Data
- `get()` - Get the measurement object
- `getCurrentValue()` - Get the most recent value
- `getAverage()` - Calculate average of all values
- `getMin()` - Get minimum value
- `getMax()` - Get maximum value
### Calculations
- `difference()` - Calculate difference between upstream and downstream positions
### Listing Available Data
- `getTypes()` - Get all measurement types
- `listVariants()` - List variants for current type
- `listPositions()` - List positions for current type and variant
## Example Workflows
### Setting and retrieving values
```javascript
// Set a measurement
container.type('flow')
.variant('measured')
.position('upstream')
.value(120)
.unit('gpm');
// Retrieve the same measurement
const flow = container.type('flow')
.variant('measured')
.position('upstream')
.getCurrentValue();
```
### Calculating differences
```javascript
// Set upstream and downstream measurements
container.type('pressure').variant('measured').position('upstream').value(100).unit('psi');
container.type('pressure').variant('measured').position('downstream').value(95).unit('psi');
// Calculate the difference
const diff = container.type('pressure').variant('measured').difference();
console.log(`Pressure drop: ${diff.currentDiff} ${diff.unit}`);
```
### Working with historical data
```javascript
// Add multiple values
container.type('temperature')
.variant('measured')
.position('outlet')
.value(72)
.value(74)
.value(73)
.unit('F');
// Get statistics
const avg = container.type('temperature').variant('measured').position('outlet').getAverage();
const min = container.type('temperature').variant('measured').position('outlet').getMin();
const max = container.type('temperature').variant('measured').position('outlet').getMax();
```

View File

@@ -0,0 +1,58 @@
const { MeasurementContainer } = require('./index');
// Create a container
const container = new MeasurementContainer({ windowSize: 20 });
// Example 1: Setting values with chaining
console.log('--- Example 1: Setting values ---');
container.type('pressure').variant('measured').position('upstream').value(100).unit('psi');
container.type('pressure').variant('measured').position('downstream').value(95).unit('psi');
container.type('pressure').variant('measured').position('downstream').value(80);
// Example 2: Getting values with chaining
console.log('--- Example 2: Getting values ---');
const upstreamValue = container.type('pressure').variant('measured').position('upstream').getCurrentValue();
const upstreamUnit = container.type('pressure').variant('measured').position('upstream').get().unit;
console.log(`Upstream pressure: ${upstreamValue} ${upstreamUnit}`);
const downstreamValue = container.type('pressure').variant('measured').position('downstream').getCurrentValue();
const downstreamUnit = container.type('pressure').variant('measured').position('downstream').get().unit;
console.log(`Downstream pressure: ${downstreamValue} ${downstreamUnit}`);
// Example 3: Calculations using chained methods
console.log('--- Example 3: Calculations ---');
container.type('flow').variant('predicted').position('upstream').value(200).unit('gpm');
container.type('flow').variant('predicted').position('downstream').value(195).unit('gpm');
const flowAvg = container.type('flow').variant('predicted').position('upstream').getAverage();
console.log(`Average upstream flow: ${flowAvg} gpm`);
// Example 4: Getting pressure difference
console.log('--- Example 4: Difference calculations ---');
const pressureDiff = container.type('pressure').variant('measured').difference();
console.log(`Pressure difference: ${pressureDiff.value} ${pressureDiff.unit}`);
// Example 5: Adding multiple values to track history
console.log('--- Example 5: Multiple values ---');
// Add several values to upstream flow
container.type('flow').variant('measured').position('upstream')
.value(210).value(215).value(205).unit('gpm');
// Then get statistics
console.log('Flow statistics:');
console.log(`- Current: ${container.type('flow').variant('measured').position('upstream').getCurrentValue()} gpm`);
console.log(`- Average: ${container.type('flow').variant('measured').position('upstream').getAverage()} gpm`);
console.log(`- Min: ${container.type('flow').variant('measured').position('upstream').getMin()} gpm`);
console.log(`- Max: ${container.type('flow').variant('measured').position('upstream').getMax()} gpm`);
console.log(`Show all values : ${JSON.stringify(container.type('flow').variant('measured').position('upstream').getAllValues())}`);
// Example 6: Listing available data
console.log('--- Example 6: Listing available data ---');
console.log('Types:', container.getTypes());
console.log('Pressure variants:', container.type('pressure').getVariants());
console.log('Measured pressure positions:', container.type('pressure').variant('measured').getPositions());
module.exports = {
runExamples: () => {
console.log('Examples of the measurement chainable API');
}
};

View File

@@ -0,0 +1,9 @@
const MeasurementContainer = require('./MeasurementContainer');
const Measurement = require('./Measurement');
const MeasurementBuilder = require('./MeasurementBuilder');
module.exports = {
MeasurementContainer,
Measurement,
MeasurementBuilder
};

View File

@@ -0,0 +1,297 @@
const ErrorMetrics = require('./errorMetrics');
// Dummy logger for tests
const logger = {
error: console.error,
debug: console.log,
info: console.log
};
const config = {
thresholds: {
NRMSE_LOW: 0.05,
NRMSE_MEDIUM: 0.10,
NRMSE_HIGH: 0.15,
LONG_TERM_LOW: 0.02,
LONG_TERM_MEDIUM: 0.04,
LONG_TERM_HIGH: 0.06
}
};
class ErrorMetricsTester {
constructor() {
this.totalTests = 0;
this.passedTests = 0;
this.failedTests = 0;
this.errorMetrics = new ErrorMetrics(config, logger);
}
assert(condition, message) {
this.totalTests++;
if (condition) {
console.log(`✓ PASS: ${message}`);
this.passedTests++;
} else {
console.log(`✗ FAIL: ${message}`);
this.failedTests++;
}
}
testMeanSquaredError() {
console.log("\nTesting Mean Squared Error...");
const predicted = [1, 2, 3];
const measured = [1, 3, 5];
const mse = this.errorMetrics.meanSquaredError(predicted, measured);
this.assert(Math.abs(mse - 1.67) < 0.1, "MSE correctly calculated");
}
testRootMeanSquaredError() {
console.log("\nTesting Root Mean Squared Error...");
const predicted = [1, 2, 3];
const measured = [1, 3, 5];
const rmse = this.errorMetrics.rootMeanSquaredError(predicted, measured);
this.assert(Math.abs(rmse - 1.29) < 0.1, "RMSE correctly calculated");
}
testNormalizedRMSE() {
console.log("\nTesting Normalized RMSE...");
const predicted = [100, 102, 104];
const measured = [98, 103, 107];
const processMin = 90, processMax = 110;
const nrmse = this.errorMetrics.normalizedRootMeanSquaredError(predicted, measured, processMin, processMax);
this.assert(typeof nrmse === 'number' && nrmse > 0, "Normalized RMSE calculated correctly");
}
testNormalizeUsingRealtime() {
console.log("\nTesting Normalize Using Realtime...");
const predicted = [100, 102, 104];
const measured = [98, 103, 107];
try {
const nrmse = this.errorMetrics.normalizeUsingRealtime(predicted, measured);
this.assert(typeof nrmse === 'number' && nrmse > 0, "Normalize using realtime calculated correctly");
} catch (error) {
this.assert(false, `Normalize using realtime failed: ${error.message}`);
}
// Test with identical values to check error handling
const sameValues = [100, 100, 100];
try {
this.errorMetrics.normalizeUsingRealtime(sameValues, sameValues);
this.assert(false, "Should throw error with identical values");
} catch (error) {
this.assert(true, "Correctly throws error when min/max are the same");
}
}
testLongTermNRMSD() {
console.log("\nTesting Long Term NRMSD Accumulation...");
// Reset the accumulation values
this.errorMetrics.cumNRMSD = 0;
this.errorMetrics.cumCount = 0;
let lastValue = 0;
for (let i = 0; i < 100; i++) {
lastValue = this.errorMetrics.longTermNRMSD(0.1 + i * 0.001);
}
this.assert(
this.errorMetrics.cumCount === 100 &&
this.errorMetrics.cumNRMSD !== 0 &&
lastValue !== 0,
"Long term NRMSD accumulates over 100 iterations"
);
// Test that values are returned only after accumulating 100 samples
this.errorMetrics.cumNRMSD = 0;
this.errorMetrics.cumCount = 0;
for (let i = 0; i < 99; i++) {
const result = this.errorMetrics.longTermNRMSD(0.1);
this.assert(result === 0, "No longTermNRMSD returned before 100 samples");
}
// Use a different value for the 100th sample to ensure a non-zero result
const result = this.errorMetrics.longTermNRMSD(0.2);
this.assert(result !== 0, "longTermNRMSD returned after 100 samples");
}
testDetectImmediateDrift() {
console.log("\nTesting Immediate Drift Detection...");
// Test high drift
let drift = this.errorMetrics.detectImmediateDrift(config.thresholds.NRMSE_HIGH + 0.01);
this.assert(drift.level === 3, "Detects high immediate drift correctly");
// Test medium drift
drift = this.errorMetrics.detectImmediateDrift(config.thresholds.NRMSE_MEDIUM + 0.01);
this.assert(drift.level === 2, "Detects medium immediate drift correctly");
// Test low drift
drift = this.errorMetrics.detectImmediateDrift(config.thresholds.NRMSE_LOW + 0.01);
this.assert(drift.level === 1, "Detects low immediate drift correctly");
// Test no drift
drift = this.errorMetrics.detectImmediateDrift(config.thresholds.NRMSE_LOW - 0.01);
this.assert(drift.level === 0, "Detects no immediate drift correctly");
}
testDetectLongTermDrift() {
console.log("\nTesting Long Term Drift Detection...");
// Test high drift
let drift = this.errorMetrics.detectLongTermDrift(config.thresholds.LONG_TERM_HIGH + 0.01);
this.assert(drift.level === 3, "Detects high long-term drift correctly");
// Test medium drift
drift = this.errorMetrics.detectLongTermDrift(config.thresholds.LONG_TERM_MEDIUM + 0.01);
this.assert(drift.level === 2, "Detects medium long-term drift correctly");
// Test low drift
drift = this.errorMetrics.detectLongTermDrift(config.thresholds.LONG_TERM_LOW + 0.01);
this.assert(drift.level === 1, "Detects low long-term drift correctly");
// Test no drift
drift = this.errorMetrics.detectLongTermDrift(config.thresholds.LONG_TERM_LOW - 0.01);
this.assert(drift.level === 0, "Detects no long-term drift correctly");
// Test negative drift values
drift = this.errorMetrics.detectLongTermDrift(-config.thresholds.LONG_TERM_HIGH - 0.01);
this.assert(drift.level === 3, "Detects negative high long-term drift correctly");
}
testDriftDetection() {
console.log("\nTesting Combined Drift Detection...");
let nrmseHigh = config.thresholds.NRMSE_HIGH + 0.01;
let ltNRMSD = 0;
let result = this.errorMetrics.detectDrift(nrmseHigh, ltNRMSD);
this.assert(
result !== null &&
result.ImmDrift &&
result.ImmDrift.level === 3 &&
result.LongTermDrift.level === 0,
"Detects high immediate drift with no long-term drift"
);
nrmseHigh = config.thresholds.NRMSE_LOW - 0.01;
ltNRMSD = config.thresholds.LONG_TERM_MEDIUM + 0.01;
result = this.errorMetrics.detectDrift(nrmseHigh, ltNRMSD);
this.assert(
result !== null &&
result.ImmDrift.level === 0 &&
result.LongTermDrift &&
result.LongTermDrift.level === 2,
"Detects medium long-term drift with no immediate drift"
);
nrmseHigh = config.thresholds.NRMSE_MEDIUM + 0.01;
ltNRMSD = config.thresholds.LONG_TERM_MEDIUM + 0.01;
result = this.errorMetrics.detectDrift(nrmseHigh, ltNRMSD);
this.assert(
result.ImmDrift.level === 2 &&
result.LongTermDrift.level === 2,
"Detects both medium immediate and medium long-term drift"
);
nrmseHigh = config.thresholds.NRMSE_LOW - 0.01;
ltNRMSD = config.thresholds.LONG_TERM_LOW - 0.01;
result = this.errorMetrics.detectDrift(nrmseHigh, ltNRMSD);
this.assert(
result.ImmDrift.level === 0 &&
result.LongTermDrift.level === 0,
"No significant drift detected when under thresholds"
);
}
testAssessDrift() {
console.log("\nTesting assessDrift function...");
// Reset accumulation for testing
this.errorMetrics.cumNRMSD = 0;
this.errorMetrics.cumCount = 0;
const predicted = [100, 101, 102, 103];
const measured = [90, 91, 92, 93];
const processMin = 90, processMax = 110;
let result = this.errorMetrics.assessDrift(predicted, measured, processMin, processMax);
this.assert(
result !== null &&
typeof result.nrmse === 'number' &&
typeof result.longTermNRMSD === 'number' &&
typeof result.immediateLevel === 'number' &&
typeof result.immediateFeedback === 'string' &&
typeof result.longTermLevel === 'number' &&
typeof result.longTermFeedback === 'string',
"assessDrift returns complete result structure"
);
this.assert(
result.immediateLevel > 0,
"assessDrift detects immediate drift with significant difference"
);
// Test with identical values
result = this.errorMetrics.assessDrift(predicted, predicted, processMin, processMax);
this.assert(
result.nrmse === 0 &&
result.immediateLevel === 0,
"assessDrift indicates no immediate drift when predicted equals measured"
);
// Test with slight drift
const measuredSlight = [100, 100.5, 101, 101.5];
result = this.errorMetrics.assessDrift(predicted, measuredSlight, processMin, processMax);
this.assert(
result !== null &&
result.nrmse < 0.05 &&
(result.immediateLevel < 2),
"assessDrift returns appropriate levels for slight drift"
);
// Test long-term drift accumulation
for (let i = 0; i < 100; i++) {
this.errorMetrics.assessDrift(
predicted,
measured.map(m => m + (Math.random() * 2 - 1)), // Add small random fluctuation
processMin,
processMax
);
}
result = this.errorMetrics.assessDrift(predicted, measured, processMin, processMax);
this.assert(
result.longTermNRMSD !== 0,
"Long-term drift accumulates over multiple assessments"
);
}
async runAllTests() {
console.log("\nStarting Error Metrics Tests...\n");
this.testMeanSquaredError();
this.testRootMeanSquaredError();
this.testNormalizedRMSE();
this.testNormalizeUsingRealtime();
this.testLongTermNRMSD();
this.testDetectImmediateDrift();
this.testDetectLongTermDrift();
this.testDriftDetection();
this.testAssessDrift();
console.log("\nTest Summary:");
console.log(`Total Tests: ${this.totalTests}`);
console.log(`Passed: ${this.passedTests}`);
console.log(`Failed: ${this.failedTests}`);
process.exit(this.failedTests > 0 ? 1 : 0);
}
}
// Run all tests
const tester = new ErrorMetricsTester();
tester.runAllTests().catch(console.error);

154
src/nrmse/errorMetrics.js Normal file
View File

@@ -0,0 +1,154 @@
//load local dependencies
const EventEmitter = require('events');
//load all config modules
const defaultConfig = require('./nrmseConfig.json');
const ConfigUtils = require('../configUtils');
class ErrorMetrics {
constructor(config = {}, logger) {
this.emitter = new EventEmitter(); // Own EventEmitter
this.configUtils = new ConfigUtils(defaultConfig);
this.config = this.configUtils.initConfig(config);
// Init after config is set
this.logger = logger;
// For long-term NRMSD accumulation
this.cumNRMSD = 0;
this.cumCount = 0;
}
//INCLUDE timestamps in the next update OLIFANT
meanSquaredError(predicted, measured) {
if (predicted.length !== measured.length) {
this.logger.error("Comparing MSE Arrays must have the same length.");
return 0;
}
let sumSqError = 0;
for (let i = 0; i < predicted.length; i++) {
const err = predicted[i] - measured[i];
sumSqError += err * err;
}
return sumSqError / predicted.length;
}
rootMeanSquaredError(predicted, measured) {
return Math.sqrt(this.meanSquaredError(predicted, measured));
}
normalizedRootMeanSquaredError(predicted, measured, processMin, processMax) {
const range = processMax - processMin;
if (range <= 0) {
this.logger.error("Invalid process range: processMax must be greater than processMin.");
}
const rmse = this.rootMeanSquaredError(predicted, measured);
return rmse / range;
}
longTermNRMSD(input) {
const storedNRMSD = this.cumNRMSD;
const storedCount = this.cumCount;
const newCount = storedCount + 1;
// Update cumulative values
this.cumCount = newCount;
// Calculate new running average
if (storedCount === 0) {
this.cumNRMSD = input; // First value
} else {
// Running average formula: newAvg = oldAvg + (newValue - oldAvg) / newCount
this.cumNRMSD = storedNRMSD + (input - storedNRMSD) / newCount;
}
if(newCount >= 100) {
// Return the current NRMSD value, not just the contribution from this sample
return this.cumNRMSD;
}
return 0;
}
normalizeUsingRealtime(predicted, measured) {
const realtimeMin = Math.min(Math.min(...predicted), Math.min(...measured));
const realtimeMax = Math.max(Math.max(...predicted), Math.max(...measured));
const range = realtimeMax - realtimeMin;
if (range <= 0) {
throw new Error("Invalid process range: processMax must be greater than processMin.");
}
const rmse = this.rootMeanSquaredError(predicted, measured);
return rmse / range;
}
detectImmediateDrift(nrmse) {
let ImmDrift = {};
this.logger.debug(`checking immediate drift with thresholds : ${this.config.thresholds.NRMSE_HIGH} ${this.config.thresholds.NRMSE_MEDIUM} ${this.config.thresholds.NRMSE_LOW}`);
switch (true) {
case( nrmse > this.config.thresholds.NRMSE_HIGH ) :
ImmDrift = {level : 3 , feedback : "High immediate drift detected"};
break;
case( nrmse > this.config.thresholds.NRMSE_MEDIUM ) :
ImmDrift = {level : 2 , feedback : "Medium immediate drift detected"};
break;
case(nrmse > this.config.thresholds.NRMSE_LOW ):
ImmDrift = {level : 1 , feedback : "Low immediate drift detected"};
break;
default:
ImmDrift = {level : 0 , feedback : "No drift detected"};
}
return ImmDrift;
}
detectLongTermDrift(longTermNRMSD) {
let LongTermDrift = {};
this.logger.debug(`checking longterm drift with thresholds : ${this.config.thresholds.LONG_TERM_HIGH} ${this.config.thresholds.LONG_TERM_MEDIUM} ${this.config.thresholds.LONG_TERM_LOW}`);
switch (true) {
case(Math.abs(longTermNRMSD) > this.config.thresholds.LONG_TERM_HIGH) :
LongTermDrift = {level : 3 , feedback : "High long-term drift detected"};
break;
case (Math.abs(longTermNRMSD) > this.config.thresholds.LONG_TERM_MEDIUM) :
LongTermDrift = {level : 2 , feedback : "Medium long-term drift detected"};
break;
case ( Math.abs(longTermNRMSD) > this.config.thresholds.LONG_TERM_LOW ) :
LongTermDrift = {level : 1 , feedback : "Low long-term drift detected"};
break;
default:
LongTermDrift = {level : 0 , feedback : "No drift detected"};
}
return LongTermDrift;
}
detectDrift(nrmse, longTermNRMSD) {
const ImmDrift = this.detectImmediateDrift(nrmse);
const LongTermDrift = this.detectLongTermDrift(longTermNRMSD);
return { ImmDrift, LongTermDrift };
}
// asses the drift
assessDrift(predicted, measured, processMin, processMax) {
// Compute NRMSE and check for immediate drift
const nrmse = this.normalizedRootMeanSquaredError(predicted, measured, processMin, processMax);
this.logger.debug(`NRMSE: ${nrmse}`);
// cmopute long-term NRMSD and add result to cumalitve NRMSD
const longTermNRMSD = this.longTermNRMSD(nrmse);
// return the drift
// Return the drift assessment object
const driftAssessment = this.detectDrift(nrmse, longTermNRMSD);
return {
nrmse,
longTermNRMSD,
immediateLevel: driftAssessment.ImmDrift.level,
immediateFeedback: driftAssessment.ImmDrift.feedback,
longTermLevel: driftAssessment.LongTermDrift.level,
longTermFeedback: driftAssessment.LongTermDrift.feedback
};
}
}
module.exports = ErrorMetrics;

138
src/nrmse/nrmseConfig.json Normal file
View File

@@ -0,0 +1,138 @@
{
"general": {
"name": {
"default": "ErrorMetrics",
"rules": {
"type": "string",
"description": "A human-readable name for the configuration."
}
},
"id": {
"default": null,
"rules": {
"type": "string",
"nullable": true,
"description": "A unique identifier for this configuration, assigned dynamically when needed."
}
},
"unit": {
"default": "unitless",
"rules": {
"type": "string",
"description": "The unit used for the state values (e.g., 'meters', 'seconds', 'unitless')."
}
},
"logging": {
"logLevel": {
"default": "info",
"rules": {
"type": "enum",
"values": [
{
"value": "debug",
"description": "Log messages are printed for debugging purposes."
},
{
"value": "info",
"description": "Informational messages are printed."
},
{
"value": "warn",
"description": "Warning messages are printed."
},
{
"value": "error",
"description": "Error messages are printed."
}
]
}
},
"enabled": {
"default": true,
"rules": {
"type": "boolean",
"description": "Indicates whether logging is active. If true, log messages will be generated."
}
}
}
},
"functionality": {
"softwareType": {
"default": "errorMetrics",
"rules": {
"type": "string",
"description": "Logical name identifying the software type."
}
},
"role": {
"default": "error calculation",
"rules": {
"type": "string",
"description": "Functional role within the system."
}
}
},
"mode": {
"current": {
"default": "active",
"rules": {
"type": "enum",
"values": [
{
"value": "active",
"description": "The error metrics calculation is active."
},
{
"value": "inactive",
"description": "The error metrics calculation is inactive."
}
],
"description": "The operational mode of the error metrics calculation."
}
}
},
"thresholds": {
"NRMSE_LOW": {
"default": 0.05,
"rules": {
"type": "number",
"description": "Low threshold for normalized root mean squared error."
}
},
"NRMSE_MEDIUM": {
"default": 0.10,
"rules": {
"type": "number",
"description": "Medium threshold for normalized root mean squared error."
}
},
"NRMSE_HIGH": {
"default": 0.15,
"rules": {
"type": "number",
"description": "High threshold for normalized root mean squared error."
}
},
"LONG_TERM_LOW": {
"default": 0.02,
"rules": {
"type": "number",
"description": "Low threshold for long-term normalized root mean squared deviation."
}
},
"LONG_TERM_MEDIUM": {
"default": 0.04,
"rules": {
"type": "number",
"description": "Medium threshold for long-term normalized root mean squared deviation."
}
},
"LONG_TERM_HIGH": {
"default": 0.06,
"rules": {
"type": "number",
"description": "High threshold for long-term normalized root mean squared deviation."
}
}
}
}

View File

@@ -0,0 +1,89 @@
class DynamicClusterDeviation {
constructor() {
this.clusters = []; // Stores clusters as { center, spread, count }
}
update(value) {
console.log(`\nProcessing value: ${value}`);
// If no clusters exist, create the first one
if (this.clusters.length === 0) {
this.clusters.push({ center: value, spread: 0, count: 1 });
console.log(` → First cluster created at ${value}`);
return { value, isOutlier: false };
}
// Step 1: Find the closest cluster
let bestMatch = null;
let minDistance = Infinity;
for (const cluster of this.clusters) {
const distance = Math.abs(value - cluster.center);
console.log(` Checking against cluster at ${cluster.center} (spread: ${cluster.spread}, count: ${cluster.count}) → distance: ${distance}`);
if (distance < minDistance) {
bestMatch = cluster;
minDistance = distance;
}
}
console.log(` Closest cluster found at ${bestMatch.center} with distance: ${minDistance}`);
// Step 2: Compute dynamic threshold
const dynamicThreshold = 1 + 5 / Math.sqrt(bestMatch.count + 1);
const allowedDeviation = dynamicThreshold * (bestMatch.spread || 1);
console.log(` Dynamic threshold: ${dynamicThreshold.toFixed(2)}, Allowed deviation: ${allowedDeviation.toFixed(2)}`);
// Step 3: Check if value fits within the dynamically adjusted cluster spread
if (minDistance <= allowedDeviation) {
// Update cluster dynamically
const newCenter = (bestMatch.center * bestMatch.count + value) / (bestMatch.count + 1);
const newSpread = Math.max(bestMatch.spread, minDistance);
bestMatch.center = newCenter;
bestMatch.spread = newSpread;
bestMatch.count += 1;
console.log(` ✅ Value fits in cluster! Updating cluster:`);
console.log(` → New center: ${newCenter.toFixed(2)}`);
console.log(` → New spread: ${newSpread.toFixed(2)}`);
console.log(` → New count: ${bestMatch.count}`);
return { value, isOutlier: false };
} else {
// If too far, create a new cluster
this.clusters.push({ center: value, spread: 0, count: 1 });
console.log(` ❌ Outlier detected! New cluster created at ${value}`);
return { value, isOutlier: true };
}
}
}
// Rolling window simulation with outlier detection
/*
const detector = new DynamicClusterDeviation();
const dataStream = [10, 10.2, 10.5, 9.8, 11, 50, 10.3, 200, 201, 200.1, 205, 202, 250, 260, 270, 280, 290, 300];
// Define the number of elements per rolling window chunk.
const windowSize = 5;
let rollingWindow = [];
dataStream.forEach((value, index) => {
console.log(`\n=== Processing value ${index + 1} ===`);
rollingWindow.push(value);
const result = detector.update(value);
console.log(`Current rolling window: [${rollingWindow.join(', ')}]`);
console.log(`Result: value=${result.value} (${result.isOutlier ? 'Outlier' : 'Inlier'})`);
// Once the window size is reached, show current cluster states and reset the window for the next chunk.
if (rollingWindow.length === windowSize) {
console.log("\n--- Rolling window chunk finished ---");
console.log("Detector cluster states:", JSON.stringify(detector.clusters, null, 2));
rollingWindow = [];
}
});
console.log("\nFinal detector cluster states:", JSON.stringify(detector.clusters, null, 2));
*/

View File

@@ -0,0 +1,277 @@
//const EventEmitter = require('events');
class movementManager {
constructor(config, logger, emitter) {
this.emitter = emitter; //new EventEmitter(); //state class emitter
const { min, max, initial } = config.position;
const { speed, maxSpeed, interval } = config.movement;
this.minPosition = min;
this.maxPosition = max;
this.currentPosition = initial;
this.speed = speed;
this.maxSpeed = maxSpeed;
this.interval = interval;
this.timeleft = 0; // timeleft of current movement
this.logger = logger;
this.movementMode = config.movement.mode;
}
getCurrentPosition() {
return this.currentPosition;
}
async moveTo(targetPosition, signal) {
// Constrain target position if necessary
if (
targetPosition < this.minPosition ||
targetPosition > this.maxPosition
) {
targetPosition = this.constrain(targetPosition);
this.logger.warn(
`New target position=${targetPosition} is constrained to fit between min=${this.minPosition} and max=${this.maxPosition}`
);
}
this.logger.info(
`Starting movement to position ${targetPosition} in ${this.movementMode} with avg speed=${this.speed}%/s.`
);
if (signal && signal.aborted) {
this.logger.debug("Movement aborted.");
return;
}
try {
// Execute the movement logic based on the mode
switch (this.movementMode) {
case "staticspeed":
const movelinFeedback = await this.moveLinear(targetPosition,signal);
this.logger.info(`Linear move: ${movelinFeedback} `);
break;
case "dynspeed":
const moveDynFeedback = await this.moveEaseInOut(targetPosition,signal);
this.logger.info(`Dynamic move : ${moveDynFeedback}`);
break;
default:
throw new Error(`Unsupported movement mode: ${this.movementMode}`);
}
} catch (error) {
this.logger.error(error);
}
}
moveLinear(targetPosition, signal) {
return new Promise((resolve, reject) => {
// Immediate abort if already signalled
if (signal?.aborted) {
return reject(new Error("Movement aborted"));
}
// Clamp the final target into [minPosition, maxPosition]
targetPosition = this.constrain(targetPosition);
// Compute direction and remaining distance
const direction = targetPosition > this.currentPosition ? 1 : -1;
const distance = Math.abs(targetPosition - this.currentPosition);
// Speed is a fraction [0,1] of full-range per second
this.speed = Math.min(Math.max(this.speed, 0), 1);
const fullRange = this.maxPosition - this.minPosition;
const velocity = this.speed * fullRange; // units per second
if (velocity === 0) {
return reject(new Error("Movement aborted: zero speed"));
}
// Duration and bookkeeping
const duration = distance / velocity; // seconds to go the remaining distance
this.timeleft = duration;
this.logger.debug(
`Linear move: dir=${direction}, dist=${distance}, vel=${velocity.toFixed(2)} u/s, dur=${duration.toFixed(2)}s`
);
// Compute how much to move each tick
const intervalMs = this.interval;
const intervalSec = intervalMs / 1000;
const stepSize = direction * velocity * intervalSec;
const startTime = Date.now();
// Kick off the loop
const intervalId = setInterval(() => {
// 7a) Abort check
if (signal?.aborted) {
clearInterval(intervalId);
return reject(new Error("Movement aborted"));
}
// Advance position and clamp
this.currentPosition += stepSize;
this.currentPosition = this.constrain(this.currentPosition);
this.emitPos(this.currentPosition);
// Update timeleft
const elapsed = (Date.now() - startTime) / 1000;
this.timeleft = Math.max(0, duration - elapsed);
this.logger.debug(
`pos=${this.currentPosition.toFixed(2)}, timeleft=${this.timeleft.toFixed(2)}`
);
// Completed the move?
if (
(direction > 0 && this.currentPosition >= targetPosition) ||
(direction < 0 && this.currentPosition <= targetPosition)
) {
clearInterval(intervalId);
this.currentPosition = targetPosition;
this.emitPos(this.currentPosition);
return resolve("Reached target move.");
}
}, intervalMs);
// 8) Also catch aborts that happen before the first tick
signal?.addEventListener("abort", () => {
clearInterval(intervalId);
reject(new Error("Movement aborted"));
});
});
}
moveLinearinTime(targetPosition,signal) {
return new Promise((resolve, reject) => {
// Abort immediately if already signalled
if (signal?.aborted) {
return reject(new Error("Movement aborted"));
}
const direction = targetPosition > this.currentPosition ? 1 : -1;
const distance = Math.abs(targetPosition - this.currentPosition);
// Ensure speed is a percentage [0, 1]
this.speed = Math.min(Math.max(this.speed, 0), 1);
// Calculate duration based on percentage of distance per second
const duration = 1 / this.speed; // 1 second for 100% of the distance
this.timeleft = duration; //set this so other classes can use it
this.logger.debug(
`Linear movement: Direction=${direction}, Distance=${distance}, Duration=${duration}s`
);
let elapsedTime = 0;
const interval = this.interval; // Update every x ms
const totalSteps = Math.ceil((duration * 1000) / interval);
const stepSize = direction * (distance / totalSteps);
// 2) Set up the abort listener once
const intervalId = setInterval(() => {
// 3) Check for abort on each tick
if (signal?.aborted) {
clearInterval(intervalId);
return reject(new Error("Movement aborted"));
}
// Update elapsed time
elapsedTime += interval / 1000;
this.timeleft = duration - elapsedTime; //set this so other classes can use it
// Update the position incrementally
this.currentPosition += stepSize;
this.emitPos(this.currentPosition);
this.logger.debug(
`Using ${this.movementMode} => Current position ${this.currentPosition}`
);
// Check if the target position has been reached
if (
(direction > 0 && this.currentPosition >= targetPosition) ||
(direction < 0 && this.currentPosition <= targetPosition)
) {
clearInterval(intervalId);
this.currentPosition = targetPosition;
resolve(`Reached target move.`);
}
}, interval);
// Also attach abort outside the interval in case it fires before the first tick:
signal?.addEventListener("abort", () => {
clearInterval(intervalId);
reject(new Error("Movement aborted"));
});
});
}
moveEaseInOut(targetPosition, signal) {
return new Promise((resolve, reject) => {
// 1) Bail immediately if already aborted
if (signal?.aborted) {
return reject(new Error("Movement aborted"));
}
const direction = targetPosition > this.currentPosition ? 1 : -1;
const totalDistance = Math.abs(targetPosition - this.currentPosition);
const startPosition = this.currentPosition;
this.speed = Math.min(Math.max(this.speed, 0), 1);
const easeFunction = (t) =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
let elapsedTime = 0;
const duration = totalDistance / this.speed;
this.timeleft = duration;
const interval = this.interval;
// 2) Start the moving loop
const intervalId = setInterval(() => {
// 3) Check for abort on each tick
if (signal?.aborted) {
clearInterval(intervalId);
return reject(new Error("Movement aborted"));
}
elapsedTime += interval / 1000;
const progress = Math.min(elapsedTime / duration, 1);
this.timeleft = duration - elapsedTime;
const easedProgress = easeFunction(progress);
const newPosition =
startPosition + (targetPosition - startPosition) * easedProgress;
this.emitPos(newPosition);
this.logger.debug(
`Using ${this.movementMode} => Progress=${progress.toFixed(
2
)}, Eased=${easedProgress.toFixed(2)}`
);
if (progress >= 1) {
clearInterval(intervalId);
this.currentPosition = targetPosition;
resolve(`Reached target move.`);
} else {
this.currentPosition = newPosition;
}
}, interval);
// 4) Also listen once for abort before first tick
signal?.addEventListener("abort", () => {
clearInterval(intervalId);
reject(new Error("Movement aborted"));
});
});
}
emitPos(newPosition) {
this.emitter.emit("positionChange", newPosition);
}
constrain(value) {
return Math.min(Math.max(value, this.minPosition), this.maxPosition);
}
}
module.exports = movementManager;

131
src/state/state.js Normal file
View File

@@ -0,0 +1,131 @@
//load local dependencies
const EventEmitter = require('events');
const StateManager = require('./stateManager');
const MovementManager = require('./movementManager');
//load all config modules
const defaultConfig = require('./stateConfig.json');
const ConfigUtils = require('../../../generalFunctions/helper/configUtils');
class state{
constructor(config = {}, logger) {
this.emitter = new EventEmitter(); // Own EventEmitter
this.configUtils = new ConfigUtils(defaultConfig);
this.config = this.configUtils.initConfig(config);
this.abortController = null; // new abort controller for aborting async tasks
// Init after config is set
this.logger = logger;
// Initialize StateManager for state handling
this.stateManager = new StateManager(this.config,this.logger);
this.movementManager = new MovementManager(this.config, this.logger, this.emitter);
this.delayedMove = null;
this.mode = this.config.mode.current;
// Log initialization
this.logger.info("State class initialized.");
}
// -------- Delegate State Management -------- //
getMoveTimeLeft() {
return this.movementManager.timeleft;
}
getCurrentState() {
return this.stateManager.currentState;
}
getStateDescription() {
return this.stateManager.getStateDescription();
}
// -------- Movement Methods -------- //
getCurrentPosition() {
return this.movementManager.getCurrentPosition();
}
getRunTimeHours() {
return this.stateManager.getRunTimeHours();
}
async moveTo(targetPosition) {
// Check for invalid conditions and throw errors
if (targetPosition === this.getCurrentPosition()) {
this.logger.warn(`Target position=${targetPosition} is the same as the current position ${this.getCurrentPosition()}. Not executing move.`);
return;
}
if (this.stateManager.getCurrentState() !== "operational") {
if (this.config.mode.current === "auto") {
this.delayedMove = targetPosition;
this.logger.warn(`Saving setpoint=${targetPosition} to execute once back in 'operational' state.`);
}
else{
this.logger.warn(`Not able to accept setpoint=${targetPosition} while not in ${this.stateManager.getCurrentState()} state`);
}
//return early
return;
}
this.abortController = new AbortController();
const { signal } = this.abortController;
try {
const newState = targetPosition < this.getCurrentPosition() ? "decelerating" : "accelerating";
await this.transitionToState(newState,signal); // awaits transition
await this.movementManager.moveTo(targetPosition,signal); // awaits moving
this.emitter.emit("movementComplete", { position: targetPosition });
await this.transitionToState("operational");
} catch (error) {
this.logger.error(error);
}
}
// -------- State Transition Methods -------- //
async transitionToState(targetState, signal) {
const fromState = this.getCurrentState();
const position = this.getCurrentPosition();
try {
this.logger.debug(`Starting transition from ${fromState} to ${targetState}.`);
const feedback = await this.stateManager.transitionTo(targetState,signal);
this.logger.info(`Statemanager: ${feedback}`);
/* -- Auto pick setpoints in auto mode when operational--*/
if (
targetState === "operational" &&
this.config.mode.current === "auto" &&
this.delayedMove !== position &&
this.delayedMove
) {
this.logger.info(`Automatically picking up on last requested setpoint ${this.delayedMove}`);
//trigger move
await this.moveTo(this.delayedMove,signal);
this.delayedMove = null;
this.logger.info(`moveTo : ${feedback} `);
}
this.logger.info(`State change to ${targetState} completed.`);
this.emitter.emit('stateChange', targetState); // <-- Implement Here
} catch (error) {
if (
error.message === "Transition aborted" ||
error.message === "Movement aborted"
) {
throw error;
}
this.logger.error(error);
}
}
}
module.exports = state;

331
src/state/stateConfig.json Normal file
View File

@@ -0,0 +1,331 @@
{
"general": {
"name": {
"default": "State Configuration",
"rules": {
"type": "string",
"description": "A human-readable name for the state configuration."
}
},
"id": {
"default": null,
"rules": {
"type": "string",
"nullable": true,
"description": "A unique identifier for this configuration, assigned dynamically when needed."
}
},
"unit": {
"default": "unitless",
"rules": {
"type": "string",
"description": "The unit used for the state values (e.g., 'meters', 'seconds', 'unitless')."
}
},
"logging": {
"logLevel": {
"default": "info",
"rules": {
"type": "enum",
"values": [
{
"value": "debug",
"description": "Log messages are printed for debugging purposes."
},
{
"value": "info",
"description": "Informational messages are printed."
},
{
"value": "warn",
"description": "Warning messages are printed."
},
{
"value": "error",
"description": "Error messages are printed."
}
]
}
},
"enabled": {
"default": true,
"rules": {
"type": "boolean",
"description": "Indicates whether logging is active. If true, log messages will be generated."
}
}
}
},
"functionality": {
"softwareType": {
"default": "state class",
"rules": {
"type": "string",
"description": "Logical name identifying the software type."
}
},
"role": {
"default": "StateController",
"rules": {
"type": "string",
"description": "Functional role within the system."
}
}
},
"time": {
"starting": {
"default": 10,
"rules": {
"type": "number",
"description": "Time in seconds for the starting phase."
}
},
"warmingup": {
"default": 5,
"rules": {
"type": "number",
"description": "Time in seconds for the warming-up phase."
}
},
"stopping": {
"default": 5,
"rules": {
"type": "number",
"description": "Time in seconds for the stopping phase."
}
},
"coolingdown": {
"default": 10,
"rules": {
"type": "number",
"description": "Time in seconds for the cooling-down phase."
}
}
},
"movement": {
"mode": {
"default": "dynspeed",
"rules": {
"type": "enum",
"values": [
{
"value": "staticspeed",
"description": "Linear movement to setpoint."
},
{
"value": "dynspeed",
"description": "Ease-in and ease-out to setpoint."
}
]
}
},
"speed": {
"default": 1,
"rules": {
"type": "number",
"description": "Current speed setting."
}
},
"maxSpeed": {
"default": 10,
"rules": {
"type": "number",
"description": "Maximum speed setting."
}
},
"interval": {
"default": 1000,
"rules": {
"type": "number",
"description": "Feedback interval in milliseconds."
}
}
},
"position": {
"min": {
"default": 0,
"rules": {
"type": "number",
"description": "Minimum position value."
}
},
"max": {
"default": 100,
"rules": {
"type": "number",
"description": "Maximum position value."
}
},
"initial": {
"default": 0,
"rules": {
"type": "number",
"description": "Initial position value."
}
}
},
"state": {
"current": {
"default": "idle",
"rules": {
"type": "enum",
"values": [
{
"value": "idle",
"description": "Machine is idle."
},
{
"value": "starting",
"description": "Machine is starting up."
},
{
"value": "warmingup",
"description": "Machine is warming up."
},
{
"value": "operational",
"description": "Machine is running."
},
{
"value": "accelerating",
"description": "Machine is accelerating."
},
{
"value": "decelerating",
"description": "Machine is decelerating."
},
{
"value": "stopping",
"description": "Machine is stopping."
},
{
"value": "coolingdown",
"description": "Machine is cooling down."
},
{
"value": "off",
"description": "Machine is off."
}
],
"description": "Current state of the machine."
}
},
"allowedTransitions":{
"default": {},
"rules": {
"type": "object",
"schema": {
"idle": {
"default": ["starting", "off","emergencystop"],
"rules":{
"type": "set",
"itemType": "string",
"description": "Allowed transitions from idle state."
}
},
"starting": {
"default": ["starting","warmingup","emergencystop"],
"rules":{
"type": "set",
"itemType": "string",
"description": "Allowed transitions from starting state."
}
},
"warmingup": {
"default": ["operational","emergencystop"],
"rules":{
"type": "set",
"itemType": "string",
"description": "Allowed transitions from warmingup state."
}
},
"operational": {
"default": ["accelerating", "decelerating", "stopping","emergencystop"],
"rules":{
"type": "set",
"itemType": "string",
"description": "Allowed transitions from operational state."
}
},
"accelerating": {
"default": ["operational","emergencystop"],
"rules":{
"type": "set",
"itemType": "string",
"description": "Allowed transitions from accelerating state."
}
},
"decelerating": {
"default": ["operational","emergencystop"],
"rules":{
"type": "set",
"itemType": "string",
"description": "Allowed transitions from decelerating state."
}
},
"stopping": {
"default": ["idle","coolingdown","emergencystop"],
"rules":{
"type": "set",
"itemType": "string",
"description": "Allowed transitions from stopping state."
}
},
"coolingdown": {
"default": ["idle","off","emergencystop"],
"rules":{
"type": "set",
"itemType": "string",
"description": "Allowed transitions from coolingDown state."
}
},
"off": {
"default": ["idle","emergencystop"],
"rules":{
"type": "set",
"itemType": "string",
"description": "Allowed transitions from off state."
}
},
"emergencystop": {
"default": ["idle","off"],
"rules":{
"type": "set",
"itemType": "string",
"description": "Allowed transitions from emergency stop state."
}
}
},
"description": "Allowed transitions between states."
}
},
"activeStates":{
"default": ["operational", "starting", "warmingup", "accelerating", "decelerating"],
"rules": {
"type": "set",
"itemType": "string",
"description": "Active states."
}
}
},
"mode": {
"current": {
"default": "auto",
"rules": {
"type": "enum",
"values": [
{
"value": "auto",
"description": "Automatically tracks and handles delayed commands for setpoints > 0."
},
{
"value": "manual",
"description": "Requires explicit commands to start."
}
],
"description": "Current mode of the machine."
}
}
}
}

164
src/state/stateManager.js Normal file
View File

@@ -0,0 +1,164 @@
/**
* @file stateManager.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 managing state transitions and state descriptions.
* @description Class for managing state transitions and state descriptions.
* @module stateManager
* @exports stateManager
* @version 0.1.0
* @since 0.1.0
*
* Author:
* - Rene De Ren
* Email:
* - rene@thegoldenbasket.nl
*/
class stateManager {
constructor(config, logger) {
this.currentState = config.state.current;
this.availableStates = config.state.available;
this.descriptions = config.state.descriptions;
this.logger = logger;
this.transitionTimeleft = 0;
this.transitionTimes = config.time;
// Define valid transitions (can be extended dynamically if needed)
this.validTransitions = config.state.allowedTransitions;
// NEW: Initialize runtime tracking
this.runTimeHours = 0; // cumulative runtime in hours
this.runTimeStart = null; // timestamp when active state began
// Define active states (runtime counts only in these states)
this.activeStates = config.state.activeStates;
}
getCurrentState() {
return this.currentState;
}
transitionTo(newState,signal) {
return new Promise((resolve, reject) => {
if (signal && signal.aborted) {
this.logger.debug("Transition aborted.");
return reject("Transition aborted.");
}
if (!this.isValidTransition(newState)) {
return reject(
`Invalid transition from ${this.currentState} to ${newState}. Transition not executed.`
); //go back early and reject promise
}
// NEW: Handle runtime tracking based on active states
this.handleRuntimeTracking(newState);
const transitionDuration = this.transitionTimes[this.currentState] || 0; // Default to 0 if no transition time
this.logger.debug(
`Transition from ${this.currentState} to ${newState} will take ${transitionDuration}s.`
);
if (transitionDuration > 0) {
const timeoutId = setTimeout(() => {
this.currentState = newState;
resolve(`Transition from ${this.currentState} to ${newState} completed in ${transitionDuration}s.`);
}, transitionDuration * 1000);
if (signal) {
signal.addEventListener('abort', () => {
clearTimeout(timeoutId);
reject(new Error('Transition aborted'));
});
}
} else {
this.currentState = newState;
resolve(`Immediate transition to ${this.currentState} completed.`);
}
});
}
handleRuntimeTracking(newState) {
// NEW: Handle runtime tracking based on active states
const wasActive = this.activeStates.has(this.currentState);
const willBeActive = this.activeStates.has(newState);
if (wasActive && !willBeActive && this.runTimeStart) {
// stop runtime timer and accumulate elapsed time
const elapsed = (Date.now() - this.runTimeStart) / 3600000; // hours
this.runTimeHours += elapsed;
this.runTimeStart = null;
this.logger.debug(
`Runtime timer stopped; elapsed=${elapsed.toFixed(
3
)}h, total=${this.runTimeHours.toFixed(3)}h.`
);
} else if (!wasActive && willBeActive && !this.runTimeStart) {
// starting new runtime
this.runTimeStart = Date.now();
this.logger.debug("Runtime timer started.");
}
}
isValidTransition(newState) {
this.logger.debug(
`Check 1 Transition valid ? From ${
this.currentState
} To ${newState} => ${this.validTransitions[this.currentState]?.has(
newState
)} `
);
this.logger.debug(
`Check 2 Transition valid ? ${
this.currentState
} is not equal to ${newState} => ${this.currentState !== newState}`
);
// check if transition is valid and not the same as before
const valid =
this.validTransitions[this.currentState]?.has(newState) &&
this.currentState !== newState;
//if not valid
if (!valid) {
return false;
} else {
return true;
}
}
getStateDescription(state = this.currentState) {
return this.descriptions[state] || "No description available.";
}
// NEW: Getter to retrieve current cumulative runtime (active time) in hours.
getRunTimeHours() {
// If currently active add the ongoing duration.
let currentElapsed = 0;
if (this.runTimeStart) {
currentElapsed = (Date.now() - this.runTimeStart) / 3600000;
}
return this.runTimeHours + currentElapsed;
}
}
module.exports = stateManager;