From 23cf829fdc4b37fad8ebc19ba2d9229216643f17 Mon Sep 17 00:00:00 2001 From: Khalid Adil Date: Thu, 19 Sep 2024 14:36:57 -0500 Subject: [PATCH] Add realtime output of telemetry data in conditionals and add support for historical conditional telemetry queries to allow for plotting --- src/plugins/condition/ConditionManager.js | 108 ++++++- .../ConditionSetTelemetryProvider.js | 3 +- .../components/ConditionCollection.vue | 18 +- .../condition/components/ConditionItem.vue | 96 +++++- .../condition/components/CriterionItem.vue | 2 +- src/plugins/condition/components/TestData.vue | 25 +- .../condition/historicalTelemetryProvider.js | 274 ++++++++++++++++++ .../plot/stackedPlot/StackedPlotItem.vue | 1 + src/plugins/plot/tickUtils.js | 4 +- 9 files changed, 510 insertions(+), 21 deletions(-) create mode 100644 src/plugins/condition/historicalTelemetryProvider.js diff --git a/src/plugins/condition/ConditionManager.js b/src/plugins/condition/ConditionManager.js index 4a18140cf7..f18eb068cc 100644 --- a/src/plugins/condition/ConditionManager.js +++ b/src/plugins/condition/ConditionManager.js @@ -24,6 +24,7 @@ import { EventEmitter } from 'eventemitter3'; import { v4 as uuid } from 'uuid'; import Condition from './Condition.js'; +import HistoricalTelemetryProvider from './historicalTelemetryProvider.js'; import { getLatestTimestamp } from './utils/time.js'; export default class ConditionManager extends EventEmitter { @@ -46,6 +47,8 @@ export default class ConditionManager extends EventEmitter { applied: false }; this.initialize(); + this.telemetryBuffer = []; + this.isProcessing = false; } subscribeToTelemetry(telemetryObject) { @@ -320,6 +323,17 @@ export default class ConditionManager extends EventEmitter { return currentCondition; } + getHistoricalData() { + const historicalTelemetry = new HistoricalTelemetryProvider( + this.openmct, + this.telemetryObjects, + this.compositionLoad, + this.conditions, + this.conditionSetDomainObject + ); + return historicalTelemetry.getHistoricalData(); + } + getCurrentConditionLAD(conditionResults) { const conditionCollection = this.conditionSetDomainObject.configuration.conditionCollection; let currentCondition = conditionCollection[conditionCollection.length - 1]; @@ -375,8 +389,26 @@ export default class ConditionManager extends EventEmitter { } const currentCondition = this.getCurrentConditionLAD(conditionResults); + let output = currentCondition?.configuration?.output; + if (output === 'telemetry value') { + const { outputTelemetry, outputMetadata } = currentCondition.configuration; + const outputTelemetryObject = await this.openmct.objects.get(outputTelemetry); + const telemetryOptions = { + size: 1, + strategy: 'latest', + timeContext: this.openmct.time.getContextForView([]) + }; + const latestData = await this.openmct.telemetry.request( + outputTelemetryObject, + telemetryOptions + ); + if (latestData?.[0]?.[outputMetadata]) { + output = latestData?.[0]?.[outputMetadata]; + } + } + const currentOutput = { - output: currentCondition.configuration.output, + output: output, id: this.conditionSetDomainObject.identifier, conditionId: currentCondition.id, ...latestTimestamp @@ -394,6 +426,18 @@ export default class ConditionManager extends EventEmitter { } } + const conditionTelemetries = []; + const conditions = this.conditionSetDomainObject.configuration.conditionCollection; + conditions.forEach((condition) => { + if (condition?.configuration?.outputTelemetry) { + conditionTelemetries.push(condition?.configuration?.outputTelemetry); + } + }); + + if (conditionTelemetries.includes(id)) { + return true; + } + return false; } @@ -415,7 +459,7 @@ export default class ConditionManager extends EventEmitter { timestamp[timeSystemKey] = currentTimestamp; if (this.shouldEvaluateNewTelemetry(currentTimestamp)) { this.updateConditionResults(normalizedDatum); - this.updateCurrentCondition(timestamp); + this.updateCurrentCondition(timestamp, endpoint, datum); } } @@ -428,14 +472,12 @@ export default class ConditionManager extends EventEmitter { }); } - updateCurrentCondition(timestamp) { - const currentCondition = this.getCurrentCondition(); - + emitConditionSetResult(currentCondition, timestamp, outputValue) { this.emit( 'conditionSetResultUpdated', Object.assign( { - output: currentCondition.configuration.output, + output: outputValue, id: this.conditionSetDomainObject.identifier, conditionId: currentCondition.id }, @@ -444,6 +486,60 @@ export default class ConditionManager extends EventEmitter { ); } + updateCurrentCondition(timestamp, telemetryObject, telemetryData) { + this.telemetryBuffer.push({ timestamp, telemetryObject, telemetryData }); + + if (!this.isProcessing) { + this.processBuffer(); + } + } + + async processBuffer() { + this.isProcessing = true; + + while (this.telemetryBuffer.length > 0) { + const { timestamp, telemetryObject, telemetryData } = this.telemetryBuffer.shift(); + await this.processCondition(timestamp, telemetryObject, telemetryData); + } + + this.isProcessing = false; + } + + async processCondition(timestamp, telemetryObject, telemetryData) { + const currentCondition = this.getCurrentCondition(); + let telemetryValue = currentCondition.configuration.output; + if (currentCondition?.configuration?.outputTelemetry) { + const selectedOutputIdentifier = currentCondition?.configuration?.outputTelemetry; + const outputMetadata = currentCondition?.configuration?.outputMetadata; + const telemetryKeystring = this.openmct.objects.makeKeyString(telemetryObject.identifier); + + if (selectedOutputIdentifier === telemetryKeystring) { + telemetryValue = telemetryData[outputMetadata]; + } else { + const outputTelemetryObject = await this.openmct.objects.get(selectedOutputIdentifier); + const telemetryOptions = { + size: 1, + strategy: 'latest', + start: timestamp?.utc - 1000, + end: timestamp?.utc + 1000 + }; + const outputTelemetryData = await this.openmct.telemetry.request( + outputTelemetryObject, + telemetryOptions + ); + const outputTelemetryValue = + outputTelemetryData?.length > 0 ? outputTelemetryData.slice(-1)[0] : null; + if (outputTelemetryData.length && outputTelemetryValue?.[outputMetadata]) { + telemetryValue = outputTelemetryValue?.[outputMetadata]; + } else { + telemetryValue = undefined; + } + } + } + + this.emitConditionSetResult(currentCondition, timestamp, telemetryValue); + } + getTestData(metadatum) { let data = undefined; if (this.testData.applied) { diff --git a/src/plugins/condition/ConditionSetTelemetryProvider.js b/src/plugins/condition/ConditionSetTelemetryProvider.js index 38e7d4cb83..c45b13c22d 100644 --- a/src/plugins/condition/ConditionSetTelemetryProvider.js +++ b/src/plugins/condition/ConditionSetTelemetryProvider.js @@ -42,8 +42,9 @@ export default class ConditionSetTelemetryProvider { async request(domainObject, options) { let conditionManager = this.getConditionManager(domainObject); + const formattedHistoricalData = await conditionManager.getHistoricalData(); let latestOutput = await conditionManager.requestLADConditionSetOutput(options); - return latestOutput; + return [...formattedHistoricalData, ...latestOutput]; } subscribe(domainObject, callback) { diff --git a/src/plugins/condition/components/ConditionCollection.vue b/src/plugins/condition/components/ConditionCollection.vue index 84b5aff008..146955002f 100644 --- a/src/plugins/condition/components/ConditionCollection.vue +++ b/src/plugins/condition/components/ConditionCollection.vue @@ -235,10 +235,11 @@ export default { return arr; }, - addTelemetryObject(domainObject) { + async addTelemetryObject(domainObject) { const keyString = this.openmct.objects.makeKeyString(domainObject.identifier); + const telemetryPath = await this.getFullTelemetryPath(domainObject); - this.telemetryObjs.push(domainObject); + this.telemetryObjs.push({ ...domainObject, path: telemetryPath }); this.$emit('telemetry-updated', this.telemetryObjs); this.subscribeToStaleness(domainObject, (stalenessResponse) => { @@ -248,6 +249,19 @@ export default { }); }); }, + async getFullTelemetryPath(telemetry) { + const keyString = this.openmct.objects.makeKeyString(telemetry.identifier); + const originalPathObjects = await this.openmct.objects.getOriginalPath(keyString, []); + + const telemetryPath = originalPathObjects.reverse().map((pathObject) => { + if (pathObject.type !== 'root') { + return pathObject.name; + } + return undefined; + }); + + return telemetryPath.join('/'); + }, removeTelemetryObject(identifier) { const keyString = this.openmct.objects.makeKeyString(identifier); const index = this.telemetryObjs.findIndex((obj) => { diff --git a/src/plugins/condition/components/ConditionItem.vue b/src/plugins/condition/components/ConditionItem.vue index dd42a58316..250c517154 100644 --- a/src/plugins/condition/components/ConditionItem.vue +++ b/src/plugins/condition/components/ConditionItem.vue @@ -99,13 +99,13 @@ @change="setOutputValue" > + + + + + + -
Match @@ -181,7 +214,12 @@ {{ condition.configuration.name }} - Output: {{ condition.configuration.output }} + + Output: + {{ + condition.configuration.output === undefined ? 'none' : condition.configuration.output + }} +
@@ -250,10 +288,11 @@ export default { expanded: true, trigger: 'all', selectedOutputSelection: '', - outputOptions: ['false', 'true', 'string'], + outputOptions: ['none', 'false', 'true', 'string', 'telemetry value'], criterionIndex: 0, draggingOver: false, - isDefault: this.condition.isDefault + isDefault: this.condition.isDefault, + telemetryMetadataOptions: {} }; }, computed: { @@ -287,26 +326,51 @@ export default { return false; } }, + watch: { + condition: { + handler() { + if (this.condition.configuration.output !== 'telemetry value') { + this.condition.configuration.outputTelemetry = null; + this.condition.configuration.outputMetadata = null; + } + }, + deep: true + }, + isEditing(newValue, oldValue) { + if (newValue === true) { + this.initializeMetadata(); + } + } + }, unmounted() { this.destroy(); }, mounted() { this.setOutputSelection(); + this.initializeMetadata(); }, methods: { setOutputSelection() { let conditionOutput = this.condition.configuration.output; if (conditionOutput) { - if (conditionOutput !== 'false' && conditionOutput !== 'true') { + if ( + conditionOutput !== 'false' && + conditionOutput !== 'true' && + conditionOutput !== 'telemetry value' + ) { this.selectedOutputSelection = 'string'; } else { this.selectedOutputSelection = conditionOutput; } + } else if (conditionOutput === undefined) { + this.selectedOutputSelection = 'none'; } }, setOutputValue() { if (this.selectedOutputSelection === 'string') { this.condition.configuration.output = ''; + } else if (this.selectedOutputSelection === 'none') { + this.condition.configuration.output = undefined; } else { this.condition.configuration.output = this.selectedOutputSelection; } @@ -401,6 +465,24 @@ export default { }, initCap(str) { return str.charAt(0).toUpperCase() + str.slice(1); + }, + initializeMetadata() { + this.telemetry.forEach((telemetryObject) => { + const id = this.openmct.objects.makeKeyString(telemetryObject.identifier); + let telemetryMetadata = this.openmct.telemetry.getMetadata(telemetryObject); + if (telemetryMetadata) { + this.telemetryMetadataOptions[id] = telemetryMetadata.values().slice(); + } else { + this.telemetryMetadataOptions[id] = []; + } + }); + }, + getId(identifier) { + if (identifier) { + return this.openmct.objects.makeKeyString(identifier); + } + + return []; } } }; diff --git a/src/plugins/condition/components/CriterionItem.vue b/src/plugins/condition/components/CriterionItem.vue index a632d675a1..4d0b94e094 100644 --- a/src/plugins/condition/components/CriterionItem.vue +++ b/src/plugins/condition/components/CriterionItem.vue @@ -40,7 +40,7 @@ :key="telemetryOption.identifier.key" :value="telemetryOption.identifier" > - {{ telemetryOption.name }} + {{ telemetryOption.path }} diff --git a/src/plugins/condition/components/TestData.vue b/src/plugins/condition/components/TestData.vue index 7d25680703..1e6f695cf0 100644 --- a/src/plugins/condition/components/TestData.vue +++ b/src/plugins/condition/components/TestData.vue @@ -63,7 +63,7 @@ :key="index" :value="telemetryOption.identifier" > - {{ telemetryOption.name }} + {{ telemetryPaths[index] || telemetryOption.name }} @@ -147,7 +147,8 @@ export default { expanded: true, isApplied: false, testInputs: [], - telemetryMetadataOptions: {} + telemetryMetadataOptions: {}, + telemetryPaths: [] }; }, watch: { @@ -200,6 +201,10 @@ export default { this.telemetryMetadataOptions[id] = []; } }); + this.telemetry.forEach(async (telemetryOption, index) => { + const telemetryPath = await this.getFullTelemetryPath(telemetryOption); + this.telemetryPaths[index] = telemetryPath; + }); }, addTestInput(testInput) { this.testInputs.push( @@ -244,6 +249,22 @@ export default { applied: this.isApplied, conditionTestInputs: this.testInputs }); + }, + async getFullTelemetryPath(telemetry) { + const keyStringForObject = this.openmct.objects.makeKeyString(telemetry.identifier); + const originalPathObjects = await this.openmct.objects.getOriginalPath( + keyStringForObject, + [] + ); + + const telemetryPath = originalPathObjects.reverse().map((pathObject) => { + if (pathObject.type !== 'root') { + return pathObject.name; + } + return undefined; + }); + + return telemetryPath.join('/'); } } }; diff --git a/src/plugins/condition/historicalTelemetryProvider.js b/src/plugins/condition/historicalTelemetryProvider.js new file mode 100644 index 0000000000..f5377d8231 --- /dev/null +++ b/src/plugins/condition/historicalTelemetryProvider.js @@ -0,0 +1,274 @@ +export default class HistoricalTelemetryProvider { + constructor(openmct, telemetryObjects, compositionLoad, conditions, conditionSetDomainObject) { + this.openmct = openmct; + this.telemetryObjects = telemetryObjects; + this.compositionLoad = compositionLoad; + this.bounds = { start: null, end: null }; + this.telemetryList = []; + this.conditions = conditions; + this.conditionSetDomainObject = conditionSetDomainObject; + this.historicalTelemetryPoolMap = new Map(); + this.historicalTelemetryDateMap = new Map(); + this.index = 0; + } + + setTimeBounds(bounds) { + this.bounds = bounds; + } + + refreshAllHistoricalTelemetries() { + const refreshPromises = []; + for (const [, value] of Object.entries(this.telemetryObjects)) { + refreshPromises.push(this.refreshHistoricalTelemetry(value)); + } + return Promise.all(refreshPromises); + } + + async refreshHistoricalTelemetry(domainObject, identifier) { + console.log('refreshHistoricalTelemetry'); + if (!domainObject && identifier) { + domainObject = await this.openmct.objects.get(identifier); + } + const id = this.openmct.objects.makeKeyString(domainObject.identifier); + const telemetryOptions = { ...this.bounds }; + const historicalTelemetry = await this.openmct.telemetry.request( + domainObject, + telemetryOptions + ); + this.historicalTelemetryPoolMap.set(id, { domainObject, historicalTelemetry }); + return { domainObject, historicalTelemetry }; + } + + evaluateTrueCondition(historicalDateMap, timestamp, condition, conditionCollectionMap) { + const telemetryData = historicalDateMap.get(timestamp); + const conditionConfiguration = conditionCollectionMap.get(condition.id)?.configuration; + const { outputTelemetry, outputMetadata } = conditionConfiguration; + let output = {}; + if (outputTelemetry) { + const outputTelemetryID = this.openmct.objects.makeKeyString(outputTelemetry); + const outputTelemetryData = telemetryData.get(outputTelemetryID); + output.telemetry = outputTelemetryData; + output.value = outputTelemetryData[outputMetadata]; + output.condition = condition; + } else if (conditionConfiguration?.output) { + output.telemetry = null; + output.value = conditionConfiguration?.output; + output.condition = condition; + } + return output; + } + + async getAllTelemetries(conditionCollection) { + const conditionCollectionMap = new Map(); + const inputTelemetries = []; + const outputTelemetries = []; + const historicalTelemetryPoolPromises = []; + + conditionCollection.forEach((condition, index) => { + console.log('-------------------------'); + console.log(condition); + const { id } = condition; + const { criteria, output, outputTelemetry, outputMetadata } = condition.configuration; + const inputTelemetry = criteria?.[0]?.telemetry; + console.log(id); + console.log(criteria); + console.log(output); + console.log(outputMetadata); + console.log('inputTelemetry', inputTelemetry); + console.log('outputTelemetry', outputTelemetry); + conditionCollectionMap.set(condition?.id, condition); + if (inputTelemetry) { + const inputTelemetryId = this.openmct.objects.makeKeyString(inputTelemetry); + if (![...inputTelemetries, ...outputTelemetries].includes(inputTelemetryId)) { + historicalTelemetryPoolPromises.push( + this.refreshHistoricalTelemetry(null, inputTelemetry) + ); + } + inputTelemetries.push(inputTelemetryId); + } else { + inputTelemetries.push(null); + } + if (outputTelemetry) { + if (![...inputTelemetries, ...outputTelemetries].includes(outputTelemetry)) { + historicalTelemetryPoolPromises.push( + this.refreshHistoricalTelemetry(null, outputTelemetry) + ); + } + outputTelemetries.push(outputTelemetry); + } else { + outputTelemetries.push(null); + } + }); + + const historicalTelemetriesPool = await Promise.all(historicalTelemetryPoolPromises); + return { + historicalTelemetriesPool, + inputTelemetries, + outputTelemetries, + conditionCollectionMap + }; + } + + sortTelemetriesByDate(historicalTelemetriesPool) { + const historicalTelemetryDateMap = new Map(); + historicalTelemetriesPool.forEach((historicalTelemetryList) => { + const { historicalTelemetry, domainObject } = historicalTelemetryList; + const { identifier } = domainObject; + const telemetryIdentifier = this.openmct.objects.makeKeyString(identifier); + historicalTelemetry.forEach((historicalTelemetryItem) => { + if (!historicalTelemetryDateMap.get(historicalTelemetryItem.utc)) { + const telemetryMap = new Map(); + telemetryMap.set(telemetryIdentifier, historicalTelemetryItem); + historicalTelemetryDateMap.set(historicalTelemetryItem.utc, telemetryMap); + } else { + const telemetryMap = historicalTelemetryDateMap.get(historicalTelemetryItem.utc); + telemetryMap.set(telemetryIdentifier, historicalTelemetryItem); + historicalTelemetryDateMap.set(historicalTelemetryItem.utc, telemetryMap); + } + }); + }); + return historicalTelemetryDateMap; + } + + evaluateConditionsByDate(historicalTelemetryDateMap, conditionCollectionMap) { + const outputTelemetryDateMap = new Map(); + historicalTelemetryDateMap.forEach((historicalTelemetryMap, timestamp) => { + let isConditionValid = false; + const evaluatedConditions = []; + this.conditions.forEach((condition) => { + if (isConditionValid) { + return; + } + const { id } = condition; + const conditionMetadata = { condition }; + const conditionCriteria = condition.criteria[0]; + let result; + if (conditionCriteria?.telemetry) { + const conditionInputTelemetryId = this.openmct.objects.makeKeyString( + conditionCriteria.telemetry + ); + const inputTelemetry = historicalTelemetryMap.get(conditionInputTelemetryId); + conditionMetadata.inputTelemetry = inputTelemetry; + result = conditionCriteria.computeResult({ + id, + ...inputTelemetry + }); + } else if (!conditionCriteria) { + const conditionDetails = conditionCollectionMap.get(id); + const { isDefault } = conditionDetails; + const conditionConfiguration = conditionDetails?.configuration; + const { outputTelemetry, outputMetadata, output } = conditionConfiguration; + if (isDefault) { + const conditionOutput = { + telemetry: null, + value: output, + condition + }; + outputTelemetryDateMap.set(timestamp, conditionOutput); + } + } + conditionMetadata.result = result; + evaluatedConditions.push(conditionMetadata); + if (result === true) { + isConditionValid = true; + const conditionOutput = this.evaluateTrueCondition( + historicalTelemetryDateMap, + timestamp, + condition, + conditionCollectionMap + ); + console.log(conditionOutput.value); + outputTelemetryDateMap.set(timestamp, conditionOutput); + } + }); + }); + return outputTelemetryDateMap; + } + + async getHistoricalInputsByDate() { + console.log('getHistoricalInputsByDate'); + console.log(this.conditions); + const conditionCollection = this.conditionSetDomainObject.configuration.conditionCollection; + + const { + historicalTelemetriesPool, + inputTelemetries, + outputTelemetries, + conditionCollectionMap + } = await this.getAllTelemetries(conditionCollection); + + const historicalTelemetryDateMap = this.sortTelemetriesByDate(historicalTelemetriesPool); + const outputTelemetryDateMap = this.evaluateConditionsByDate( + historicalTelemetryDateMap, + conditionCollectionMap + ); + + console.log('*️⃣*️⃣*️⃣*️⃣*️⃣*️⃣*️⃣*️⃣*️⃣*️⃣*️⃣*️⃣*️⃣*️⃣'); + console.log(historicalTelemetriesPool); + console.log(this.historicalTelemetryPoolMap); + console.log(inputTelemetries); + console.log(outputTelemetries); + console.log(historicalTelemetryDateMap); + console.log(outputTelemetryDateMap); + + return outputTelemetryDateMap; + } + + addItemToHistoricalTelemetryMap(telemetryMap, item, type, index) { + if (type === 'input') { + telemetryMap.set(); + } + } + + async getHistoricalData() { + console.log('getHistoricalData'); + await this.compositionLoad; + this.setTimeBounds(this.openmct.time.getBounds()); + const outputTelemetryMap = await this.getHistoricalInputsByDate(); + const formattedOutputTelemetry = this.formatOutputData(outputTelemetryMap); + // const firstObjectKey = this.historicalTelemetryPoolMap.keys().next().value; + // const firstObjectValue = this.historicalTelemetryPoolMap.values().next().value; + // const formattedHistoricalData = this.formatHistoricalData(firstObjectKey, firstObjectValue); + console.log(formattedOutputTelemetry); + // console.log(formattedHistoricalData); + return formattedOutputTelemetry; + } + + formatOutputData(outputTelemetryMap) { + const outputTelemetryList = []; + const domainObject = this.conditionSetDomainObject; + outputTelemetryMap.forEach((outputMetadata, timestamp) => { + const { condition, telemetry, value } = outputMetadata; + outputTelemetryList.push({ + conditionId: condition.id, + id: domainObject.identifier, + output: value, + utc: timestamp + }); + }); + return outputTelemetryList; + } + + simpleTelemetryList(outputTelemetryMap) { + const outputTelemetryList = []; + outputTelemetryMap.forEach((outputMetadata, timestamp) => { + const { value } = outputMetadata; + outputTelemetryList.push(value); + }); + return outputTelemetryList; + } + + formatHistoricalData(historicalDataKey, telemetryDetails) { + const formattedData = []; + const { domainObject, historicalTelemetry } = telemetryDetails; + historicalTelemetry.forEach((value) => { + formattedData.push({ + id: domainObject.identifier, + output: value.sin, + conditionId: historicalDataKey, + utc: value.utc + }); + }); + return formattedData; + } +} diff --git a/src/plugins/plot/stackedPlot/StackedPlotItem.vue b/src/plugins/plot/stackedPlot/StackedPlotItem.vue index 1541b8c980..4a9c7e28ad 100644 --- a/src/plugins/plot/stackedPlot/StackedPlotItem.vue +++ b/src/plugins/plot/stackedPlot/StackedPlotItem.vue @@ -243,6 +243,7 @@ export default { domainObject: { ...this.childObject, configuration: { + ...this.childObject.configuration, series: [ { identifier: this.childObject.identifier, diff --git a/src/plugins/plot/tickUtils.js b/src/plugins/plot/tickUtils.js index fff48cb546..80e19b5dce 100644 --- a/src/plugins/plot/tickUtils.js +++ b/src/plugins/plot/tickUtils.js @@ -94,7 +94,7 @@ export function ticks(start, stop, count) { } export function commonPrefix(a, b) { - const maxLen = Math.min(a.length, b.length); + const maxLen = Math.min(a.length, b?.length); let breakpoint = 0; for (let i = 0; i < maxLen; i++) { if (a[i] !== b[i]) { @@ -110,7 +110,7 @@ export function commonPrefix(a, b) { } export function commonSuffix(a, b) { - const maxLen = Math.min(a.length, b.length); + const maxLen = Math.min(a.length, b?.length); let breakpoint = 0; for (let i = 0; i <= maxLen; i++) { if (a[a.length - i] !== b[b.length - i]) {