Skip to content

Commit f9ccf44

Browse files
authored
Merge pull request #88 from longzheng/battery-charge-buffer
Implement battery charge buffer
2 parents 4eac069 + 4eecd65 commit f9ccf44

File tree

8 files changed

+199
-4
lines changed

8 files changed

+199
-4
lines changed

config.schema.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,20 @@
506506
},
507507
"additionalProperties": false,
508508
"description": "Publish active control limits"
509+
},
510+
"battery": {
511+
"type": "object",
512+
"properties": {
513+
"chargeBufferWatts": {
514+
"type": "number",
515+
"description": "A minimum buffer to allow the battery to charge if export limit would otherwise have prevented the battery from charging"
516+
}
517+
},
518+
"required": [
519+
"chargeBufferWatts"
520+
],
521+
"additionalProperties": false,
522+
"description": "Battery configuration"
509523
}
510524
},
511525
"required": [

docs/.vitepress/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export default withMermaid(
3838
{ text: 'Site meter', link: '/configuration/meter' },
3939
{ text: 'Setpoints', link: '/configuration/setpoints' },
4040
{ text: 'Publish', link: '/configuration/publish' },
41+
{ text: 'Battery', link: '/configuration/battery' },
4142
],
4243
},
4344
{

docs/configuration/battery.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Battery
2+
3+
An **optional** battery can be configured to adjust the controller behaviour.
4+
5+
[[toc]]
6+
7+
## Charge buffer
8+
9+
In export limited scenarios, a "solar soaking" battery may not be able to charge correctly if the export limit is very low or zero. To allow the battery to charge, a minimum charge buffer can be configured which will override the export limit if it is below the configured watts.
10+
11+
To configure a charge buffer, add the following property to `config.json`
12+
13+
```js
14+
{
15+
"battery": {
16+
"chargeBufferWatts": 100 // (number) required: the minimum charge buffer in watts
17+
}
18+
...
19+
}
20+
```
21+
22+
> [!IMPORTANT]
23+
> Users on dynamic export connections MUST NOT set a high charge buffer which may violate your connection agreement for dynamic export limits.
24+
25+
**Why doesn't the controller know if the battery is charged?**
26+
27+
Since the controller does not have direct control of batteries (especially batteries without an API e.g. Tesla Powerwall), it is not possible to know if the battery is configured for charging. Even if the battery SOC is known, it is possible the battery may be configured with a lower SOC cap or VPP mode which overrides the charging behaviour.

src/coordinator/helpers/inverterController.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { describe, expect, it } from 'vitest';
2+
import { type ActiveInverterControlLimit } from './inverterController.js';
23
import {
4+
adjustActiveInverterControlForBatteryCharging,
35
calculateTargetSolarPowerRatio,
46
calculateTargetSolarWatts,
57
getActiveInverterControlLimit,
@@ -236,3 +238,88 @@ describe('getActiveInverterControlLimit', () => {
236238
} satisfies typeof inverterControlLimit);
237239
});
238240
});
241+
242+
describe('adjustActiveInverterControlForBatteryCharging', () => {
243+
it('should return the original limit if opModExpLimW is undefined', () => {
244+
const activeInverterControlLimit: ActiveInverterControlLimit = {
245+
opModEnergize: undefined,
246+
opModConnect: undefined,
247+
opModGenLimW: undefined,
248+
opModExpLimW: undefined,
249+
opModImpLimW: undefined,
250+
opModLoadLimW: undefined,
251+
};
252+
const result = adjustActiveInverterControlForBatteryCharging({
253+
activeInverterControlLimit,
254+
batteryChargeBufferWatts: 100,
255+
});
256+
expect(result.opModExpLimW?.value).toEqual(undefined);
257+
});
258+
259+
it('should return the original limit if opModExpLimW is greater than the buffer', () => {
260+
const activeInverterControlLimit: ActiveInverterControlLimit = {
261+
opModEnergize: undefined,
262+
opModConnect: undefined,
263+
opModGenLimW: undefined,
264+
opModExpLimW: { source: 'fixed', value: 200 },
265+
opModImpLimW: undefined,
266+
opModLoadLimW: undefined,
267+
};
268+
const result = adjustActiveInverterControlForBatteryCharging({
269+
activeInverterControlLimit,
270+
batteryChargeBufferWatts: 100,
271+
});
272+
expect(result.opModExpLimW?.value).toEqual(200);
273+
});
274+
275+
it('should return the original limit if opModExpLimW is equal to the buffer', () => {
276+
const activeInverterControlLimit: ActiveInverterControlLimit = {
277+
opModEnergize: undefined,
278+
opModConnect: undefined,
279+
opModGenLimW: undefined,
280+
opModExpLimW: { source: 'fixed', value: 100 },
281+
opModImpLimW: undefined,
282+
opModLoadLimW: undefined,
283+
};
284+
const result = adjustActiveInverterControlForBatteryCharging({
285+
activeInverterControlLimit,
286+
batteryChargeBufferWatts: 100,
287+
});
288+
expect(result.opModExpLimW?.value).toEqual(100);
289+
});
290+
291+
it('should adjust the limit if opModExpLimW is less than the buffer', () => {
292+
const activeInverterControlLimit: ActiveInverterControlLimit = {
293+
opModEnergize: undefined,
294+
opModConnect: undefined,
295+
opModGenLimW: undefined,
296+
opModExpLimW: { source: 'batteryChargeBuffer', value: 0 },
297+
opModImpLimW: undefined,
298+
opModLoadLimW: undefined,
299+
};
300+
const result = adjustActiveInverterControlForBatteryCharging({
301+
activeInverterControlLimit,
302+
batteryChargeBufferWatts: 100,
303+
});
304+
expect(result.opModExpLimW?.value).toBe(100);
305+
});
306+
307+
it('should not affect the other limits', () => {
308+
const activeInverterControlLimit: ActiveInverterControlLimit = {
309+
opModEnergize: { source: 'fixed', value: true },
310+
opModConnect: { source: 'fixed', value: true },
311+
opModGenLimW: { source: 'fixed', value: 1000 },
312+
opModExpLimW: { source: 'fixed', value: 0 },
313+
opModImpLimW: { source: 'fixed', value: 1000 },
314+
opModLoadLimW: { source: 'fixed', value: 1000 },
315+
};
316+
const result = adjustActiveInverterControlForBatteryCharging({
317+
activeInverterControlLimit,
318+
batteryChargeBufferWatts: 100,
319+
});
320+
expect(result).toEqual({
321+
...activeInverterControlLimit,
322+
opModExpLimW: { source: 'batteryChargeBuffer', value: 100 },
323+
});
324+
});
325+
});

src/coordinator/helpers/inverterController.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ export type InverterControlTypes =
3939
| 'mqtt'
4040
| 'csipAus'
4141
| 'twoWayTariff'
42-
| 'negativeFeedIn';
42+
| 'negativeFeedIn'
43+
| 'batteryChargeBuffer';
4344

4445
export type InverterControlLimit = {
4546
source: InverterControlTypes;
@@ -98,6 +99,7 @@ export class InverterController {
9899
private controlLimitsLoopTimer: NodeJS.Timeout | null = null;
99100
private applyControlLoopTimer: NodeJS.Timeout | null = null;
100101
private abortController: AbortController;
102+
private batteryChargeBufferWatts: number | null = null;
101103

102104
constructor({
103105
config,
@@ -113,6 +115,8 @@ export class InverterController {
113115
this.publish = new Publish({ config });
114116
this.secondsToSample = config.inverterControl.sampleSeconds;
115117
this.intervalSeconds = config.inverterControl.intervalSeconds;
118+
this.batteryChargeBufferWatts =
119+
config.battery?.chargeBufferWatts ?? null;
116120
this.setpoints = setpoints;
117121
this.logger = pinoLogger.child({ module: 'InverterController' });
118122
this.abortController = new AbortController();
@@ -305,10 +309,36 @@ export class InverterController {
305309
...recentDerSamples.map((sample) => sample.invertersCount),
306310
);
307311

312+
const batteryAdjustedInverterControlLimit = (() => {
313+
const batteryChargeBufferWatts = this.batteryChargeBufferWatts;
314+
315+
if (batteryChargeBufferWatts === null) {
316+
return this.controlLimitsCache.activeInverterControlLimit;
317+
}
318+
319+
const adjustedInverterControlLimit =
320+
adjustActiveInverterControlForBatteryCharging({
321+
activeInverterControlLimit:
322+
this.controlLimitsCache.activeInverterControlLimit,
323+
batteryChargeBufferWatts,
324+
});
325+
326+
this.logger.info(
327+
{
328+
activeInverterControlLimit:
329+
this.controlLimitsCache.activeInverterControlLimit,
330+
batteryChargeBufferWatts,
331+
adjustedInverterControlLimit,
332+
},
333+
'Adjusted active inverter control limit for battery charging',
334+
);
335+
336+
return adjustedInverterControlLimit;
337+
})();
338+
308339
const rampedInverterConfiguration = ((): InverterConfiguration => {
309340
const configuration = calculateInverterConfiguration({
310-
activeInverterControlLimit:
311-
this.controlLimitsCache.activeInverterControlLimit,
341+
activeInverterControlLimit: batteryAdjustedInverterControlLimit,
312342
nameplateMaxW: averagedNameplateMaxW,
313343
siteWatts: averagedSiteWatts,
314344
solarWatts: averagedSolarWatts,
@@ -688,3 +718,27 @@ export function getActiveInverterControlLimit(
688718
opModLoadLimW,
689719
};
690720
}
721+
722+
export function adjustActiveInverterControlForBatteryCharging({
723+
activeInverterControlLimit,
724+
batteryChargeBufferWatts,
725+
}: {
726+
activeInverterControlLimit: ActiveInverterControlLimit;
727+
batteryChargeBufferWatts: number;
728+
}): ActiveInverterControlLimit {
729+
if (
730+
activeInverterControlLimit.opModExpLimW !== undefined &&
731+
activeInverterControlLimit.opModExpLimW.value < batteryChargeBufferWatts
732+
) {
733+
// adjust the export limit value to the battery charge buffer watts
734+
return {
735+
...activeInverterControlLimit,
736+
opModExpLimW: {
737+
source: 'batteryChargeBuffer',
738+
value: batteryChargeBufferWatts,
739+
},
740+
};
741+
}
742+
743+
return activeInverterControlLimit;
744+
}

src/helpers/config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,16 @@ A longer time will smooth out load changes but may result in overshoot.`,
313313
})
314314
.describe('Publish active control limits')
315315
.optional(),
316+
battery: z
317+
.object({
318+
chargeBufferWatts: z
319+
.number()
320+
.describe(
321+
'A minimum buffer to allow the battery to charge if export limit would otherwise have prevented the battery from charging',
322+
),
323+
})
324+
.describe('Battery configuration')
325+
.optional(),
316326
});
317327

318328
export type Config = z.infer<typeof configSchema>;

src/ui/gen/api.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2005,7 +2005,7 @@ export interface components {
20052005
date: string;
20062006
};
20072007
/** @enum {string} */
2008-
InverterControlTypes: "fixed" | "mqtt" | "csipAus" | "twoWayTariff" | "negativeFeedIn";
2008+
InverterControlTypes: "fixed" | "mqtt" | "csipAus" | "twoWayTariff" | "negativeFeedIn" | "batteryChargeBuffer";
20092009
InverterControlLimit: {
20102010
/** Format: double */
20112011
opModLoadLimW?: number;

src/ui/routes/index.lazy.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,8 @@ function LimitCard({
726726
return 'Utility dynamic connection';
727727
case 'twoWayTariff':
728728
return 'Two-way tariff';
729+
case 'batteryChargeBuffer':
730+
return 'Battery charge buffer';
729731
case undefined:
730732
return '';
731733
}

0 commit comments

Comments
 (0)