Skip to content

Commit f8c9c6a

Browse files
committed
Planner: add per-slot CapacityLedger (Phase 2 foundation)
Tracks residual charging power per time slot on a shared circuit: Available/Reserve plus CanHost, the semi-continuous min-power gate (a charger runs >= min or off). Foundation for priority-ordered, circuit-aware planning. Single circuit + static budget for now; hierarchy TODO.
1 parent a42eeef commit f8c9c6a

3 files changed

Lines changed: 90 additions & 10 deletions

File tree

core/planner/ledger.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package planner
2+
3+
import (
4+
"time"
5+
6+
"github.com/evcc-io/evcc/api"
7+
)
8+
9+
// CapacityLedger tracks residual charging power available per time slot on a
10+
// shared circuit, so loadpoints can be planned in priority order without
11+
// overcommitting the circuit. Slots are keyed by their truncated start time.
12+
type CapacityLedger struct {
13+
slot time.Duration
14+
budget float64 // total circuit power (W) per slot
15+
used map[time.Time]float64 // reserved power (W) per slot start
16+
}
17+
18+
// NewCapacityLedger creates a ledger with the given per-slot power budget.
19+
func NewCapacityLedger(budget float64, slot time.Duration) *CapacityLedger {
20+
return &CapacityLedger{slot: slot, budget: budget, used: make(map[time.Time]float64)}
21+
}
22+
23+
// Available returns the residual power in the slot containing t.
24+
func (l *CapacityLedger) Available(t time.Time) float64 {
25+
return max(0, l.budget-l.used[t.Truncate(l.slot)])
26+
}
27+
28+
// Reserve subtracts power across every slot the plan occupies.
29+
func (l *CapacityLedger) Reserve(plan api.Rates, power float64) {
30+
for _, r := range plan {
31+
for t := r.Start.Truncate(l.slot); t.Before(r.End); t = t.Add(l.slot) {
32+
l.used[t] += power
33+
}
34+
}
35+
}
36+
37+
// CanHost reports whether the slot containing t can still grant minPower.
38+
// Semi-continuous: a charger runs at >= minPower or off (evcc-io/optimizer#91).
39+
func (l *CapacityLedger) CanHost(t time.Time, minPower float64) bool {
40+
return l.Available(t) >= minPower
41+
}

core/planner/ledger_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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 TestCapacityLedger(t *testing.T) {
12+
start := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
13+
next := start.Add(time.Hour)
14+
15+
l := NewCapacityLedger(11000, time.Hour)
16+
17+
// full budget initially, hosts a 3p 6A (4140W) session
18+
assert.Equal(t, 11000.0, l.Available(start))
19+
assert.True(t, l.CanHost(start, 4140))
20+
21+
// reserve 7400W in the first slot
22+
l.Reserve(api.Rates{{Start: start, End: next}}, 7400)
23+
assert.Equal(t, 3600.0, l.Available(start))
24+
25+
// semi-continuous: 3600 < 4140 min -> cannot host another 3p session,
26+
// but a 1p 6A (1380W) still fits
27+
assert.False(t, l.CanHost(start, 4140))
28+
assert.True(t, l.CanHost(start, 1380))
29+
30+
// the next slot is untouched
31+
assert.Equal(t, 11000.0, l.Available(next))
32+
33+
// over-reserving never goes negative
34+
l.Reserve(api.Rates{{Start: start, End: next}}, 9000)
35+
assert.Equal(t, 0.0, l.Available(start))
36+
}

docs/agents/loadmanagement-aware-planning.md

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,18 +91,21 @@ Root gap: plans optimise against the tariff and clamp only to the circuit's
9191
a throttled loadpoint charges slower than `maxPower` assumed, so its plan
9292
eventually overruns.
9393

94-
### Phase 1 — Deterministic priority-aware clamp (Option C)
95-
- [x] Order the update round by priority instead of config order
96-
(`site.prioritizedLoadpoints`): fast/deadline-bound (`IsFastChargingActive`)
97-
first, then descending `EffectivePriority`. So on a shared circuit the
98-
higher-priority loadpoints establish draw first and the reactive clamp
99-
throttles the rest. Note: forced and plan-active are folded into one tier
100-
here; a strict forced > plan-active sub-order can be added later.
101-
- [x] Regression test (`TestPrioritizedLoadpoints`): fast first, then priority.
94+
### Phase 1 — Deterministic priority-aware clamp (Option C) — dropped
95+
96+
Reordering the round-robin update sequence by priority was tried and reverted:
97+
it does not help. Updates are round-robin (one loadpoint per tick) and also
98+
event-driven (`lpUpdateChan`), and the clamp reacts to *measured* power, so in
99+
steady state allocation is order-independent and the reorder is a near no-op.
100+
101+
Priority must instead drive **planner allocation** (Phase 2): plan loadpoints in
102+
priority order against the shared-capacity ledger. The round-robin control
103+
sequence stays untouched.
102104

103105
### Phase 2 — Capacity ledger + priority-ordered planning (Option B)
104-
- [ ] Per-circuit, per-slot residual-capacity ledger, mirroring the parent/child
105-
circuit tree (like `ValidatePower` recursion).
106+
- [x] Per-slot residual-capacity ledger (`planner.CapacityLedger`): `Available`,
107+
`Reserve`, and `CanHost` (semi-continuous min-power gate). Single circuit,
108+
static budget for now; parent/child tree still TODO.
106109
- [ ] Extend `planner.Plan` / `optimalPlan` to accept a per-slot power cap.
107110
Today a slot equals full Pmax; a capped slot delivers
108111
`min(Pmax, residual) × slotLen`, so a partially-available slot must spill

0 commit comments

Comments
 (0)