Summary
The MPC planner optimizes energy cost only (öre/kWh × kWh). It has no
concept of a demand charge — the Swedish effektavgift, billed on the
monthly peak timmedeleffekt (the highest clock-hour average power, priced
in kr/kW). Because that term is absent from the objective, the DP never
time-shifts a controllable sub-hour load to sit astride a clock-hour
boundary — the well-known "start at XX:30" trick that halves a ≤1-hour
load's contribution to the monthly hourly peak.
This is a feature request to make the planner demand-charge aware. The XX:30
straddle should then fall out for free — it's just what a peak-minimizing
DP does at 15-min resolution; we should not hard-code a timing heuristic.
The core trade-off the planner must make
Demand-charge awareness is not "always straddle the hour." The peak is a
single monthly scalar that couples every hour of the billing period, so each
local decision to run a controllable load (charge the battery, charge an EV,
run a flexload) carries a month-long cost consequence. The DP has to weigh:
- Benefit — the energy-cost saving of running the load now: cheap spot
across the two 15-min windows, storing cheap kWh, or meeting an EV deadline;
against
- Cost — the marginal demand charge if that load pushes a clock-hour
average above the current monthly peak (kr/kW — paid once, but sized by
the single worst hour, so it "sticks" for the entire month).
So the right move is situational: split/straddle to halve the hour average,
spread thinner across more slots to lower the kW, or skip it this month
when the peak penalty dominates the energy win. The XX:30 straddle is one tool
the DP reaches for when it happens to win that trade-off — not the goal itself.
Current behavior (verified in code)
- Objective is pure energy.
cost = effPrice × gridKWh (import) /
−exportPrice × |gridKWh| (export) in go/internal/mpc/mpc.go. No kr/kW
term anywhere in the Bellman recursion.
- No demand-tariff config exists. Nothing for effekt/demand/capacity/
monthly-peak in go/internal/config/config.go.
- Savings ignore power.
go/internal/savings/savings.go tracks only kWh
and öre; there is no kW-peak line item to optimize against or report.
- Slots are clock-aligned, never deliberately offset. The planner plans on
the price grid (15-min NordPool PTU since late 2025, or legacy 60-min) and
the synthetic-slot builder rounds down to the slot grid
(start % (slotLen·60·1000)) in go/internal/mpc/service.go. No half-hour
offset is ever introduced.
The nearest existing power-aware features are different mechanisms, not
this:
PowerLimits.MaxImportW (go/internal/mpc/power_limits.go) is a hard
per-slot import cap, but in practice it's only populated from the physical
fuse via clampSlotGridLimits(FuseMaxW, …) — there is no config surface for
a time-windowed capacity tariff. A hard cap also forces flattening; it does
not reward straddling the hour boundary.
peak_shaving mode / PeakImportCeilingW hold instantaneous grid import
under a ceiling by discharging the battery — a kW cap enforced with
stored energy, not a time-of-use shift of a controllable load.
Why it matters + the concrete XX:30 arithmetic
Many Swedish DSOs bill an effektavgift on the month's peak
timmedeleffekt — the highest clock-hour average grid import over the
month, priced in kr/kW. For a fixed-duration load shorter than or equal to an
hour, you can halve its contribution to that peak purely by when you start
it:
Take a 1-hour, 10 kW EV charge (10 kWh, same energy either way):
- Start 14:00 → fully inside the
14:00–15:00 window → that window
averages 10 kW → contributes 10 kW to the monthly peak.
- Start 14:30 → 5 kWh in
14:00–15:00 (5 kW avg) + 5 kWh in
15:00–16:00 (5 kW avg) → each window sees only 5 kW → contributes
5 kW to the monthly peak.
Same energy, same energy cost, half the demand charge on that load. The
planner already schedules controllable loads (battery charge, EV loadpoints)
at 15-min resolution, so it could place them this way — it just has no reason
to, because the peak isn't in the objective.
Sketch of what an implementation would need
- Config — a demand-charge rate (e.g.
grid.demand_charge_ore_per_kw),
the billing-window length (clock-hour today), and the peak measure. DSOs
differ: some bill the single monthly max, others the mean of the 3 highest
daily peaks on separate days — make it configurable.
- Objective — carry the "running monthly peak so far" into the plan as a
parameter, and add a marginal cost whenever a plan pushes a clock-hour's
average import above the current peak. This means aggregating the
15-min slots into clock-hour windows inside the cost accumulation.
- Emergent XX:30 — because the DP already runs at 15-min resolution, once
the peak term exists it will discover the clock-hour straddle for discrete
loads on its own. No special-case timing code.
- Surface it — add a kW-peak line to
savings.go and the UI so the
avoided demand charge is visible.
Complications worth calling out up front:
- State blow-up. A true running-peak DP state adds a dimension. Likely fine
to approximate: re-feed "peak-so-far this month" as a slowly-updated
parameter each replan (replan is every 15 min; horizon is 48 h ≪ a month),
rather than a full extra state axis.
- Tariff variety (see config point above) — the model must not hard-code
one DSO's rule.
Important nuance — 15-min settlement can neutralize the trick
The Nordic move to 15-minute settlement (NordPool PTU, which this codebase
already consumes for prices) points toward effektavgift windows shrinking
from 60 → 15 min. If a DSO measures the peak over 15-min windows, a 30-min
offset gains nothing — a 1-hour load spans four equal quarters regardless of
where it starts.
Conclusion: implement the general demand-charge term (parameterized by
window length), not a hard-coded "XX:30" heuristic. The straddle then emerges
only when — and only for as long as — the billing window is a full clock hour.
Related
Summary
The MPC planner optimizes energy cost only (
öre/kWh × kWh). It has noconcept of a demand charge — the Swedish effektavgift, billed on the
monthly peak timmedeleffekt (the highest clock-hour average power, priced
in kr/kW). Because that term is absent from the objective, the DP never
time-shifts a controllable sub-hour load to sit astride a clock-hour
boundary — the well-known "start at XX:30" trick that halves a ≤1-hour
load's contribution to the monthly hourly peak.
This is a feature request to make the planner demand-charge aware. The XX:30
straddle should then fall out for free — it's just what a peak-minimizing
DP does at 15-min resolution; we should not hard-code a timing heuristic.
The core trade-off the planner must make
Demand-charge awareness is not "always straddle the hour." The peak is a
single monthly scalar that couples every hour of the billing period, so each
local decision to run a controllable load (charge the battery, charge an EV,
run a flexload) carries a month-long cost consequence. The DP has to weigh:
across the two 15-min windows, storing cheap kWh, or meeting an EV deadline;
against
average above the current monthly peak (kr/kW — paid once, but sized by
the single worst hour, so it "sticks" for the entire month).
So the right move is situational: split/straddle to halve the hour average,
spread thinner across more slots to lower the kW, or skip it this month
when the peak penalty dominates the energy win. The XX:30 straddle is one tool
the DP reaches for when it happens to win that trade-off — not the goal itself.
Current behavior (verified in code)
cost = effPrice × gridKWh(import) /−exportPrice × |gridKWh|(export) ingo/internal/mpc/mpc.go. No kr/kWterm anywhere in the Bellman recursion.
monthly-peak in
go/internal/config/config.go.go/internal/savings/savings.gotracks only kWhand öre; there is no kW-peak line item to optimize against or report.
the price grid (15-min NordPool PTU since late 2025, or legacy 60-min) and
the synthetic-slot builder rounds down to the slot grid
(
start % (slotLen·60·1000)) ingo/internal/mpc/service.go. No half-houroffset is ever introduced.
The nearest existing power-aware features are different mechanisms, not
this:
PowerLimits.MaxImportW(go/internal/mpc/power_limits.go) is a hardper-slot import cap, but in practice it's only populated from the physical
fuse via
clampSlotGridLimits(FuseMaxW, …)— there is no config surface fora time-windowed capacity tariff. A hard cap also forces flattening; it does
not reward straddling the hour boundary.
peak_shavingmode /PeakImportCeilingWhold instantaneous grid importunder a ceiling by discharging the battery — a kW cap enforced with
stored energy, not a time-of-use shift of a controllable load.
Why it matters + the concrete XX:30 arithmetic
Many Swedish DSOs bill an effektavgift on the month's peak
timmedeleffekt — the highest clock-hour average grid import over the
month, priced in kr/kW. For a fixed-duration load shorter than or equal to an
hour, you can halve its contribution to that peak purely by when you start
it:
Take a 1-hour, 10 kW EV charge (10 kWh, same energy either way):
14:00–15:00window → that windowaverages 10 kW → contributes 10 kW to the monthly peak.
14:00–15:00(5 kW avg) + 5 kWh in15:00–16:00(5 kW avg) → each window sees only 5 kW → contributes5 kW to the monthly peak.
Same energy, same energy cost, half the demand charge on that load. The
planner already schedules controllable loads (battery charge, EV loadpoints)
at 15-min resolution, so it could place them this way — it just has no reason
to, because the peak isn't in the objective.
Sketch of what an implementation would need
grid.demand_charge_ore_per_kw),the billing-window length (clock-hour today), and the peak measure. DSOs
differ: some bill the single monthly max, others the mean of the 3 highest
daily peaks on separate days — make it configurable.
parameter, and add a marginal cost whenever a plan pushes a clock-hour's
average import above the current peak. This means aggregating the
15-min slots into clock-hour windows inside the cost accumulation.
the peak term exists it will discover the clock-hour straddle for discrete
loads on its own. No special-case timing code.
savings.goand the UI so theavoided demand charge is visible.
Complications worth calling out up front:
to approximate: re-feed "peak-so-far this month" as a slowly-updated
parameter each replan (replan is every 15 min; horizon is 48 h ≪ a month),
rather than a full extra state axis.
one DSO's rule.
Important nuance — 15-min settlement can neutralize the trick
The Nordic move to 15-minute settlement (NordPool PTU, which this codebase
already consumes for prices) points toward effektavgift windows shrinking
from 60 → 15 min. If a DSO measures the peak over 15-min windows, a 30-min
offset gains nothing — a 1-hour load spans four equal quarters regardless of
where it starts.
Conclusion: implement the general demand-charge term (parameterized by
window length), not a hard-coded "XX:30" heuristic. The straddle then emerges
only when — and only for as long as — the billing window is a full clock hour.
Related
bug(planner): arbitrage exports live PV surplus while planning later grid charge(same planner objective).