Skip to content

feat(planner): make the DP demand-charge (effektavgift) aware so it can shave monthly timmedeleffekt — incl. the "XX:30" clock-hour straddle #535

Description

@HuggeK

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
  1. 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.
  2. 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.
  3. 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.
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions