Skip to content

Commit

Permalink
Merge pull request #34 from longzheng/sunspec-optimize
Browse files Browse the repository at this point in the history
Optimize SunSpec connection to reduce control loop latency
  • Loading branch information
longzheng authored Oct 2, 2024
2 parents 8b31ca3 + 8aaaf10 commit 9a90736
Show file tree
Hide file tree
Showing 11 changed files with 118 additions and 141 deletions.
2 changes: 1 addition & 1 deletion src/coordinator/helpers/inverterController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion src/coordinator/helpers/inverterSample.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
15 changes: 5 additions & 10 deletions src/inverter/inverterDataPollerBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
17 changes: 16 additions & 1 deletion src/inverter/sunspec/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export class SunSpecInverterDataPoller extends InverterDataPollerBase {

override async getInverterData(): Promise<Result<InverterData>> {
try {
const start = performance.now();

const models = {
inverter: await this.inverterConnection.getInverterModel(),
nameplate: await this.inverterConnection.getNameplateModel(),
Expand All @@ -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;

Expand Down Expand Up @@ -234,13 +239,23 @@ 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,
};
case 'limit':
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:
Expand Down
9 changes: 4 additions & 5 deletions src/meters/mqtt/index.ts
Original file line number Diff line number Diff line change
@@ -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<typeof siteSampleDataSchema> | null = null;
private cachedMessage: SiteSample | null = null;

constructor({
mqttConfig,
Expand Down Expand Up @@ -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<Result<SiteSampleData>> {
override async getSiteSample(): Promise<Result<SiteSample>> {
if (!this.cachedMessage) {
return {
success: false,
Expand Down
17 changes: 13 additions & 4 deletions src/meters/powerwall2/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -27,11 +27,19 @@ export class Powerwall2SiteSamplePoller extends SiteSamplePollerBase {
void this.startPolling();
}

override async getSiteSampleData(): Promise<Result<SiteSampleData>> {
override async getSiteSample(): Promise<Result<SiteSample>> {
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,
Expand All @@ -55,14 +63,15 @@ export function generateSiteSample({
meter,
}: {
meter: z.infer<typeof metersSiteSchema>;
}): SiteSampleData {
}): SiteSample {
const firstMeter = meter[0];

if (!firstMeter) {
throw new Error('no meter found');
}

return {
date: new Date(),
realPower: {
type: 'perPhaseNet',
phaseA: firstMeter.Cached_readings.real_power_a,
Expand Down
43 changes: 15 additions & 28 deletions src/meters/siteSamplePollerBase.ts
Original file line number Diff line number Diff line change
@@ -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<{
Expand Down Expand Up @@ -41,7 +41,7 @@ export abstract class SiteSamplePollerBase extends EventEmitter<{
this.onDestroy();
}

abstract getSiteSampleData(): Promise<Result<SiteSampleData>>;
abstract getSiteSample(): Promise<Result<SiteSample>>;

abstract onDestroy(): void;

Expand All @@ -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,
});
}
}
}
29 changes: 18 additions & 11 deletions src/meters/sunspec/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -38,11 +38,19 @@ export class SunSpecMeterSiteSamplePoller extends SiteSamplePollerBase {
void this.startPolling();
}

override async getSiteSampleData(): Promise<Result<SiteSampleData>> {
override async getSiteSample(): Promise<Result<SiteSample>> {
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({
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -132,20 +141,18 @@ function convertConsumptionMeteringToFeedInMetering({
});

return {
...siteSample,
realPower: siteRealPower,
reactivePower: siteSample.reactivePower,
voltage: siteSample.voltage,
frequency: siteSample.frequency,
};
}

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'
Expand Down
Loading

0 comments on commit 9a90736

Please sign in to comment.