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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions