Skip to content

Commit d427ef2

Browse files
committed
Planner: per-slot power cap in planCapped (Phase 2 core)
Generalise optimalPlan into planCapped: with no cap it is the old full-power duration accounting (optimalPlan delegates, behaviour unchanged); with a per-slot availability a slot delivers min(maxPower, avail) and is skipped below minPower (semi-continuous), so a power-limited slot counts less and the plan spills into more slots. Not yet wired into Plan - needs the ledger.
1 parent f8c9c6a commit d427ef2

3 files changed

Lines changed: 75 additions & 7 deletions

File tree

core/planner/capped_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package planner
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/evcc-io/evcc/api"
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestPlanCapped(t *testing.T) {
12+
start := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
13+
target := start.Add(4 * time.Hour)
14+
15+
mk := func(n int) api.Rates {
16+
var r api.Rates
17+
for i := 0; i < n; i++ {
18+
s := start.Add(time.Duration(i) * time.Hour)
19+
r = append(r, api.Rate{Start: s, End: s.Add(time.Hour), Value: 0.1})
20+
}
21+
return r
22+
}
23+
24+
const maxPower, minPower = 11000.0, 1380.0
25+
full := func(time.Time) float64 { return maxPower }
26+
half := func(time.Time) float64 { return maxPower / 2 }
27+
28+
// full availability == unconstrained duration accounting
29+
p := planCapped(mk(4), time.Hour, target, full, maxPower, minPower)
30+
assert.Equal(t, time.Hour, Duration(p))
31+
32+
// half power -> plan spills into twice the wall-clock time for the same energy
33+
p = planCapped(mk(4), time.Hour, target, half, maxPower, minPower)
34+
assert.Equal(t, 2*time.Hour, Duration(p))
35+
assert.Len(t, p, 2)
36+
37+
// a slot below min power is skipped (semi-continuous)
38+
skipFirst := func(ts time.Time) float64 {
39+
if ts.Equal(start) {
40+
return minPower - 1
41+
}
42+
return maxPower
43+
}
44+
p = planCapped(mk(4), time.Hour, target, skipFirst, maxPower, minPower)
45+
assert.Equal(t, time.Hour, Duration(p))
46+
for _, r := range p {
47+
assert.False(t, r.Start.Equal(start), "sub-min slot must be skipped")
48+
}
49+
}

core/planner/planner.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,19 +39,37 @@ func New(log *util.Logger, tariff api.Tariff, opt ...func(t *Planner)) *Planner
3939
// - rates are sorted in ascending order by cost and descending order by start time (prefer late slots)
4040
// - rates are filtered to [now, targetTime] window by caller
4141
func optimalPlan(rates api.Rates, requiredDuration time.Duration, targetTime time.Time) api.Rates {
42+
return planCapped(rates, requiredDuration, targetTime, nil, 0, 0)
43+
}
44+
45+
// planCapped builds a lowest-cost plan; with avail==nil each slot is full power
46+
// (duration accounting), else min(maxPower, avail) and skipped below minPower.
47+
func planCapped(rates api.Rates, requiredDuration time.Duration, targetTime time.Time, avail func(time.Time) float64, maxPower, minPower float64) api.Rates {
4248
plan := make(api.Rates, 0, int64(requiredDuration)/int64(tariff.SlotDuration)+3)
4349

4450
for _, slot := range rates {
4551
slotDuration := slot.End.Sub(slot.Start)
46-
requiredDuration -= slotDuration
52+
53+
// per-slot power factor: full unless a capacity cap applies
54+
factor := 1.0
55+
if avail != nil && maxPower > 0 {
56+
p := avail(slot.Start)
57+
if p < minPower {
58+
continue // semi-continuous: charge >= min or not at all
59+
}
60+
factor = min(maxPower, p) / maxPower
61+
}
62+
63+
requiredDuration -= time.Duration(float64(slotDuration) * factor)
4764

4865
// slot covers more than we need, so shorten it
4966
if requiredDuration < 0 {
67+
trim := time.Duration(float64(-requiredDuration) / factor)
5068
// the first (if not single) slot should start as late as possible
5169
if IsFirst(slot, plan) && len(plan) > 0 {
52-
slot.Start = slot.Start.Add(-requiredDuration)
70+
slot.Start = slot.Start.Add(trim)
5371
} else {
54-
slot.End = slot.End.Add(requiredDuration)
72+
slot.End = slot.End.Add(-trim)
5573
}
5674
requiredDuration = 0
5775
}

docs/agents/loadmanagement-aware-planning.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,11 @@ sequence stays untouched.
106106
- [x] Per-slot residual-capacity ledger (`planner.CapacityLedger`): `Available`,
107107
`Reserve`, and `CanHost` (semi-continuous min-power gate). Single circuit,
108108
static budget for now; parent/child tree still TODO.
109-
- [ ] Extend `planner.Plan` / `optimalPlan` to accept a per-slot power cap.
110-
Today a slot equals full Pmax; a capped slot delivers
111-
`min(Pmax, residual) × slotLen`, so a partially-available slot must spill
112-
the plan into more slots. This is the core rework.
109+
- [x] `planner.planCapped` (per-slot power cap): with `avail==nil` it is the old
110+
full-power duration accounting (`optimalPlan` delegates, behaviour
111+
unchanged); with a cap a slot delivers `min(maxPower, avail)` and is skipped
112+
below `minPower`, so a power-limited slot counts proportionally less and the
113+
plan spills into more slots. Not yet wired into `Plan` (needs the ledger).
113114
- [ ] Site orchestration: reserve forced load → plan loadpoints in priority
114115
order against the ledger, subtracting each reservation.
115116
- [ ] Allocate the shared budget in whole `EffectiveMinPower()` chunks

0 commit comments

Comments
 (0)