From 860e37724e208c884a8da7eaa39b3852dbc21962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Fri, 8 Mar 2024 13:38:40 +0100 Subject: [PATCH 01/10] chore: update `.gitignore` --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 5dc62a9e..22285e9a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,13 +12,16 @@ pi.sh .venv _old desktop.ini +.idea/ *.bak +**/coverage **/grafana/var **/mosquitto/data **/mosquitto/log **/pgadmin +**/pnpm-lock.yaml **/portainer/data **/postgres/data **/volumes/traefik/acme.json From 09f673ac9e5524fcf1e6aff405c854a2e0cbee2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Fri, 8 Mar 2024 13:42:38 +0100 Subject: [PATCH 02/10] chore: install `@jest/globals` --- services/relay/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/services/relay/package.json b/services/relay/package.json index a23251fb..694db38d 100644 --- a/services/relay/package.json +++ b/services/relay/package.json @@ -24,6 +24,7 @@ "xml-js": "^1.6.11" }, "devDependencies": { + "@jest/globals": "^29.7.0", "jest": "^29.7.0", "nodemon": "^2.0.14" } From 56e961defdb79ec70a5da317c01283178338225e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Fri, 8 Mar 2024 16:12:01 +0100 Subject: [PATCH 03/10] feat(relay): add migration for `raw.conditions` --- services/relay/src/migrations/migrate.js | 1 + .../migrations/versions/044-conditions.sql | 43 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 services/relay/src/migrations/versions/044-conditions.sql diff --git a/services/relay/src/migrations/migrate.js b/services/relay/src/migrations/migrate.js index 21bf6e79..0ca819c2 100644 --- a/services/relay/src/migrations/migrate.js +++ b/services/relay/src/migrations/migrate.js @@ -25,6 +25,7 @@ const migrations = { 7: ['041-schedule'], 8: ['042-bins-metrics'], 9: ['043-setup-devices'], + 10: ['044-conditions'], }, } diff --git a/services/relay/src/migrations/versions/044-conditions.sql b/services/relay/src/migrations/versions/044-conditions.sql new file mode 100644 index 00000000..b77c1c34 --- /dev/null +++ b/services/relay/src/migrations/versions/044-conditions.sql @@ -0,0 +1,43 @@ +-- Add `raw.conditions` table used for persisting conditions data (e.g. alarms, warnings) + +-- Create the table +create table if not exists raw.conditions ( + time timestamptz primary key not null, -- When condition was raised, the start time + resolved_time timestamptz references raw.conditions(time), -- When condition was cleared, otherwise `null` for still active conditions, the end time + -- Note: We reference the `node_id` column of the `public.devices` view instead of `node_id` of `raw.nodes`, as in the view the nodes are filtered down to those items which have `nodes.props->>'node_type'` set to `Device`. + node_id int references public.devices (nodes_id) not null, + -- Note: We reference the `path` column of the `public.dataitems` view instead of `nodes.props->>'path'` of `raw.nodes`, as we cannot reference a JSONB property value and because in the view the nodes are filtered down to those items which have `nodes.props->>'node_type'` set to `Device`. + dataitem_id int references public.dataitems (path) not null, + state text check (state in ('Fault'::text, 'Normal'::text, 'Unavailable'::text, 'Warning'::text)) not null, -- Tag name of the condition when it was raised + type text not null check (type ~ '^x:.*' or type in ('ACCELERATION'::text, 'ACCUMULATED_TIME'::text, 'ACTIVATION_COUNT'::text, 'ACTIVE_AXES'::text, 'ACTIVE_POWER_SOURCE'::text, 'ACTUATOR'::text, 'ACTUATOR_STATE'::text, 'ADAPTER_SOFTWARE_VERSION'::text, 'ADAPTER_URI'::text, 'ALARM'::text, 'ALARM_LIMIT'::text, 'ALARM_LIMITS'::text, 'AMPERAGE'::text, 'AMPERAGE_AC'::text, 'AMPERAGE_DC'::text, 'ANGLE'::text, 'ANGULAR_ACCELERATION'::text, 'ANGULAR_DECELERATION'::text, 'ANGULAR_VELOCITY'::text, 'APPLICATION'::text, 'ASSET_CHANGED'::text, 'ASSET_COUNT'::text, 'ASSET_REMOVED'::text, 'ASSET_UPDATE_RATE'::text, 'AVAILABILITY'::text, 'AXIS_COUPLING'::text, 'AXIS_FEEDRATE'::text, 'AXIS_FEEDRATE_OVERRIDE'::text, 'AXIS_INTERLOCK'::text, 'AXIS_STATE'::text, 'BATTERY_CAPACITY'::text, 'BATTERY_CHARGE'::text, 'BATTERY_STATE'::text, 'BLOCK'::text, 'BLOCK_COUNT'::text, 'CAPACITY_FLUID'::text, 'CAPACITY_SPATIAL'::text, 'CHARACTERISTIC_PERSISTENT_ID'::text, 'CHARACTERISTIC_STATUS'::text, 'CHARGE_RATE'::text, 'CHUCK_INTERLOCK'::text, 'CHUCK_STATE'::text, 'CLOCK_TIME'::text, 'CODE'::text, 'COMMUNICATIONS'::text, 'COMPONENT_DATA'::text, 'COMPOSITION_STATE'::text, 'CONCENTRATION'::text, 'CONDUCTIVITY'::text, 'CONNECTION_STATUS'::text, 'CONTROLLER_MODE'::text, 'CONTROLLER_MODE_OVERRIDE'::text, 'CONTROL_LIMIT'::text, 'CONTROL_LIMITS'::text, 'COUPLED_AXES'::text, 'CUTTING_SPEED'::text, 'CYCLE_COUNT'::text, 'DATA_RANGE'::text, 'DATE_CODE'::text, 'DEACTIVATION_COUNT'::text, 'DECELERATION'::text, 'DENSITY'::text, 'DEPOSITION_ACCELERATION_VOLUMETRIC'::text, 'DEPOSITION_DENSITY'::text, 'DEPOSITION_MASS'::text, 'DEPOSITION_RATE_VOLUMETRIC'::text, 'DEPOSITION_VOLUME'::text, 'DEVICE_ADDED'::text, 'DEVICE_CHANGED'::text, 'DEVICE_REMOVED'::text, 'DEVICE_UUID'::text, 'DEW_POINT'::text, 'DIAMETER'::text, 'DIRECTION'::text, 'DISCHARGE_RATE'::text, 'DISPLACEMENT'::text, 'DISPLACEMENT_ANGULAR'::text, 'DISPLACEMENT_LINEAR'::text, 'DOOR_STATE'::text, 'ELECTRICAL_ENERGY'::text, 'EMERGENCY_STOP'::text, 'END_OF_BAR'::text, 'EQUIPMENT_MODE'::text, 'EQUIPMENT_TIMER'::text, 'EXECUTION'::text, 'FEATURE_MEASUREMENT'::text, 'FEATURE_PERSISTENT_ID'::text, 'FILL_LEVEL'::text, 'FIRMWARE'::text, 'FIXTURE_ID'::text, 'FLOW'::text, 'FOLLOWING_ERROR'::text, 'FOLLOWING_ERROR_ANGULAR'::text, 'FOLLOWING_ERROR_LINEAR'::text, 'FREQUENCY'::text, 'FUNCTIONAL_MODE'::text, 'GLOBAL_POSITION'::text, 'GRAVITATIONAL_ACCELERATION'::text, 'GRAVITATIONAL_FORCE'::text, 'HARDNESS'::text, 'HARDWARE'::text, 'HOST_NAME'::text, 'HUMIDITY_ABSOLUTE'::text, 'HUMIDITY_RELATIVE'::text, 'HUMIDITY_SPECIFIC'::text, 'INTERFACE_STATE'::text, 'LEAK_DETECT'::text, 'LENGTH'::text, 'LEVEL'::text, 'LIBRARY'::text, 'LINE'::text, 'LINEAR_FORCE'::text, 'LINE_LABEL'::text, 'LINE_NUMBER'::text, 'LOAD'::text, 'LOAD_COUNT'::text, 'LOCATION_ADDRESS'::text, 'LOCK_STATE'::text, 'LOGIC_PROGRAM'::text, 'MAINTENANCE_LIST'::text, 'MASS'::text, 'MATERIAL'::text, 'MATERIAL_LAYER'::text, 'MEASUREMENT_TYPE'::text, 'MEASUREMENT_UNITS'::text, 'MEASUREMENT_VALUE'::text, 'MESSAGE'::text, 'MOTION_PROGRAM'::text, 'MTCONNECT_VERSION'::text, 'NETWORK'::text, 'NETWORK_PORT'::text, 'OBSERVATION_UPDATE_RATE'::text, 'OPENNESS'::text, 'OPERATING_MODE'::text, 'OPERATING_SYSTEM'::text, 'OPERATOR_ID'::text, 'ORIENTATION'::text, 'PALLET_ID'::text, 'PART_COUNT'::text, 'PART_COUNT_TYPE'::text, 'PART_DETECT'::text, 'PART_GROUP_ID'::text, 'PART_ID'::text, 'PART_KIND_ID'::text, 'PART_NUMBER'::text, 'PART_PROCESSING_STATE'::text, 'PART_STATUS'::text, 'PART_UNIQUE_ID'::text, 'PATH_FEEDRATE'::text, 'PATH_FEEDRATE_OVERRIDE'::text, 'PATH_FEEDRATE_PER_REVOLUTION'::text, 'PATH_MODE'::text, 'PATH_POSITION'::text, 'PH'::text, 'POSITION'::text, 'POSITION_CARTESIAN'::text, 'POWER_FACTOR'::text, 'POWER_STATE'::text, 'POWER_STATUS'::text, 'PRESSURE'::text, 'PRESSURE_ABSOLUTE'::text, 'PRESSURIZATION_RATE'::text, 'PROCESS_AGGREGATE_ID'::text, 'PROCESS_KIND_ID'::text, 'PROCESS_OCCURRENCE_ID'::text, 'PROCESS_STATE'::text, 'PROCESS_TIME'::text, 'PROCESS_TIMER'::text, 'PROGRAM'::text, 'PROGRAM_COMMENT'::text, 'PROGRAM_EDIT'::text, 'PROGRAM_EDIT_NAME'::text, 'PROGRAM_HEADER'::text, 'PROGRAM_LOCATION'::text, 'PROGRAM_LOCATION_TYPE'::text, 'PROGRAM_NEST_LEVEL'::text, 'RESISTANCE'::text, 'ROTARY_MODE'::text, 'ROTARY_VELOCITY'::text, 'ROTARY_VELOCITY_OVERRIDE'::text, 'ROTATION'::text, 'SENSOR_ATTACHMENT'::text, 'SENSOR_STATE'::text, 'SERIAL_NUMBER'::text, 'SETTLING_ERROR'::text, 'SETTLING_ERROR_ANGULAR'::text, 'SETTLING_ERROR_LINEAR'::text, 'SOUND_LEVEL'::text, 'SPECIFICATION_LIMIT'::text, 'SPECIFICATION_LIMITS'::text, 'SPINDLE_INTERLOCK'::text, 'SPINDLE_SPEED'::text, 'STRAIN'::text, 'SYSTEM'::text, 'TEMPERATURE'::text, 'TENSION'::text, 'TILT'::text, 'TOOL_ASSET_ID'::text, 'TOOL_CUTTING_ITEM'::text, 'TOOL_GROUP'::text, 'TOOL_ID'::text, 'TOOL_NUMBER'::text, 'TOOL_OFFSET'::text, 'TOOL_OFFSETS'::text, 'TORQUE'::text, 'TRANSFER_COUNT'::text, 'TRANSLATION'::text, 'UNCERTAINTY'::text, 'UNCERTAINTY_TYPE'::text, 'UNLOAD_COUNT'::text, 'USER'::text, 'VALVE_STATE'::text, 'VARIABLE'::text, 'VELOCITY'::text, 'VISCOSITY'::text, 'VOLTAGE'::text, 'VOLTAGE_AC'::text, 'VOLTAGE_DC'::text, 'VOLT_AMPERE'::text, 'VOLT_AMPERE_REACTIVE'::text, 'VOLUME_FLUID'::text, 'VOLUME_SPATIAL'::text, 'WAIT_STATE'::text, 'WATTAGE'::text, 'WIRE'::text, 'WORKHOLDING_ID'::text, 'WORK_OFFSET'::text, 'WORK_OFFSETS'::text, 'X_DIMENSION'::text, 'Y_DIMENSION'::text, 'Z_DIMENSION'::text)), -- Condition type + condition_id text, + native_code text, + native_severity text, + qualifier text check (qualifier in ('HIGH'::text, 'LOW'::text)), + message text, -- Value of the tag + -- Columns `time`, `node_id`, `condition_id`, `dataitem_id` and `native_code` are used for condition uniqueness consideration, i.e. all of them together must be unique in the whole table + unique(time, node_id, dataitem_id, condition_id, native_code), + -- Make sure at least one of `condition_id` or `native_code` is defined + constraint check_conditions_condition_id_or_native_code check (condition_id is not null or native_code is not null) +); + +-- Create some extra indices +create index conditions_time_resolved_time_node_id_idx on raw.conditions using btree (time, resolved_time, node_id); +create index conditions_node_id_time_idx on raw.conditions using btree (node_id, time); + +-- Create a view +create or replace view public.conditions as + select + rc.time, + rc.resolved_time, + pd.props->>'name' AS device, + rc.path, + rc.state, + rc.type text, + rc.native_code, + rc.native_severity, + rc.qualifier, + message + from raw.conditions rc + join + public.nodes as pd on rc.node_id = pd.node_id; From fc1574ee7c0d0389705060c3c56ba32d23633675 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Fri, 8 Mar 2024 18:39:22 +0100 Subject: [PATCH 04/10] fix(relay): fix referencing `Observation#getHistoryRecords()` --- services/relay/src/dataObservations.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/relay/src/dataObservations.js b/services/relay/src/dataObservations.js index 5f799340..2da8cabf 100644 --- a/services/relay/src/dataObservations.js +++ b/services/relay/src/dataObservations.js @@ -51,7 +51,7 @@ export class Observations extends Data { // get history records to write to db // observations is now [{ device_id, dataitem_id, tag, dataItemId, name, timestamp, value }, ...] //. records is - const records = getHistoryRecords(this.observations) + const records = this.getHistoryRecords(this.observations) // write all records to db return await db.addHistory(records) From fca2733c68a5bd3f1a6442b1125e2a2ef85c1584 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Fri, 8 Mar 2024 18:41:42 +0100 Subject: [PATCH 05/10] feat(relay): add `Observation#getCachedConditionIndex()` --- services/relay/src/dataObservations.js | 48 ++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/services/relay/src/dataObservations.js b/services/relay/src/dataObservations.js index 2da8cabf..f28f7bed 100644 --- a/services/relay/src/dataObservations.js +++ b/services/relay/src/dataObservations.js @@ -57,6 +57,54 @@ export class Observations extends Data { return await db.addHistory(records) } + /** + * Get a cached condition index + * + * @param {{ + * time: Date, + * resolvedTime: Date | null, + * nodeId: number, + * dataitemId: number | string, + * state: string, + * type: string, + * conditionId: string | null, + * nativeCode: string | null, + * nativeSeverity: string | null, + * qualifier: string | null, + * message: string | null + * }} condition - Condition of a node + * + * @returns {number | undefined} - The index of a condition in a node + */ + getCachedConditionIndex(condition) { + // Invalid input should return `undefined` + !condition || !Object.keys(condition).some(i => (i === 'conditionId' || i === 'nativeCode') && condition[i]) + if (Object.prototype.toString.call(condition) !== '[object Object]') { + return undefined + } + + if (condition.nodeId in this.conditionCache) { + const cachedConditions = this.conditionCache[condition.nodeId] + + for (const cachedConditionIdx in cachedConditions) { + const cachedCondition = cachedConditions[cachedConditionIdx] + + // Check if the condition is already active + // Note: In MTConnect model v2.3, `conditionId` was added to identify a condition which should be the preferred. Conditions in older MTConnect model versions, however, still need to be identified using `nativeCode` and `dataitemId`. + if ( + condition?.conditionId === cachedCondition?.conditionId + || !('conditionId' in condition) + && condition.dataitemId === cachedCondition.dataitemId + && condition.nativeCode === cachedCondition.nativeCode + ) { + return +cachedConditionIdx + } + } + } + + return undefined + } + /** * Build up an array of history records to write * From 49a45b920d69d66507c784f220b3b3c5c3a02e8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Fri, 8 Mar 2024 18:54:01 +0100 Subject: [PATCH 06/10] feat(relay): add `Observation#cacheConditions()` --- services/relay/src/dataObservations.js | 85 ++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/services/relay/src/dataObservations.js b/services/relay/src/dataObservations.js index f28f7bed..25bbfca7 100644 --- a/services/relay/src/dataObservations.js +++ b/services/relay/src/dataObservations.js @@ -13,6 +13,28 @@ export class Observations extends Data { this.type = type // used by read method - will be 'current' or 'sample' this.agent = agent this.observations = null // array of dataitems + + /** + * List of conditions active in the last processed MTConnect XML per machine + * + * @type {{ + * [k in number]?: ({ + * time: Date, + * resolvedTime: Date | null, + * nodeId: number, + * dataitemId: number, + * state: string, + * type: string, + * conditionId: string | null, + * nativeCode: string | null, + * nativeSeverity: string | null, + * qualifier: string | null, + * message: string | null + * })[] + * }} + * @private + */ + this.conditionCache = {} } // read dataitem values from current/sample endpoints as xml/js tree, @@ -57,6 +79,69 @@ export class Observations extends Data { return await db.addHistory(records) } + /** + * Cache a condition of a node + * + * @param {{ + * time: Date, + * resolvedTime: Date | null, + * nodeId: number, + * dataitemId: number | string, + * state: string, + * type: string, + * conditionId: string | null, + * nativeCode: string | null, + * nativeSeverity: string | null, + * qualifier: string | null, + * message: string | null + * }} condition - Condition + * + * @returns {'insert' | 'update' | 'delete' | undefined} - Type of query we need to execute to persist the condition into the database, `undefined` when there is nothing to persist + * + * @remarks The structure of conditions is the same as the structure of `raw.conditions` table. + */ + cacheConditions(condition) { + // Invalid input should be disregarded + if (!condition || !Object.keys(condition).some(i => (i === 'conditionId' || i === 'nativeCode') && condition[i])) { + return undefined + } + + if (!(condition.nodeId in this.conditionCache)) { + // If there were no active conditions for a particular node, cache the current list of conditions + this.conditionCache[condition.nodeId] = [condition] + return 'insert' + } + + const conditionIndex = this.getCachedConditionIndex(condition) + + if (!Number.isInteger(conditionIndex)) { + // If a particular condition is not yet cached, cache it + if (condition.nodeId in this.conditionCache) { + // Append the condition to the array + this.conditionCache[condition.nodeId].push(condition) + } else { + // Create a new array with the condition + this.conditionCache[condition.nodeId] = [condition] + } + + return 'insert' + } else if (this.conditionCache[condition.nodeId][conditionIndex].state !== condition.state) { + // Update or remove the condition based on whether the `state` of the condition has changed or not + if (condition.state === 'Normal') { + // The condition is resolved, remove it from cache + this.conditionCache[condition.nodeId].splice(conditionIndex, 1) + } else { + // The condition changed its state to one different from `Normal`, update `time` and `state` + this.conditionCache[condition.nodeId][conditionIndex].time = condition.time + this.conditionCache[condition.nodeId][conditionIndex].state = condition.state + } + + return 'update' + } + + return undefined + } + /** * Get a cached condition index * From 347b52c1a4b7fab36b118be8daf8669a1eca990e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Fri, 8 Mar 2024 18:54:35 +0100 Subject: [PATCH 07/10] feat(relay): add tests of some `Observation` methods --- services/relay/src/dataObservations.spec.js | 837 +++++++++++++++++++- 1 file changed, 821 insertions(+), 16 deletions(-) diff --git a/services/relay/src/dataObservations.spec.js b/services/relay/src/dataObservations.spec.js index 45f7ab96..272b13dc 100644 --- a/services/relay/src/dataObservations.spec.js +++ b/services/relay/src/dataObservations.spec.js @@ -1,17 +1,813 @@ -import { jest } from '@jest/globals' +import { beforeEach, describe, expect, it, jest } from '@jest/globals' import { Observations } from './dataObservations' -describe('dataObservations', () => { +describe('Observations', () => { let observations - beforeAll(() => { + beforeEach(() => { observations = new Observations() }) + describe('cacheConditions()', () => { + let observations + + beforeAll(() => { + observations = new Observations() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('invalid input', () => { + it('should not cache anything for `null`', () => { + expect.assertions(2) + + const totalConditionsNumberBefore = Object.keys(observations['conditionCache']).reduce((a, c) => observations['conditionCache'][c].length, 0) + expect(observations.cacheConditions(null)).toBe(undefined) + const totalConditionsNumberAfter = Object.keys(observations['conditionCache']).reduce((a, c) => observations['conditionCache'][c].length, 0) + + expect(totalConditionsNumberBefore).toBe(totalConditionsNumberAfter) + }) + + it('should not cache anything for `undefined`', () => { + expect.assertions(2) + const totalConditionsNumberBefore = Object.keys(observations['conditionCache']).reduce((a, c) => observations['conditionCache'][c].length, 0) + expect(observations.cacheConditions(undefined)).toBe(undefined) + const totalConditionsNumberAfter = Object.keys(observations['conditionCache']).reduce((a, c) => observations['conditionCache'][c].length, 0) + + expect(totalConditionsNumberBefore).toBe(totalConditionsNumberAfter) + }) + + it('should not cache anything for a string', () => { + expect.assertions(2) + const totalConditionsNumberBefore = Object.keys(observations['conditionCache']).reduce((a, c) => observations['conditionCache'][c].length, 0) + expect(observations.cacheConditions('test')).toBe(undefined) + const totalConditionsNumberAfter = Object.keys(observations['conditionCache']).reduce((a, c) => observations['conditionCache'][c].length, 0) + + expect(totalConditionsNumberBefore).toBe(totalConditionsNumberAfter) + }) + + it('should not cache anything for a number', () => { + expect.assertions(2) + const totalConditionsNumberBefore = Object.keys(observations['conditionCache']).reduce((a, c) => observations['conditionCache'][c].length, 0) + expect(observations.cacheConditions(1)).toBe(undefined) + const totalConditionsNumberAfter = Object.keys(observations['conditionCache']).reduce((a, c) => observations['conditionCache'][c].length, 0) + + expect(totalConditionsNumberBefore).toBe(totalConditionsNumberAfter) + }) + + it('should not cache anything for a boolean', () => { + expect.assertions(2) + const totalConditionsNumberBefore = Object.keys(observations['conditionCache']).reduce((a, c) => observations['conditionCache'][c].length, 0) + expect(observations.cacheConditions(true)).toBe(undefined) + const totalConditionsNumberAfter = Object.keys(observations['conditionCache']).reduce((a, c) => observations['conditionCache'][c].length, 0) + + expect(totalConditionsNumberBefore).toBe(totalConditionsNumberAfter) + }) + + it('should not cache anything for an empty object', () => { + expect.assertions(2) + const totalConditionsNumberBefore = Object.keys(observations['conditionCache']).reduce((a, c) => observations['conditionCache'][c].length, 0) + expect(observations.cacheConditions({})).toBe(undefined) + const totalConditionsNumberAfter = Object.keys(observations['conditionCache']).reduce((a, c) => observations['conditionCache'][c].length, 0) + + expect(totalConditionsNumberBefore).toBe(totalConditionsNumberAfter) + }) + + it('should not cache anything for an empty array', () => { + expect.assertions(2) + const totalConditionsNumberBefore = Object.keys(observations['conditionCache']).reduce((a, c) => observations['conditionCache'][c].length, 0) + expect(observations.cacheConditions([])).toBe(undefined) + const totalConditionsNumberAfter = Object.keys(observations['conditionCache']).reduce((a, c) => observations['conditionCache'][c].length, 0) + + expect(totalConditionsNumberBefore).toBe(totalConditionsNumberAfter) + }) + + it('should not cache anything when neither `conditionId` nor `nativeCode` is defined', () => { + expect.assertions(2) + jest.replaceProperty(observations, 'conditionCache', {}) + + const condition = { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + } + + const totalConditionsNumberBefore = Object.keys(observations['conditionCache']).reduce((a, c) => observations['conditionCache'][c].length, 0) + expect(observations.cacheConditions(condition)).toBe(undefined) + const totalConditionsNumberAfter = Object.keys(observations['conditionCache']).reduce((a, c) => observations['conditionCache'][c].length, 0) + + expect(totalConditionsNumberBefore).toBe(totalConditionsNumberAfter) + }) + }) + + describe('cache updates', () => { + it('should add a new item with `nativeCode` to cache if it is not there yet', () => { + expect.assertions(2) + jest.replaceProperty(observations, 'conditionCache', {}) + + const condition = { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: null, + nativeCode: '345', + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + } + + expect(observations.cacheConditions(condition)).toBe('insert') + expect(observations['conditionCache']?.[condition.nodeId]?.length).toBe(1) + }) + + it('should add a new item with `conditionId` to cache if it is not there yet', () => { + expect.assertions(2) + jest.replaceProperty(observations, 'conditionCache', {}) + + const condition = { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: '3', + nativeCode: null, + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + } + + expect(observations.cacheConditions(condition)).toBe('insert') + expect(observations['conditionCache']?.[condition.nodeId]?.length).toBe(1) + }) + + it('should not add a new item with `nativeCode` to cache if it is already there', () => { + expect.assertions(2) + + jest.replaceProperty(observations, 'conditionCache', { + 173: [ + { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: null, + nativeCode: '345', + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + }, + ], + }) + + const condition = { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: null, + nativeCode: '345', + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + } + + expect(observations.cacheConditions(condition)).toBe(undefined) + expect(observations['conditionCache']?.[condition.nodeId]?.length).toBe(1) + }) + + it('should not add a new item with `conditionId` to cache if it is already there', () => { + expect.assertions(2) + + jest.replaceProperty(observations, 'conditionCache', { + 173: [ + { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: '1', + nativeCode: null, + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + }, + ], + }) + + const condition = { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: '1', + nativeCode: null, + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + } + + expect(observations.cacheConditions(condition)).toBe(undefined) + expect(observations['conditionCache']?.[condition.nodeId]?.length).toBe(1) + }) + + it('should append a condition to cache array when it is not yet cached with `conditionId`', () => { + expect.assertions(2) + + jest.replaceProperty(observations, 'conditionCache', { + 173: [ + { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: '1', + nativeCode: null, + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + }, + ], + }) + + const condition = { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 172, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: '1', + nativeCode: null, + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + } + + expect(observations.cacheConditions(condition)).toBe('insert') + expect(observations['conditionCache']?.[condition.nodeId]?.length).toBe(1) + }) + + it('should append a condition to cache array when it is not yet cached with `nativeCode`', () => { + expect.assertions(2) + + jest.replaceProperty(observations, 'conditionCache', { + 173: [ + { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: null, + nativeCode: '345', + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + }, + ], + }) + + const condition = { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 172, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: null, + nativeCode: '345', + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + } + + expect(observations.cacheConditions(condition)).toBe('insert') + expect(observations['conditionCache']?.[condition.nodeId]?.length).toBe(1) + }) + + it('should not cache a condition when it is already cached with `conditionId` and with the same `state`', () => { + expect.assertions(2) + + jest.replaceProperty(observations, 'conditionCache', { + 173: [ + { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: '1', + nativeCode: null, + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + }, + ], + }) + + const condition = observations['conditionCache'][173][0] + + expect(observations.cacheConditions(condition)).toBe(undefined) + expect(observations['conditionCache']?.[condition.nodeId]?.length).toBe(1) + }) + + it('should not cache a condition when it is already cached with `nativeCode` and with the same `state`', () => { + expect.assertions(2) + + jest.replaceProperty(observations, 'conditionCache', { + 173: [ + { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: null, + nativeCode: '344', + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + }, + ], + }) + + const condition = observations['conditionCache'][173][0] + + expect(observations.cacheConditions(condition)).toBe(undefined) + expect(observations['conditionCache']?.[condition.nodeId]?.length).toBe(1) + }) + + it('should remove a condition from cache array when it is already cached with `conditionId`, but with `state` changed to `Normal`', () => { + expect.assertions(2) + + jest.replaceProperty(observations, 'conditionCache', { + 173: [ + { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: '1', + nativeCode: null, + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + }, + ], + }) + + const condition = { + ...observations['conditionCache'][173][0], + time: new Date('2024-03-02T15:05:34.729Z'), + state: 'Normal', + } + + expect(observations.cacheConditions(condition)).toBe('update') + expect(observations['conditionCache']?.[condition.nodeId]?.length).toBe(0) + }) + + it('should remove a condition from cache array when it is already cached with `nativeCode`, but with `state` changed to `Normal`', () => { + expect.assertions(2) + + jest.replaceProperty(observations, 'conditionCache', { + 173: [ + { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: null, + nativeCode: '344', + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + }, + ], + }) + + const condition = { + ...observations['conditionCache'][173][0], + time: new Date('2024-03-02T15:05:34.729Z'), + state: 'Normal', + } + + expect(observations.cacheConditions(condition)).toBe('update') + expect(observations['conditionCache']?.[condition.nodeId]?.length).toBe(0) + }) + + it('should update a condition in cache array when it is already cached with `conditionId`, but with `state` changed to a state other than `Normal`', () => { + expect.assertions(6) + + jest.replaceProperty(observations, 'conditionCache', { + 173: [ + { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: '1', + nativeCode: null, + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + }, + ], + }) + + const condition = { + ...observations['conditionCache'][173][0], + time: new Date('2024-03-02T15:05:34.729Z'), + state: 'Warning', + } + + const conditionsNumberBefore = observations['conditionCache'][condition.nodeId]?.length + expect(observations.cacheConditions(condition)).toBe('update') + const conditionsNumberAfter = observations['conditionCache'][condition.nodeId]?.length + + expect(observations['conditionCache'][condition.nodeId].length).toBe(1) + expect(observations['conditionCache'][condition.nodeId].length).toBe(conditionsNumberBefore) + expect(conditionsNumberAfter).toBe(conditionsNumberBefore) + expect(observations['conditionCache'][condition.nodeId][0].time).toBe(condition.time) + expect(observations['conditionCache'][condition.nodeId][0].state).toBe(condition.state) + }) + + it('should update a condition in cache array when it is already cached with `nativeCode`, but with `state` changed to a state other than `Normal`', () => { + expect.assertions(6) + + jest.replaceProperty(observations, 'conditionCache', { + 173: [ + { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: null, + nativeCode: '334', + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + }, + ], + }) + + const condition = { + ...observations['conditionCache'][173][0], + time: new Date('2024-03-02T15:05:34.729Z'), + state: 'Warning', + } + + const conditionsNumberBefore = observations['conditionCache'][condition.nodeId]?.length + expect(observations.cacheConditions(condition)).toBe('update') + const conditionsNumberAfter = observations['conditionCache'][condition.nodeId]?.length + + expect(observations['conditionCache'][condition.nodeId].length).toBe(1) + expect(observations['conditionCache'][condition.nodeId].length).toBe(conditionsNumberBefore) + expect(conditionsNumberAfter).toBe(conditionsNumberBefore) + expect(observations['conditionCache'][condition.nodeId][0].time).toBe(condition.time) + expect(observations['conditionCache'][condition.nodeId][0].state).toBe(condition.state) + }) + }) + }) + + describe('getCachedConditionIndex()', () => { + let observations + + beforeAll(() => { + observations = new Observations() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('invalid input', () => { + it('should return `undefined` for `null`', () => { + expect.assertions(1) + expect(observations.getCachedConditionIndex(null)).toBe(undefined) + }) + + it('should return `undefined` for `undefined`', () => { + expect.assertions(1) + expect(observations.getCachedConditionIndex(undefined)).toBe(undefined) + }) + + it('should return `undefined` for a string', () => { + expect.assertions(1) + expect(observations.getCachedConditionIndex('test')).toBe(undefined) + }) + + it('should return `undefined` for a number', () => { + expect.assertions(1) + expect(observations.getCachedConditionIndex(1)).toBe(undefined) + }) + + it('should return `undefined` for a boolean', () => { + expect.assertions(1) + expect(observations.getCachedConditionIndex(true)).toBe(undefined) + }) + + it('should not cache anything for an empty object', () => { + expect.assertions(1) + expect(observations.getCachedConditionIndex({})).toBe(undefined) + }) + + it('should not cache anything for an empty array', () => { + expect.assertions(1) + expect(observations.getCachedConditionIndex([])).toBe(undefined) + }) + + it('should return `undefined` when neither `conditionId` nor `nativeCode` is defined', () => { + expect.assertions(1) + + const condition = { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + } + + expect(observations.getCachedConditionIndex(condition)).toBe(undefined) + }) + }) + + describe('valid input', () => { + it('should return `undefined` when no condition is cached', () => { + expect.assertions(2) + + expect(observations.getCachedConditionIndex({ + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: null, + nativeCode: '345', + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + })).toBe(undefined) + + expect(Object.keys(observations['conditionCache'])).toHaveLength(0) + }) + + it('should return `undefined` when the condition is not yet cached, but the same `conditionId` is cached for another node', () => { + expect.assertions(2) + + jest.replaceProperty(observations, 'conditionCache', { + 205: [ + { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 205, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: '1', + nativeCode: null, + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + }, + ], + }) + + expect(observations.getCachedConditionIndex({ + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: '1', + nativeCode: null, + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + })).toBe(undefined) + + expect(Object.keys(observations['conditionCache']).length).toBeGreaterThan(0) + }) + + it('should return `undefined` when the condition is not yet cached, but the same `nativeCode` is cached for another node', () => { + expect.assertions(2) + + jest.replaceProperty(observations, 'conditionCache', { + 205: [ + { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 205, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: null, + nativeCode: '234', + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + }, + ], + }) + + expect(observations.getCachedConditionIndex({ + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: null, + nativeCode: '234', + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + })).toBe(undefined) + + expect(Object.keys(observations['conditionCache']).length).toBeGreaterThan(0) + }) + + it('should return a `conditionCache` array index of the condition when the condition is already cached with `nativeCode` defined', () => { + expect.assertions(2) + + jest.replaceProperty(observations, 'conditionCache', { + 173: [ + { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: null, + nativeCode: '345', + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + }, + { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_temp_cond', + state: 'Warning', + type: 'SYSTEM', + conditionId: null, + nativeCode: '131', + nativeSeverity: 'warning', + qualifier: null, + message: 'Test temp alarm message', + }, + ], + 254: [ + { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 254, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: null, + nativeCode: '345', + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + }, + ], + }) + + const condition = { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: null, + nativeCode: '345', + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + } + + const idx = observations.getCachedConditionIndex(condition) + + expect(idx).toBe(0) + expect(observations['conditionCache'][condition.nodeId][idx]).toStrictEqual(condition) + }) + + it('should return a `conditionCache` array index of the condition when the condition is already cached with `conditionId` defined', () => { + expect.assertions(2) + + jest.replaceProperty(observations, 'conditionCache', { + 173: [ + { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: '345', + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + }, + { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_temp_cond', + state: 'Warning', + type: 'SYSTEM', + conditionId: '131', + nativeSeverity: 'warning', + qualifier: null, + message: 'Test temp alarm message', + }, + ], + 254: [ + { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 254, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: '345', + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + }, + ], + }) + + const condition = { + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: '345', + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + } + + const idx = observations.getCachedConditionIndex(condition) + + expect(idx).toBe(0) + expect(observations['conditionCache'][condition.nodeId][idx]).toStrictEqual(condition) + }) + }) + }) + describe('getHistoryRecords()', () => { - describe('general tests', () => { + describe('invalid observations', () => { it('should return an empty array when no observation has `dataitem_id` property', () => { + expect.assertions(1) + expect(observations.getHistoryRecords([{ category: 'CONDITION', dataItemId: '48e7290b1184_condition', @@ -20,12 +816,15 @@ describe('dataObservations', () => { tag: 'Normal', timestamp: '2024-02-25T03:13:36.67177Z', type: 'SYSTEM', - uid: 'Main/48e7290b1184_condition' + uid: 'Main/48e7290b1184_condition', }])).toStrictEqual([]) }) it('should return an empty array when no observation has `dataitem_id` property', () => { - const warn = jest.spyOn(console, 'warn').mockImplementation((m) => {}) + expect.assertions(1) + + const warn = jest.spyOn(console, 'warn').mockImplementation(() => { + }) const obs = { category: 'CONDITION', dataItemId: '48e7290b1184_condition', @@ -35,7 +834,7 @@ describe('dataObservations', () => { tag: 'Normal', timestamp: '2024-02-25T03:13:36.67177Z', type: 'SYSTEM', - uid: 'Main/48e7290b1184_condition' + uid: 'Main/48e7290b1184_condition', } observations.getHistoryRecords(obs) @@ -46,6 +845,8 @@ describe('dataObservations', () => { describe('test `value` type conversion', () => { it('should be converted to number when `category` is `SAMPLE` and `value` is a number stored as a string', () => { + expect.assertions(1) + expect(observations.getHistoryRecords([{ category: 'SAMPLE', dataItemId: '409151d72b38_pcc', @@ -57,18 +858,20 @@ describe('dataObservations', () => { tag: 'PartCount', timestamp: '2024-02-25T03:05:44.384587Z', uid: 'Main/409151d72b38_pcc', - value: '111' + value: '111', }])).toStrictEqual([ { dataitem_id: 771, node_id: 769, time: '2024-02-25T03:05:44.384587Z', - value: 111 - } + value: 111, + }, ]) }) it('should be converted to JSON string', () => { + expect.assertions(1) + expect(observations.getHistoryRecords([{ dataItemId: 'ox003_sharcs_adapter_uri', dataitem_id: 743, @@ -77,20 +880,22 @@ describe('dataObservations', () => { tag: 'AdapterURI', timestamp: '2024-02-25T03:05:44.384387Z', uid: 'Main/ox003_sharcs_adapter_uri', - value: 'mqtt://mosquitto:1883' + value: 'mqtt://mosquitto:1883', }])).toStrictEqual([ { dataitem_id: 743, node_id: 111, time: '2024-02-25T03:05:44.384387Z', - value: '"mqtt://mosquitto:1883"' - } + value: '"mqtt://mosquitto:1883"', + }, ]) }) }) describe('test the value for `CONDITION` category', () => { it('should be set to the `tag` value', () => { + expect.assertions(1) + expect(observations.getHistoryRecords([{ category: 'CONDITION', dataItemId: '48e7290b1184_condition', @@ -100,14 +905,14 @@ describe('dataObservations', () => { tag: 'Normal', timestamp: '2024-02-25T03:13:36.67177Z', type: 'SYSTEM', - uid: 'Main/48e7290b1184_condition' + uid: 'Main/48e7290b1184_condition', }])).toStrictEqual([ { dataitem_id: 1201, node_id: 751, time: '2024-02-25T03:13:36.67177Z', - value: '"Normal"' - } + value: '"Normal"', + }, ]) }) }) From 48b97726588777a8ec17a9ad70fa90c3071dd86d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Tue, 26 Mar 2024 13:48:39 +0100 Subject: [PATCH 08/10] feat(relay): add `Observation#isCondition()` --- services/relay/src/dataObservations.js | 34 ++++++++ services/relay/src/dataObservations.spec.js | 94 +++++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/services/relay/src/dataObservations.js b/services/relay/src/dataObservations.js index 25bbfca7..72ada4ca 100644 --- a/services/relay/src/dataObservations.js +++ b/services/relay/src/dataObservations.js @@ -142,6 +142,40 @@ export class Observations extends Data { return undefined } + /** + * Check whether an observation is a condition + * + * @todo Improve the type of Observation by allowing only some property values. + * + * @param {{ + * assetType?: string, // 'UNAVAILABLE' + * category?: string, // 'CONDITION' | 'EVENT' | 'SAMPLE' + * count?: string, // `${number}` + * dataItemId: string, // '*_asset_chg' | '*_asset_count' | '*_asset_rem' | '*_avail' | '*_condition' | '*_pcc' | 'agent_*_asset_chg' | 'agent_*_asset_count' | 'agent_*_asset_rem' | 'agent_avail' | 'device_added' | 'device_changed' | 'device_removed' | 'ox003_sharcs_adapter_software_version' | 'ox003_sharcs_adapter_uri' | 'ox003_sharcs_asset_update_rate' | 'ox003_sharcs_connection_status' | 'ox003_sharcs_mtconnect_version' | 'ox003_sharcs_observation_update_rate' + * dataitem_id?: number, + * device_id?: number, + * name?: string, // 'availability' | 'partcount' + * sequence: string, // `${number}` + * statistic?: string, // 'AVERAGE' + * subType?: string, // 'COMPLETE' + * tag: string, // 'AdapterSoftwareVersion' | 'AdapterURI' | 'AssetChanged' | 'AssetCountDataSet' | 'AssetRemoved' | 'AssetUpdateRate' | 'Availability' | 'ConnectionStatus' | 'DeviceAdded' | 'DeviceChanged' | 'DeviceRemoved' | 'MTConnectVersion' | 'Normal' | 'ObservationUpdateRate' | 'PartCount' + * timestamp: string, // `${Date.toISOString()}` + * type?: string, // 'SYSTEM' + * uid: string, // 'Main/*_asset_chg' | 'Main/*_asset_count' | 'Main/*_asset_rem' | 'Main/*_avail' | 'Main/*_condition' | 'Main/*_pcc' | 'Main/agent_*_asset_chg' | 'Main/agent_*_asset_count' | 'Main/agent_*_asset_rem' | 'Main/agent_avail' | 'Main/device_added' | 'Main/device_changed' | 'Main/device_removed' | 'Main/ox003_sharcs_adapter_software_version' | 'Main/ox003_sharcs_adapter_uri' | 'Main/ox003_sharcs_asset_update_rate' | 'Main/ox003_sharcs_connection_status' | 'Main/ox003_sharcs_mtconnect_version' | 'Main/ox003_sharcs_observation_update_rate' + * value?: string // '00000000-0000-0000-1337-409151d72b38' | 'AVAILABLE' | 'ESTABLISHED' | 'UNAVAILABLE' | 'mqtt://mosquitto:1883' + * }} observation - Observation of a node + * + * @returns {boolean} - Whether the observation is a condition + */ + isCondition(observation) { + // A condition must be an object + return Object.prototype.toString.call(observation) === '[object Object]' + // The `category` property must have a value of `CONDITION` + && observation.category === 'CONDITION' + // The `tag` property must have a value set to either `Fault`, `Normal`, `Warning` or `Unavailable` + && ['Fault', 'Normal', 'Warning', 'Unavailable'].includes(observation.tag) + } + /** * Get a cached condition index * diff --git a/services/relay/src/dataObservations.spec.js b/services/relay/src/dataObservations.spec.js index 272b13dc..4b6264a0 100644 --- a/services/relay/src/dataObservations.spec.js +++ b/services/relay/src/dataObservations.spec.js @@ -917,4 +917,98 @@ describe('Observations', () => { }) }) }) + + describe('isCondition()', () => { + describe('invalid input', () => { + it('should return `false` for `null`', () => { + expect.assertions(1) + expect(observations.isCondition(null)).toBe(false) + }) + + it('should return `false` for `undefined`', () => { + expect.assertions(1) + expect(observations.isCondition(undefined)).toBe(false) + }) + + it('should return `false` for a string', () => { + expect.assertions(1) + expect(observations.isCondition('test')).toBe(false) + }) + + it('should return `false` for a number', () => { + expect.assertions(1) + expect(observations.isCondition(1)).toBe(false) + }) + + it('should return `false` for a boolean', () => { + expect.assertions(1) + expect(observations.isCondition(true)).toBe(false) + }) + + it('should return `false` for an empty object', () => { + expect.assertions(1) + expect(observations.isCondition({})).toBe(false) + }) + + it('should return `false` for an empty array', () => { + expect.assertions(1) + expect(observations.isCondition([])).toBe(false) + }) + + it('should return `false` when `category` is not `CONDITION`', () => { + expect.assertions(1) + + const condition = { + category: 'NOT_CONDITION', + dataItemId: '48e7290b1184_condition', + device_id: 751, + sequence: '51', + tag: 'Normal', + timestamp: '2024-02-25T03:13:36.67177Z', + type: 'SYSTEM', + uid: 'Main/48e7290b1184_condition', + } + + expect(observations.isCondition(condition)).toBe(false) + }) + + it('should return `false` when `tag` has an unsupported value', () => { + expect.assertions(1) + + const condition = { + category: 'CONDITION', + dataItemId: '48e7290b1184_condition', + device_id: 751, + sequence: '51', + tag: 'Something else', + timestamp: '2024-02-25T03:13:36.67177Z', + type: 'SYSTEM', + uid: 'Main/48e7290b1184_condition', + } + + expect(observations.isCondition(condition)).toBe(false) + }) + }) + + describe('valid input', () => { + ['Fault', 'Normal', 'Warning', 'Unavailable'].forEach(tag => { + it(`should return \`true\` for a \`${tag}\``, () => { + expect.assertions(1) + + const condition = { + category: 'CONDITION', + dataItemId: '48e7290b1184_condition', + device_id: 751, + sequence: '51', + tag: `${tag}`, + timestamp: '2024-02-25T03:13:36.67177Z', + type: 'SYSTEM', + uid: 'Main/48e7290b1184_condition', + } + + expect(observations.isCondition(condition)).toBe(true) + }) + }) + }) + }) }) \ No newline at end of file From 5273f9e41aaa24ed7bb841076dd06f14fb5ed05c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Thu, 28 Mar 2024 13:58:27 +0100 Subject: [PATCH 09/10] feat(relay): add `Observation#getConditionIdentifiers()` --- services/relay/src/dataObservations.js | 46 +++++++++++++ services/relay/src/dataObservations.spec.js | 73 +++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/services/relay/src/dataObservations.js b/services/relay/src/dataObservations.js index 72ada4ca..c6b30b5d 100644 --- a/services/relay/src/dataObservations.js +++ b/services/relay/src/dataObservations.js @@ -176,6 +176,52 @@ export class Observations extends Data { && ['Fault', 'Normal', 'Warning', 'Unavailable'].includes(observation.tag) } + /** + * Get identifier properties of a particular condition + * + * @param {{ + * time: Date, + * resolvedTime: Date | null, + * nodeId: number, + * dataitemId: number | string, + * state: string, + * type: string, + * conditionId: string | null, + * nativeCode: string | null, + * nativeSeverity: string | null, + * qualifier: string | null, + * message: string | null + * }} condition - Condition of a node + * + * @returns {string[]} - An array of property names which identify the condition + * + * @remarks + * + * The returned array could be one of the following arrays or a subset of them without `message` or `type` when their are `undefined`: + * + * - MTConnect 2.3+: `['conditionId', 'nodeId', 'state', 'time', 'type']`; + * - MTConnect 2.2-: `['dataitemId', 'message', 'nodeId', 'state', 'time', 'type']`; + * - MTConnect 2.2-: `['message', 'nativeCode', 'nodeId', 'state', 'time', 'type']`. + * + * Note that this function does not check if the argument is actually a condition to optimize it. It assumes that the argument is a valid `condition` object. + */ + getConditionIdentifiers(condition) { + const ids = [] + + if (condition.hasOwnProperty('conditionId')) { + ids.push('conditionId', 'nodeId', 'state', 'time', 'type') + return ids + } + + ['dataitemId', 'message', 'nativeCode', 'nodeId', 'state', 'time', 'type'].forEach(i => { + if (condition.hasOwnProperty(i) && condition[i]) { + ids.push(i) + } + }) + + return ids + } + /** * Get a cached condition index * diff --git a/services/relay/src/dataObservations.spec.js b/services/relay/src/dataObservations.spec.js index 4b6264a0..0add4885 100644 --- a/services/relay/src/dataObservations.spec.js +++ b/services/relay/src/dataObservations.spec.js @@ -803,6 +803,79 @@ describe('Observations', () => { }) }) + describe('getConditionIdentifiers()', () => { + describe('valid conditions', () => { + it('should return condition identifiers for a condition with `conditionId`', () => { + expect.assertions(1) + + expect(observations.getConditionIdentifiers({ + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Fault', + type: 'SYSTEM', + conditionId: '3', + nativeCode: null, + nativeSeverity: 'error', + qualifier: null, + message: 'Test alarm message', + })).toStrictEqual(['conditionId', 'nodeId', 'state', 'time', 'type']) + }) + + it('should return condition identifiers for a condition with only `time`, `nodeId`, `dataitemId`, `state` and `type` defined', () => { + expect.assertions(1) + + expect(observations.getConditionIdentifiers({ + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Normal', + type: 'SYSTEM', + nativeCode: null, + nativeSeverity: null, + qualifier: null, + message: null, + })).toStrictEqual(['dataitemId', 'nodeId', 'state', 'time', 'type']) + }) + + it('should return condition identifiers for a condition with only `time`, `nativeCode`, `nodeId`, `dataitemId`, `state`, `type` and `message` defined', () => { + expect.assertions(1) + + expect(observations.getConditionIdentifiers({ + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Normal', + type: 'SYSTEM', + nativeCode: '375', + nativeSeverity: null, + qualifier: null, + message: 'Test alarm message', + })).toStrictEqual(['dataitemId', 'message', 'nativeCode', 'nodeId', 'state', 'time', 'type']) + }) + + it('should return condition identifiers for a condition with only `time`, `nodeId`, `dataitemId`, `state`, `type` and `message` defined', () => { + expect.assertions(1) + + expect(observations.getConditionIdentifiers({ + time: new Date('2024-03-02T14:05:34.729Z'), + resolvedTime: null, + nodeId: 173, + dataitemId: 'test_cond', + state: 'Normal', + type: 'SYSTEM', + nativeCode: null, + nativeSeverity: null, + qualifier: null, + message: 'Test alarm message', + })).toStrictEqual(['dataitemId', 'message', 'nodeId', 'state', 'time', 'type']) + }) + }) + }) + describe('getHistoryRecords()', () => { describe('invalid observations', () => { it('should return an empty array when no observation has `dataitem_id` property', () => { From f87e96259113630ab2ddcba606ed81555cac3909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Thu, 28 Mar 2024 14:09:47 +0100 Subject: [PATCH 10/10] test(relay): fix some test descriptions Now, the test descriptions describe what the test actually does. --- services/relay/src/dataObservations.spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/relay/src/dataObservations.spec.js b/services/relay/src/dataObservations.spec.js index 0add4885..ed9e5421 100644 --- a/services/relay/src/dataObservations.spec.js +++ b/services/relay/src/dataObservations.spec.js @@ -541,12 +541,12 @@ describe('Observations', () => { expect(observations.getCachedConditionIndex(true)).toBe(undefined) }) - it('should not cache anything for an empty object', () => { + it('should return `undefined` for an empty object', () => { expect.assertions(1) expect(observations.getCachedConditionIndex({})).toBe(undefined) }) - it('should not cache anything for an empty array', () => { + it('should return `undefined` for an empty array', () => { expect.assertions(1) expect(observations.getCachedConditionIndex([])).toBe(undefined) })