From 58d7c46c77bb67c1a57b5ee37faa83f78761d551 Mon Sep 17 00:00:00 2001 From: Long Zheng Date: Tue, 1 Oct 2024 17:05:03 +1000 Subject: [PATCH 1/5] Disable write register read verification This was largely redundant and added significant delay --- .../models/sunSpecModelFactory.test.ts | 45 ------------------- src/sunspec/models/sunSpecModelFactory.ts | 29 +----------- 2 files changed, 1 insertion(+), 73 deletions(-) diff --git a/src/sunspec/models/sunSpecModelFactory.test.ts b/src/sunspec/models/sunSpecModelFactory.test.ts index a3dc1c4..14563cf 100644 --- a/src/sunspec/models/sunSpecModelFactory.test.ts +++ b/src/sunspec/models/sunSpecModelFactory.test.ts @@ -140,49 +140,6 @@ describe('sunSpecModelFactory', () => { .spyOn(inverterSunSpecConnection.client, 'writeRegisters') .mockResolvedValue({ address: 40000, length: 6 }); - // after write - const readHoldingRegistersMock = vi - .spyOn(inverterSunSpecConnection.client, 'readHoldingRegisters') - .mockResolvedValue({ - data: [0x0001, 0x00003, 0xff80, 0x6865, 0x6c6c, 0x6f00], - buffer: Buffer.from([]), // buffer value is not used - }); - - const values = { - Hello: 3, - World: -128, - } satisfies ModelWrite; - - await expect( - model.write({ - values, - modbusConnection: inverterSunSpecConnection, - address: { start: 40000, length: 6 }, - }), - ).resolves.toBeUndefined(); - - expect(writeRegistersMock).toHaveBeenCalledOnce(); - expect(writeRegistersMock).toHaveBeenCalledWith( - 40000, - [0, 3, 0xff80, 0, 0, 0], - ); - expect(readHoldingRegistersMock).toHaveBeenCalledOnce(); - expect(readHoldingRegistersMock).toHaveBeenCalledWith(40000, 6); - }); - - it('sunSpecModelFactory.write returns even if data is not updated', async () => { - const writeRegistersMock = vi - .spyOn(inverterSunSpecConnection.client, 'writeRegisters') - .mockResolvedValue({ address: 40000, length: 6 }); - - // after write - const readHoldingRegistersMock = vi - .spyOn(inverterSunSpecConnection.client, 'readHoldingRegisters') - .mockResolvedValue({ - data: [0x0001, 0x000011, 0xff80, 0x6865, 0x6c6c, 0x6f00], - buffer: Buffer.from([]), // buffer value is not used - }); - const values = { Hello: 3, World: -128, @@ -201,7 +158,5 @@ describe('sunSpecModelFactory', () => { 40000, [0, 3, 0xff80, 0, 0, 0], ); - expect(readHoldingRegistersMock).toHaveBeenCalledOnce(); - expect(readHoldingRegistersMock).toHaveBeenCalledWith(40000, 6); }); }); diff --git a/src/sunspec/models/sunSpecModelFactory.ts b/src/sunspec/models/sunSpecModelFactory.ts index fe9152d..9f0e38b 100644 --- a/src/sunspec/models/sunSpecModelFactory.ts +++ b/src/sunspec/models/sunSpecModelFactory.ts @@ -81,34 +81,7 @@ export function sunSpecModelFactory< registerValues, ); - logger.trace('Wrote registers, validating written registers'); - - const registers = - await modbusConnection.client.readHoldingRegisters( - address.start, - address.length, - ); - - // confirm the registers were written correctly - const writtenValues = convertReadRegisters({ - registers: registers.data, - mapping: config.mapping, - }); - - objectEntriesWithType(values).forEach(([key, value]) => { - if (writtenValues[key] !== value) { - logger.error( - { - key: key.toString(), - value, - read: writtenValues[key], - }, - `Failed to write value for key`, - ); - } - }); - - logger.trace('Validated written registers'); + logger.trace('Wrote registers'); }, }; } From 50f31933917b8ddd98174cd6ea58432287f7f5b7 Mon Sep 17 00:00:00 2001 From: Long Zheng Date: Tue, 1 Oct 2024 17:05:25 +1000 Subject: [PATCH 2/5] Cache status model temporarily and cache control model --- src/sunspec/connection/inverter.ts | 31 +++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/sunspec/connection/inverter.ts b/src/sunspec/connection/inverter.ts index 43bc378..d38e5aa 100644 --- a/src/sunspec/connection/inverter.ts +++ b/src/sunspec/connection/inverter.ts @@ -1,10 +1,12 @@ -import type { ControlsModelWrite } from '../models/controls.js'; +import { differenceInSeconds } from 'date-fns'; +import type { ControlsModel, ControlsModelWrite } from '../models/controls.js'; import { controlsModel } from '../models/controls.js'; import { inverterModel } from '../models/inverter.js'; import type { NameplateModel } from '../models/nameplate.js'; import { nameplateModel } from '../models/nameplate.js'; import type { SettingsModel } from '../models/settings.js'; import { settingsModel } from '../models/settings.js'; +import type { StatusModel } from '../models/status.js'; import { statusModel } from '../models/status.js'; import { SunSpecConnection } from './base.js'; @@ -15,6 +17,14 @@ export class InverterSunSpecConnection extends SunSpecConnection { // the settings model should never change so we can cache it private settingsModelCache: SettingsModel | null = null; + // the status model should not regularly change so we can cache it for a short while + // practically we only need the value when the inverter connection state changes which does not happen often + private statusModelCache: { cache: StatusModel; date: Date } | null = null; + + // the controls model will be under our control so we can cache it + // we will merge the "default values" with the override values + private controlsModelCache: ControlsModel | null = null; + async getInverterModel() { const modelAddressById = await this.getModelAddressById(); @@ -82,6 +92,14 @@ export class InverterSunSpecConnection extends SunSpecConnection { } async getStatusModel() { + if ( + this.statusModelCache && + // cache valid for 5 seconds + differenceInSeconds(new Date(), this.statusModelCache.date) < 5 + ) { + return this.statusModelCache.cache; + } + const modelAddressById = await this.getModelAddressById(); const address = modelAddressById.get(122); @@ -95,10 +113,19 @@ export class InverterSunSpecConnection extends SunSpecConnection { address, }); + this.statusModelCache = { + cache: data, + date: new Date(), + }; + return data; } async getControlsModel() { + if (this.controlsModelCache) { + return this.controlsModelCache; + } + const modelAddressById = await this.getModelAddressById(); const address = modelAddressById.get(123); @@ -112,6 +139,8 @@ export class InverterSunSpecConnection extends SunSpecConnection { address, }); + this.controlsModelCache = data; + return data; } From fc9f6a48683844489d3f146472f1a2fac679b53e Mon Sep 17 00:00:00 2001 From: Long Zheng Date: Tue, 1 Oct 2024 17:08:05 +1000 Subject: [PATCH 3/5] Add revert time for connection and WMaxLimPct --- src/inverter/sunspec/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/inverter/sunspec/index.ts b/src/inverter/sunspec/index.ts index dd35b7a..c0a00ed 100644 --- a/src/inverter/sunspec/index.ts +++ b/src/inverter/sunspec/index.ts @@ -234,6 +234,11 @@ export function generateControlsModelWriteFromInverterConfiguration({ targetSolarPowerRatio: 0, controlsModel, }), + // revert WMaxLimtPct in 60 seconds + // this is a safety measure in case the SunSpec connection is lost + // we want to revert the inverter to the default which is assumed to be safe + // we assume we will write another config witin 60 seconds to reset this timeout + WMaxLimPct_RvrtTms: 60, VArPct_Ena: VArPct_Ena.DISABLED, OutPFSet_Ena: OutPFSet_Ena.DISABLED, }; @@ -241,6 +246,11 @@ export function generateControlsModelWriteFromInverterConfiguration({ return { ...controlsModel, Conn: Conn.CONNECT, + // revert Conn in 60 seconds + // this is a safety measure in case the SunSpec connection is lost + // we want to revert the inverter to the default which is assumed to be safe + // we assume we will write another config witin 60 seconds to reset this timeout + Conn_RvrtTms: 60, WMaxLim_Ena: WMaxLim_Ena.ENABLED, WMaxLimPct: getWMaxLimPctFromTargetSolarPowerRatio({ targetSolarPowerRatio: From 799956470d5a93b490f87752a500b8aae03c7353 Mon Sep 17 00:00:00 2001 From: Long Zheng Date: Tue, 1 Oct 2024 21:42:07 +1000 Subject: [PATCH 4/5] Refactor site sample pollers to return SiteSample --- src/meters/mqtt/index.ts | 9 +++---- src/meters/powerwall2/index.ts | 17 +++++++++--- src/meters/siteSamplePollerBase.ts | 43 +++++++++++------------------- src/meters/sunspec/index.ts | 29 ++++++++++++-------- 4 files changed, 50 insertions(+), 48 deletions(-) diff --git a/src/meters/mqtt/index.ts b/src/meters/mqtt/index.ts index 3cc1ac5..bcc0734 100644 --- a/src/meters/mqtt/index.ts +++ b/src/meters/mqtt/index.ts @@ -1,14 +1,13 @@ import mqtt from 'mqtt'; import type { Config } from '../../helpers/config.js'; -import type { z } from 'zod'; import { SiteSamplePollerBase } from '../siteSamplePollerBase.js'; -import type { SiteSampleData } from '../siteSample.js'; +import type { SiteSample } from '../siteSample.js'; import { siteSampleDataSchema } from '../siteSample.js'; import type { Result } from '../../helpers/result.js'; export class MqttSiteSamplePoller extends SiteSamplePollerBase { private client: mqtt.MqttClient; - private cachedMessage: z.infer | null = null; + private cachedMessage: SiteSample | null = null; constructor({ mqttConfig, @@ -39,14 +38,14 @@ export class MqttSiteSamplePoller extends SiteSamplePollerBase { return; } - this.cachedMessage = result.data; + this.cachedMessage = { date: new Date(), ...result.data }; }); void this.startPolling(); } // eslint-disable-next-line @typescript-eslint/require-await - override async getSiteSampleData(): Promise> { + override async getSiteSample(): Promise> { if (!this.cachedMessage) { return { success: false, diff --git a/src/meters/powerwall2/index.ts b/src/meters/powerwall2/index.ts index 92a60f6..70c8606 100644 --- a/src/meters/powerwall2/index.ts +++ b/src/meters/powerwall2/index.ts @@ -1,5 +1,5 @@ import { SiteSamplePollerBase } from '../siteSamplePollerBase.js'; -import type { SiteSampleData } from '../siteSample.js'; +import type { SiteSample } from '../siteSample.js'; import { Powerwall2Client } from './client.js'; import type { z } from 'zod'; import type { metersSiteSchema } from './api.js'; @@ -27,11 +27,19 @@ export class Powerwall2SiteSamplePoller extends SiteSamplePollerBase { void this.startPolling(); } - override async getSiteSampleData(): Promise> { + override async getSiteSample(): Promise> { try { + const start = performance.now(); + const metersSiteData = await this.client.getMetersSite(); - this.logger.trace({ metersSiteData }, 'received data'); + const end = performance.now(); + const duration = end - start; + + this.logger.trace( + { duration, metersSiteData }, + 'polled Powerwall meter site data', + ); const siteSample = generateSiteSample({ meter: metersSiteData, @@ -55,7 +63,7 @@ export function generateSiteSample({ meter, }: { meter: z.infer; -}): SiteSampleData { +}): SiteSample { const firstMeter = meter[0]; if (!firstMeter) { @@ -63,6 +71,7 @@ export function generateSiteSample({ } return { + date: new Date(), realPower: { type: 'perPhaseNet', phaseA: firstMeter.Cached_readings.real_power_a, diff --git a/src/meters/siteSamplePollerBase.ts b/src/meters/siteSamplePollerBase.ts index 600cdf6..2571cdf 100644 --- a/src/meters/siteSamplePollerBase.ts +++ b/src/meters/siteSamplePollerBase.ts @@ -1,7 +1,7 @@ import type { Logger } from 'pino'; import { logger as pinoLogger } from '../helpers/logger.js'; import EventEmitter from 'node:events'; -import type { SiteSample, SiteSampleData } from './siteSample.js'; +import type { SiteSample } from './siteSample.js'; import type { Result } from '../helpers/result.js'; export abstract class SiteSamplePollerBase extends EventEmitter<{ @@ -41,7 +41,7 @@ export abstract class SiteSamplePollerBase extends EventEmitter<{ this.onDestroy(); } - abstract getSiteSampleData(): Promise>; + abstract getSiteSample(): Promise>; abstract onDestroy(): void; @@ -51,47 +51,34 @@ export abstract class SiteSamplePollerBase extends EventEmitter<{ protected async startPolling() { const start = performance.now(); - const now = new Date(); - this.logger.trace('polling site sample data'); + const siteSample = await this.getSiteSample(); - const siteSampleData = await this.getSiteSampleData(); - - if (siteSampleData.success) { - const siteSample: SiteSample = { - // append current date to the site sample data - date: now, - ...siteSampleData.value, - }; - - this.logger.trace({ siteSample }, 'polled site sample data'); + const end = performance.now(); + const duration = end - start; - this.siteSampleCache = siteSample; + if (siteSample.success) { + this.siteSampleCache = siteSample.value; - this.emit('data', { - siteSample, - }); + this.logger.trace({ duration, siteSample }, 'polled site sample'); } else { - this.logger.error( - siteSampleData.error, - 'Error polling site sample data', - ); + this.logger.error(siteSample.error, 'Error polling site sample'); } - const end = performance.now(); - // this loop must meet sampling requirements and dynamic export requirements // Energex SEP2 Client Handbook specifies "As per the standard, samples should be taken every 200ms (10 cycles). If not capable of sampling this frequently, 1 second samples may be sufficient." // SA Power Networks – Dynamic Exports Utility Interconnection Handbook specifies "Average readings shall be generated by sampling at least every 5 seconds. For example, sample rates of less than 5 seconds are permitted." - const duration = end - start; - - this.logger.trace({ duration }, 'run time'); - // we don't want to run this loop any more frequently than the polling interval to prevent overloading the connection const delay = Math.max(this.pollingIntervalMs - duration, 0); this.pollingTimer = setTimeout(() => { void this.startPolling(); }, delay); + + if (siteSample.success) { + this.emit('data', { + siteSample: siteSample.value, + }); + } } } diff --git a/src/meters/sunspec/index.ts b/src/meters/sunspec/index.ts index 459dbb3..69627e4 100644 --- a/src/meters/sunspec/index.ts +++ b/src/meters/sunspec/index.ts @@ -1,5 +1,5 @@ import type { MeterSunSpecConnection } from '../../sunspec/connection/meter.js'; -import type { SiteSampleData } from '../siteSample.js'; +import type { SiteSample } from '../siteSample.js'; import { SiteSamplePollerBase } from '../siteSamplePollerBase.js'; import { assertNonNull } from '../../helpers/null.js'; import { getMeterMetrics } from '../../sunspec/helpers/meterMetrics.js'; @@ -38,11 +38,19 @@ export class SunSpecMeterSiteSamplePoller extends SiteSamplePollerBase { void this.startPolling(); } - override async getSiteSampleData(): Promise> { + override async getSiteSample(): Promise> { try { + const start = performance.now(); + const meterModel = await this.meterConnection.getMeterModel(); - this.logger.trace({ meterModel }, 'received data'); + const end = performance.now(); + const duration = end - start; + + this.logger.trace( + { duration, meterModel }, + 'polled SunSpec meter data', + ); const siteSample = (() => { const sample = generateSiteSample({ @@ -78,10 +86,11 @@ export class SunSpecMeterSiteSamplePoller extends SiteSamplePollerBase { } } -function generateSiteSample({ meter }: { meter: MeterModel }): SiteSampleData { +function generateSiteSample({ meter }: { meter: MeterModel }): SiteSample { const meterMetrics = getMeterMetrics(meter); return { + date: new Date(), realPower: meterMetrics.WphA ? { type: 'perPhaseNet', @@ -117,9 +126,9 @@ function convertConsumptionMeteringToFeedInMetering({ siteSample, derSample, }: { - siteSample: SiteSampleData; + siteSample: SiteSample; derSample: DerSample | null; -}): SiteSampleData { +}): SiteSample { if (!derSample) { throw new Error( 'Cannot convert consumption metering to feed-in metering without DER data', @@ -132,10 +141,8 @@ function convertConsumptionMeteringToFeedInMetering({ }); return { + ...siteSample, realPower: siteRealPower, - reactivePower: siteSample.reactivePower, - voltage: siteSample.voltage, - frequency: siteSample.frequency, }; } @@ -143,9 +150,9 @@ export function convertConsumptionRealPowerToFeedInRealPower({ consumptionRealPower, derRealPower, }: { - consumptionRealPower: SiteSampleData['realPower']; + consumptionRealPower: SiteSample['realPower']; derRealPower: DerSample['realPower']; -}): SiteSampleData['realPower'] { +}): SiteSample['realPower'] { if ( consumptionRealPower.type === 'perPhaseNet' && derRealPower.type === 'perPhaseNet' From 8aaaf101353fa4951c852da90c57f1cd39dbe064 Mon Sep 17 00:00:00 2001 From: Long Zheng Date: Tue, 1 Oct 2024 21:42:16 +1000 Subject: [PATCH 5/5] Tweak log traces --- src/coordinator/helpers/inverterController.ts | 2 +- src/coordinator/helpers/inverterSample.ts | 5 ++++- src/inverter/inverterDataPollerBase.ts | 15 +++++-------- src/inverter/sunspec/index.ts | 7 ++++++- src/sunspec/models/sunSpecModelFactory.ts | 21 ++++++++++++------- 5 files changed, 29 insertions(+), 21 deletions(-) diff --git a/src/coordinator/helpers/inverterController.ts b/src/coordinator/helpers/inverterController.ts index 9431b1d..ceff2b3 100644 --- a/src/coordinator/helpers/inverterController.ts +++ b/src/coordinator/helpers/inverterController.ts @@ -111,7 +111,7 @@ export class InverterController { const end = performance.now(); const duration = end - start; - this.logger.trace({ duration }, 'run time'); + this.logger.trace({ duration }, 'Updated inverter control values'); // update the inverter at most every 1 second const delay = Math.max(1000 - duration, 0); diff --git a/src/coordinator/helpers/inverterSample.ts b/src/coordinator/helpers/inverterSample.ts index 5dccb03..d4df2b4 100644 --- a/src/coordinator/helpers/inverterSample.ts +++ b/src/coordinator/helpers/inverterSample.ts @@ -76,7 +76,10 @@ export class InvertersPoller extends EventEmitter<{ this.derSampleCache = derSample; - this.logger.trace({ derSample }, 'generated DER sample'); + this.logger.trace( + { derSample, successInvertersCount: successInvertersData.length }, + 'generated DER sample', + ); this.emit('data', derSample); } diff --git a/src/inverter/inverterDataPollerBase.ts b/src/inverter/inverterDataPollerBase.ts index b5f1a7a..9c8f4d5 100644 --- a/src/inverter/inverterDataPollerBase.ts +++ b/src/inverter/inverterDataPollerBase.ts @@ -60,30 +60,25 @@ export abstract class InverterDataPollerBase extends EventEmitter<{ protected async startPolling() { const start = performance.now(); - this.logger.trace('polling inverter data'); - const inverterData = await this.getInverterData(); - this.logger.trace({ inverterData }, 'polled inverter data'); - this.inverterDataCache = inverterData; - this.emit('data', inverterData); - const end = performance.now(); + const duration = end - start; + + this.logger.trace({ duration, inverterData }, 'polled inverter data'); // this loop must meet sampling requirements and dynamic export requirements // Energex SEP2 Client Handbook specifies "As per the standard, samples should be taken every 200ms (10 cycles). If not capable of sampling this frequently, 1 second samples may be sufficient." // SA Power Networks – Dynamic Exports Utility Interconnection Handbook specifies "Average readings shall be generated by sampling at least every 5 seconds. For example, sample rates of less than 5 seconds are permitted." - const duration = end - start; - - this.logger.trace({ duration }, 'run time'); - // we don't want to run this loop any more frequently than the polling interval to prevent overloading the connection const delay = Math.max(this.pollingIntervalMs - duration, 0); this.pollingTimer = setTimeout(() => { void this.startPolling(); }, delay); + + this.emit('data', inverterData); } } diff --git a/src/inverter/sunspec/index.ts b/src/inverter/sunspec/index.ts index c0a00ed..502b4be 100644 --- a/src/inverter/sunspec/index.ts +++ b/src/inverter/sunspec/index.ts @@ -61,6 +61,8 @@ export class SunSpecInverterDataPoller extends InverterDataPollerBase { override async getInverterData(): Promise> { try { + const start = performance.now(); + const models = { inverter: await this.inverterConnection.getInverterModel(), nameplate: await this.inverterConnection.getNameplateModel(), @@ -69,7 +71,10 @@ export class SunSpecInverterDataPoller extends InverterDataPollerBase { controls: await this.inverterConnection.getControlsModel(), }; - this.logger.trace({ models }, 'received model data'); + const end = performance.now(); + const duration = end - start; + + this.logger.trace({ duration, models }, 'Got inverter data'); this.cachedControlsModel = models.controls; diff --git a/src/sunspec/models/sunSpecModelFactory.ts b/src/sunspec/models/sunSpecModelFactory.ts index 9f0e38b..fa3a33b 100644 --- a/src/sunspec/models/sunSpecModelFactory.ts +++ b/src/sunspec/models/sunSpecModelFactory.ts @@ -40,19 +40,23 @@ export function sunSpecModelFactory< module: `sunspec-model-${config.name}`, }); - logger.trace({ address }, 'Reading model'); + const start = performance.now(); await modbusConnection.connect(); - logger.trace({ address }, 'Reading registers'); - const registers = await modbusConnection.client.readHoldingRegisters( address.start, address.length, ); - logger.trace({ registers: registers.data }, 'Read registers'); + const end = performance.now(); + const duration = end - start; + + logger.trace( + { duration, registers: registers.data }, + 'Read registers', + ); return convertReadRegisters({ registers: registers.data, @@ -64,7 +68,7 @@ export function sunSpecModelFactory< module: `sunspec-model-${config.name}`, }); - logger.trace({ address, values }, 'Writing model'); + const start = performance.now(); await modbusConnection.connect(); @@ -74,14 +78,15 @@ export function sunSpecModelFactory< length: address.length, }); - logger.trace({ registerValues }, 'Converted write registers'); - await modbusConnection.client.writeRegisters( address.start, registerValues, ); - logger.trace('Wrote registers'); + const end = performance.now(); + const duration = end - start; + + logger.trace({ duration, registerValues }, 'Wrote registers'); }, }; }