diff --git a/index.js b/index.js new file mode 100644 index 0000000..9e2c039 --- /dev/null +++ b/index.js @@ -0,0 +1,60 @@ +/** + * generalFunctions/index.js + * ----------------------------------------------------------- + * Central barrel file for re-exporting helpers and configurations. + * Provides both namespace exports and dynamic loading capabilities. + */ + +// Core helper modules +export * as menuUtils from './src/helper/menuUtils.js'; +export * as logger from './src/helper/logger.js'; +export * as validation from './src/helper/validationUtils.js'; + +// Domain-specific modules +export * as measurements from './src/measurements/index.js'; +export * as nrmse from './src/nrmse/index.js'; +export * as state from './src/state/index.js'; + +// Configuration loader with error handling +async function loadConfig(path) { + try { + const module = await import(path, { assert: { type: 'json' } }); + return module.default; + } catch (error) { + console.warn(`Failed to load config: ${path}`, error); + return null; + } +} + +// Lazy-loaded configurations +export const configs = { + get projectSettings() { + return loadConfig('./configs/projectSettings.json'); + }, + get measurementConfig() { + return loadConfig('./configs/measurementConfig.json'); + } +}; + +// Dynamic loaders with validation +export async function loadHelper(name) { + if (!name || typeof name !== 'string') { + throw new Error('Helper name must be a non-empty string'); + } + + try { + return await import(`./src/helper/${name}.js`); + } catch (error) { + throw new Error(`Failed to load helper "${name}": ${error.message}`); + } +} + +export async function loadAssetDatasets() { + try { + return await import('./datasets/assetData/suppliers.json', { + assert: { type: 'json' } + }); + } catch (error) { + throw new Error(`Failed to load asset datasets: ${error.message}`); + } +} diff --git a/package.json b/package.json index 0a6c263..944f4a0 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,17 @@ { - "name": "general-functions", + "name": "generalFunctions", "version": "1.0.0", "description": "General utility functions used across multiple Node-RED modules", - "main": "index.js", + "main": "./index.js", + + "exports": { + ".": "./index.js", + "./menuUtils": "./src/helper/menuUtils.js", + "./mathUtils": "./src/helper/mathUtils.js", + "./assetUtils": "./src/helper/assetUtils.js", + "./outputUtils": "./src/helper/outputUtils.js" + }, + "scripts": { "test": "node test.js" }, diff --git a/src/helper/assetUtils.js b/src/helper/assetUtils.js new file mode 100644 index 0000000..2155549 --- /dev/null +++ b/src/helper/assetUtils.js @@ -0,0 +1,3 @@ +export function getAssetVariables() { + +} \ No newline at end of file diff --git a/src/helper/childRegistrationUtils.js b/src/helper/childRegistrationUtils.js new file mode 100644 index 0000000..a7181a0 --- /dev/null +++ b/src/helper/childRegistrationUtils.js @@ -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; diff --git a/src/helper/configUtils.js b/src/helper/configUtils.js new file mode 100644 index 0000000..e81d1d5 --- /dev/null +++ b/src/helper/configUtils.js @@ -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; diff --git a/src/helper/logger.js b/src/helper/logger.js new file mode 100644 index 0000000..8b4f696 --- /dev/null +++ b/src/helper/logger.js @@ -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; \ No newline at end of file diff --git a/src/helper/menuUtils.js b/src/helper/menuUtils.js new file mode 100644 index 0000000..3e9d474 --- /dev/null +++ b/src/helper/menuUtils.js @@ -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 + ? `${options + .map((opt) => ``) + .join("")}` + : ""; + + if (savedValue && options.includes(savedValue)) { + htmlElement.value = savedValue; + } +} diff --git a/src/helper/nodeTemplates.js b/src/helper/nodeTemplates.js new file mode 100644 index 0000000..da259e4 --- /dev/null +++ b/src/helper/nodeTemplates.js @@ -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; diff --git a/src/helper/outputUtils.js b/src/helper/outputUtils.js new file mode 100644 index 0000000..bd6fb8d --- /dev/null +++ b/src/helper/outputUtils.js @@ -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; diff --git a/src/helper/validationUtils.js b/src/helper/validationUtils.js new file mode 100644 index 0000000..08f31e6 --- /dev/null +++ b/src/helper/validationUtils.js @@ -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; diff --git a/src/measurements/Measurement.js b/src/measurements/Measurement.js new file mode 100644 index 0000000..f9882b5 --- /dev/null +++ b/src/measurements/Measurement.js @@ -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; diff --git a/src/measurements/MeasurementBuilder.js b/src/measurements/MeasurementBuilder.js new file mode 100644 index 0000000..af95d05 --- /dev/null +++ b/src/measurements/MeasurementBuilder.js @@ -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; diff --git a/src/measurements/MeasurementContainer.js b/src/measurements/MeasurementContainer.js new file mode 100644 index 0000000..95cf168 --- /dev/null +++ b/src/measurements/MeasurementContainer.js @@ -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; diff --git a/src/measurements/README.md b/src/measurements/README.md new file mode 100644 index 0000000..b5f16ae --- /dev/null +++ b/src/measurements/README.md @@ -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(); +``` diff --git a/src/measurements/examples.js b/src/measurements/examples.js new file mode 100644 index 0000000..2c69c63 --- /dev/null +++ b/src/measurements/examples.js @@ -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'); + } +}; diff --git a/src/measurements/index.js b/src/measurements/index.js new file mode 100644 index 0000000..68fbcb6 --- /dev/null +++ b/src/measurements/index.js @@ -0,0 +1,9 @@ +const MeasurementContainer = require('./MeasurementContainer'); +const Measurement = require('./Measurement'); +const MeasurementBuilder = require('./MeasurementBuilder'); + +module.exports = { + MeasurementContainer, + Measurement, + MeasurementBuilder +}; diff --git a/src/nrmse/errorMetric.test.js b/src/nrmse/errorMetric.test.js new file mode 100644 index 0000000..51a22f2 --- /dev/null +++ b/src/nrmse/errorMetric.test.js @@ -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); diff --git a/src/nrmse/errorMetrics.js b/src/nrmse/errorMetrics.js new file mode 100644 index 0000000..c567ec4 --- /dev/null +++ b/src/nrmse/errorMetrics.js @@ -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; diff --git a/src/nrmse/nrmseConfig.json b/src/nrmse/nrmseConfig.json new file mode 100644 index 0000000..b8eeb9a --- /dev/null +++ b/src/nrmse/nrmseConfig.json @@ -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." + } + } + } +} diff --git a/src/outliers/outlierDetection.js b/src/outliers/outlierDetection.js new file mode 100644 index 0000000..2cdca81 --- /dev/null +++ b/src/outliers/outlierDetection.js @@ -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)); +*/ \ No newline at end of file diff --git a/src/state/movementManager.js b/src/state/movementManager.js new file mode 100644 index 0000000..8688cd4 --- /dev/null +++ b/src/state/movementManager.js @@ -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; diff --git a/src/state/state.js b/src/state/state.js new file mode 100644 index 0000000..a2ee626 --- /dev/null +++ b/src/state/state.js @@ -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; + diff --git a/src/state/stateConfig.json b/src/state/stateConfig.json new file mode 100644 index 0000000..8846c61 --- /dev/null +++ b/src/state/stateConfig.json @@ -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." + } + } + } + } + \ No newline at end of file diff --git a/src/state/stateManager.js b/src/state/stateManager.js new file mode 100644 index 0000000..4549308 --- /dev/null +++ b/src/state/stateManager.js @@ -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;