Skip to content

Commit 6242aa3

Browse files
authored
Merge pull request #116 from basmeerman/work/plan-13-capacity-tariff
feat: capacity tariff peak tracking module (#101)
2 parents e736960 + 2e89571 commit 6242aa3

24 files changed

+2805
-1268
lines changed

CLAUDE.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,8 @@ cppcheck --enable=warning,style,performance \
199199
SmartEVSE-3/src/ocpp_telemetry.c \
200200
SmartEVSE-3/src/solar_debug_json.c \
201201
SmartEVSE-3/src/diag_telemetry.c \
202-
SmartEVSE-3/src/diag_modbus.c
202+
SmartEVSE-3/src/diag_modbus.c \
203+
SmartEVSE-3/src/capacity_peak.c
203204
204205
# Build ESP32 firmware
205206
pio run -e release -d SmartEVSE-3/
@@ -251,7 +252,8 @@ cppcheck --enable=warning,style,performance \
251252
SmartEVSE-3/src/ocpp_telemetry.c \
252253
SmartEVSE-3/src/solar_debug_json.c \
253254
SmartEVSE-3/src/diag_telemetry.c \
254-
SmartEVSE-3/src/diag_modbus.c
255+
SmartEVSE-3/src/diag_modbus.c \
256+
SmartEVSE-3/src/capacity_peak.c
255257

256258
# 4. ESP32 firmware build
257259
pio run -e release -d SmartEVSE-3/

SmartEVSE-3/data/app.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,23 @@ function updatePowerFlow(mainsTotal, evPower, batCurrent) {
353353
}
354354
}
355355

356+
/* ========== Capacity Tariff ========== */
357+
function updateCapacityTariff(limit, windowAvg, monthlyPeak, headroom) {
358+
var el = $id('capacity_limit_input');
359+
if (el && !el.matches(':focus')) el.value = limit > 0 ? (limit / 1000).toFixed(1) : '0';
360+
var fmtW = function(w) { return w !== undefined && w !== null ? (w / 1000).toFixed(1) + ' kW' : '-'; };
361+
$id('capacity_window_avg').textContent = fmtW(windowAvg);
362+
$id('capacity_monthly_peak').textContent = fmtW(monthlyPeak);
363+
$id('capacity_headroom').textContent = limit > 0 ? fmtW(headroom) : 'N/A';
364+
if (limit > 0) showById('capacity_tariff_card'); else showById('capacity_tariff_card');
365+
}
366+
function setCapacityLimit() {
367+
var val = parseFloat($id('capacity_limit_input').value);
368+
if (isNaN(val) || val < 0 || val > 25) { alert('Value must be 0-25 kW'); return; }
369+
var watts = Math.round(val * 1000);
370+
fetch('/settings?capacity_limit=' + watts, { method: 'POST' });
371+
}
372+
356373
/* ========== Cert visibility ========== */
357374
function toggleCertVisibility() {
358375
$id('mqtt_ca_cert_wrapper').style.display =
@@ -518,6 +535,14 @@ function loadData() {
518535
$id('override_current').textContent = (data.settings.override_current / 10).toFixed(1) + " A";
519536
$id('enable_C2').textContent = data.settings.enable_C2;
520537

538+
/* Capacity tariff */
539+
if (data.settings.capacity_limit !== undefined) {
540+
updateCapacityTariff(data.settings.capacity_limit,
541+
data.settings.capacity_window_avg,
542+
data.settings.capacity_monthly_peak,
543+
data.settings.capacity_headroom);
544+
}
545+
521546
if (data.settings.starttime) {
522547
$id('starttime_date_time').textContent = new Date(data.settings.starttime * 1000).toLocaleDateString() + " " + new Date(data.settings.starttime * 1000).toLocaleTimeString();
523548
} else {

SmartEVSE-3/data/index.html

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,41 @@ <h1>Smart EVSE</h1>
158158
</div>
159159
</div>
160160

161+
<!-- Capacity Tariff (visible when mains meter configured) -->
162+
<div class="with_mainsmeter" id="capacity_tariff_card" style="display:none">
163+
<div class="card">
164+
<div class="section-toggle collapsed" onclick="toggleSection(this)">
165+
<span class="card-title" style="margin-bottom:0">Capacity Tariff</span>
166+
</div>
167+
<div class="section-body collapsed">
168+
<div class="form-row">
169+
<span class="form-label">
170+
<span class="tooltip">Peak Limit
171+
<span class="tooltiptext">15-minute average power limit (kW). Set to 0 to disable. Belgian capaciteitstarief / EU capacity billing.</span>
172+
</span>
173+
</span>
174+
<div style="display:flex;gap:6px;align-items:center">
175+
<input type="number" id="capacity_limit_input" min="0" max="25" step="0.1" style="width:80px;padding:4px 6px;border:1px solid var(--bg3);border-radius:6px;background:var(--bg);color:var(--fg);font-size:.85rem;text-align:center" title="0 = disabled">
176+
<span style="font-size:.85rem;color:var(--fg2)">kW</span>
177+
<button type="button" class="btn btn-sm" onclick="setCapacityLimit()">Set</button>
178+
</div>
179+
</div>
180+
<div class="detail-item">
181+
<span class="detail-label">Window Avg</span>
182+
<span class="detail-value" id="capacity_window_avg">-</span>
183+
</div>
184+
<div class="detail-item">
185+
<span class="detail-label">Monthly Peak</span>
186+
<span class="detail-value" id="capacity_monthly_peak">-</span>
187+
</div>
188+
<div class="detail-item">
189+
<span class="detail-label">Headroom</span>
190+
<span class="detail-value" id="capacity_headroom">-</span>
191+
</div>
192+
</div>
193+
</div>
194+
</div>
195+
161196
<!-- Original Phase Details (with home battery) -->
162197
<div id="with_homebattery" style="display:none">
163198
<div class="detail-grid">

SmartEVSE-3/src/capacity_peak.c

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
* capacity_peak.c — 15-minute quarter-peak averaging for EU capacity tariffs
3+
*
4+
* Pure C implementation — no platform dependencies.
5+
* Tracks rolling 15-minute average power and monthly peaks for the Belgian
6+
* capaciteitstarief model.
7+
*/
8+
9+
#include "capacity_peak.h"
10+
#include <string.h>
11+
#include <stdio.h>
12+
#include <limits.h>
13+
14+
void capacity_init(capacity_state_t *state) {
15+
if (!state) return;
16+
memset(state, 0, sizeof(*state));
17+
}
18+
19+
void capacity_set_limit(capacity_state_t *state, int32_t limit_w) {
20+
if (!state) return;
21+
state->limit_w = limit_w;
22+
}
23+
24+
void capacity_tick_1s(capacity_state_t *state, int32_t total_power_w,
25+
uint8_t month, uint8_t year_offset) {
26+
if (!state) return;
27+
28+
/* Detect month rollover — reset monthly peak */
29+
if (month != state->monthly.peak_month ||
30+
year_offset != state->monthly.peak_year_offset) {
31+
state->monthly.monthly_peak_w = 0;
32+
state->monthly.peak_month = month;
33+
state->monthly.peak_year_offset = year_offset;
34+
}
35+
36+
/* Accumulate watt-seconds */
37+
state->window.accumulated_ws += total_power_w;
38+
state->window.window_elapsed_s++;
39+
40+
/* Window complete? */
41+
if (state->window.window_elapsed_s >= CAPACITY_WINDOW_SECONDS) {
42+
state->window.window_avg_w =
43+
(int32_t)(state->window.accumulated_ws / CAPACITY_WINDOW_SECONDS);
44+
state->window.window_valid = 1;
45+
46+
/* Update monthly peak if this window is a new high */
47+
if (state->window.window_avg_w > state->monthly.monthly_peak_w) {
48+
state->monthly.monthly_peak_w = state->window.window_avg_w;
49+
}
50+
51+
/* Reset window for next 15-minute period */
52+
state->window.accumulated_ws = 0;
53+
state->window.window_elapsed_s = 0;
54+
}
55+
}
56+
57+
int32_t capacity_get_headroom_w(const capacity_state_t *state) {
58+
if (!state) return 0;
59+
if (state->limit_w == 0) return INT32_MAX;
60+
61+
/*
62+
* Calculate how much additional power can be consumed in the remainder
63+
* of this window without the window average exceeding the limit.
64+
*
65+
* The window average at completion will be:
66+
* avg = (accumulated_ws + future_ws) / CAPACITY_WINDOW_SECONDS
67+
*
68+
* For avg <= limit_w:
69+
* accumulated_ws + future_ws <= limit_w * CAPACITY_WINDOW_SECONDS
70+
*
71+
* remaining_seconds = CAPACITY_WINDOW_SECONDS - window_elapsed_s
72+
* If we add P watts for all remaining seconds:
73+
* future_ws = P * remaining_seconds
74+
*
75+
* So the max P that keeps avg <= limit_w:
76+
* P = (limit_w * CAPACITY_WINDOW_SECONDS - accumulated_ws) / remaining_seconds
77+
*
78+
* This gives the instantaneous headroom: how much power can be drawn
79+
* for the rest of this window without exceeding the limit.
80+
*/
81+
uint16_t elapsed = state->window.window_elapsed_s;
82+
if (elapsed == 0) {
83+
/* Window just started — full headroom available */
84+
return state->limit_w;
85+
}
86+
87+
int64_t budget_ws = (int64_t)state->limit_w * CAPACITY_WINDOW_SECONDS;
88+
int64_t remaining_ws = budget_ws - state->window.accumulated_ws;
89+
uint16_t remaining_s = CAPACITY_WINDOW_SECONDS - elapsed;
90+
91+
if (remaining_s == 0) {
92+
/*
93+
* Window is about to complete (elapsed == 900 shouldn't happen since
94+
* tick resets it, but guard against it). Return based on current avg.
95+
*/
96+
int32_t current_avg =
97+
(int32_t)(state->window.accumulated_ws / CAPACITY_WINDOW_SECONDS);
98+
return state->limit_w - current_avg;
99+
}
100+
101+
int32_t headroom = (int32_t)(remaining_ws / remaining_s);
102+
return headroom;
103+
}
104+
105+
int32_t capacity_get_window_avg_w(const capacity_state_t *state) {
106+
if (!state) return 0;
107+
return state->window.window_avg_w;
108+
}
109+
110+
int32_t capacity_get_monthly_peak_w(const capacity_state_t *state) {
111+
if (!state) return 0;
112+
return state->monthly.monthly_peak_w;
113+
}
114+
115+
int16_t capacity_headroom_to_da(int32_t headroom_w, uint8_t phases) {
116+
if (phases == 0) return 0;
117+
/* Convert watts to deciamps: P / (V * phases) * 10 = P * 10 / (230 * phases) */
118+
return (int16_t)(headroom_w * 10 / (230 * phases));
119+
}
120+
121+
int capacity_to_json(const capacity_state_t *state, char *buf, size_t bufsz) {
122+
if (!state || !buf || bufsz == 0) {
123+
return -1;
124+
}
125+
126+
/* Calculate running average for display */
127+
int32_t running_avg = 0;
128+
if (state->window.window_elapsed_s > 0) {
129+
running_avg = (int32_t)(state->window.accumulated_ws /
130+
state->window.window_elapsed_s);
131+
}
132+
133+
int32_t headroom = capacity_get_headroom_w(state);
134+
135+
int n = snprintf(buf, bufsz,
136+
"{\"limit_w\":%d,"
137+
"\"window_avg_w\":%d,"
138+
"\"running_avg_w\":%d,"
139+
"\"window_elapsed_s\":%u,"
140+
"\"window_valid\":%u,"
141+
"\"monthly_peak_w\":%d,"
142+
"\"headroom_w\":%d}",
143+
(int)state->limit_w,
144+
(int)state->window.window_avg_w,
145+
(int)running_avg,
146+
(unsigned)state->window.window_elapsed_s,
147+
(unsigned)state->window.window_valid,
148+
(int)state->monthly.monthly_peak_w,
149+
(int)headroom);
150+
151+
if (n < 0 || (size_t)n >= bufsz) {
152+
return -1;
153+
}
154+
155+
return n;
156+
}

SmartEVSE-3/src/capacity_peak.h

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* capacity_peak.h — 15-minute quarter-peak averaging for EU capacity tariffs
3+
*
4+
* Pure C module — no platform dependencies. Tracks rolling 15-minute average
5+
* power consumption, records monthly peaks, and calculates headroom for the
6+
* Belgian capaciteitstarief (and similar EU capacity tariff structures).
7+
*
8+
* Architecture: Called once per second with total mains power. Maintains a
9+
* 15-minute sliding window of accumulated watt-seconds. At window completion,
10+
* computes average power and updates monthly peak if new high.
11+
*/
12+
13+
#ifndef CAPACITY_PEAK_H
14+
#define CAPACITY_PEAK_H
15+
16+
#include <stdint.h>
17+
#include <stddef.h>
18+
19+
#define CAPACITY_WINDOW_SECONDS 900 /* 15 minutes */
20+
21+
#ifdef __cplusplus
22+
extern "C" {
23+
#endif
24+
25+
/* 15-minute window state */
26+
typedef struct {
27+
int64_t accumulated_ws; /* Watt-seconds accumulated in current window */
28+
uint16_t window_elapsed_s; /* Seconds elapsed in current 15-min window */
29+
int32_t window_avg_w; /* Average power (W) for last completed window */
30+
uint8_t window_valid; /* 1 if at least one window completed */
31+
} capacity_window_t;
32+
33+
/* Monthly peak tracking */
34+
typedef struct {
35+
int32_t monthly_peak_w; /* Highest 15-min avg this month (W) */
36+
uint8_t peak_month; /* Month number (1-12) of current tracking */
37+
uint8_t peak_year_offset; /* Year - 2024 */
38+
} capacity_monthly_t;
39+
40+
/* Full capacity tariff state */
41+
typedef struct {
42+
capacity_window_t window;
43+
capacity_monthly_t monthly;
44+
int32_t limit_w; /* User-configured capacity limit (W), 0=disabled */
45+
} capacity_state_t;
46+
47+
/* Initialize capacity state — zeroes all fields. */
48+
void capacity_init(capacity_state_t *state);
49+
50+
/* Set the capacity limit in watts. 0 = disabled (no constraint). */
51+
void capacity_set_limit(capacity_state_t *state, int32_t limit_w);
52+
53+
/*
54+
* Called every second with total mains power (sum of all phases, watts).
55+
* Accumulates watt-seconds, completes windows every 900s, and tracks
56+
* monthly peaks. Month (1-12) and year_offset (year - 2024) are used
57+
* to detect month rollovers and reset the monthly peak.
58+
*/
59+
void capacity_tick_1s(capacity_state_t *state, int32_t total_power_w,
60+
uint8_t month, uint8_t year_offset);
61+
62+
/*
63+
* Get current headroom — how many more watts can be consumed in the
64+
* remainder of this 15-minute window without exceeding the limit.
65+
* Returns INT32_MAX if limit is disabled (limit_w == 0).
66+
*/
67+
int32_t capacity_get_headroom_w(const capacity_state_t *state);
68+
69+
/* Get last completed window average power in watts. */
70+
int32_t capacity_get_window_avg_w(const capacity_state_t *state);
71+
72+
/* Get this month's peak 15-minute average power in watts. */
73+
int32_t capacity_get_monthly_peak_w(const capacity_state_t *state);
74+
75+
/*
76+
* Convert headroom in watts to current headroom in deciamps for N phases.
77+
* Returns 0 if phases == 0. Uses 230V nominal voltage.
78+
*/
79+
int16_t capacity_headroom_to_da(int32_t headroom_w, uint8_t phases);
80+
81+
/*
82+
* Format capacity state as JSON into buf.
83+
* Returns the number of bytes written (excluding NUL), or -1 on error
84+
* (NULL state, NULL buf, or bufsz == 0).
85+
*/
86+
int capacity_to_json(const capacity_state_t *state, char *buf, size_t bufsz);
87+
88+
#ifdef __cplusplus
89+
}
90+
#endif
91+
92+
#endif /* CAPACITY_PEAK_H */

0 commit comments

Comments
 (0)