Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions core/lm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,13 +270,22 @@ earlier allows more and a short spike is only covered when the window would end
above the limit. It is at most `cap × limit` (default 2), stops growing from the
freeze minute (default 12) as a meter clock off by seconds could move a late
draw into the next window, and the last cycle borrows the next window's
budget. What evcc did not see, after a start or a gap, counts at the limit.
budget. What evcc did not see, after a start or a gap, counts at the limit; so
does a gap of over 2 minutes inside a window that the grid power or a changed
source has to fill in (a counter of the same source before and after covers it),
and such a window stays out of the statistics (`TestMeterUnmetered`).

The energy drawn comes from the grid meter's import counter, else a Home
Assistant energy sensor (kWh or Wh), else the grid power. A failed or backward
reading is replaced by the grid power for that interval; a counter standing
still while the grid power says more than 20 Wh in 2 minutes is replaced for
the rest of the window (`peakShavingSource`).
Assistant energy sensor (kWh or Wh), else the grid power. A failed reading, a 0
of the sensor (as evcc takes it for the grid meter) and a reading below the last
are replaced by the grid power for that interval. The lower reading only becomes
the new base once the next one confirms it (a real reset); back at the old level
it was a glitch, and the counter's step leaves out what the grid power already
counted (`TestMeterCounterGlitch`). A step beyond what a grid connection draws
(100 kW) is no measurement either. A counter standing still while the grid power
says more than 20 Wh in 2 minutes is replaced for the rest of the window
(`peakShavingSource`). A failing sensor is logged once, then at debug level
until it works again (`TestPeakEnergyReadLoggedOnce`).

Below the reserve the battery is kept in normal mode, as hold would block the
discharge controller; a battery mode set from outside through the api stays.
Expand All @@ -302,7 +311,9 @@ starts there again. The load management circuit rises along. See
**Statistics** (*Mehr → Peak Shaving*): per month the highest quarter hour with
and without the battery and how often it covered a peak; only quarter hours
metered from their start count, kept for 24 months (`peakMonths`,
`core/site_peak_stats.go`). Each month also keeps its baseline, the highest
`core/site_peak_stats.go`). "Without the battery" adds back only what the
battery gave to the house, not what it fed into the grid (`TestMeterCompleted`;
with a counter an approximation, as the import counter knows no export). Each month also keeps its baseline, the highest
limit set by hand in it (the base while following the peak); months from before
get the current one at the start. The dialog shows the current month in two
tiles (highest quarter hour with and without the battery next to each other
Expand Down
117 changes: 100 additions & 17 deletions core/peak/meter.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type State struct {
Source string // where this sample's energy came from
Stale bool // the counter stopped updating, grid power used for the rest of the window
StaleNow bool // ... and did so with this sample
GlitchNow bool // this sample's counter reading was not taken as it was, see Meter
Completed *Completed
}

Expand All @@ -49,7 +50,10 @@ func (s State) End() time.Time {
// Meter tracks the grid energy of the running window. The energy drawn comes
// from an import counter when the sample has one, else from the grid power.
// What it did not see, after a start or a gap, counts at the limit: assuming
// less could spend a budget that was already used. Not safe for concurrent use.
// less could spend a budget that was already used. A counter reading lower than
// the last is taken for a glitch (a sensor reporting 0 for a moment) until the
// next reading confirms it, one further ahead than any grid connection draws is
// no measurement. The grid power counts meanwhile. Not safe for concurrent use.
type Meter struct {
windowStart time.Time
meteredFrom time.Time
Expand All @@ -65,24 +69,98 @@ type Meter struct {
unmovedWs float64 // drawn according to the grid power while the counter stood still
unmovedSince time.Time // the counter has not moved since
stale bool
gapped bool // a gap inside the window was counted at the limit

// a reading below the last one is a reset, a replaced counter or a glitch (a
// sensor reporting 0 for a moment). It only becomes the new base once the next
// reading confirms it; back at the old level it never was one.
pending bool
pendingEnergy float64 // the lower reading in kWh
pendingWs float64 // drawn according to the grid power while it waited
glitch bool // the last sample's reading was not taken as it was
}

// rebase takes this reading as the base for the next step
func (m *Meter) rebase(source string, energy float64) {
m.source, m.lastEnergy = source, energy
m.pending, m.pendingWs = false, 0
}

// drawn returns the energy drawn since the last sample in Ws. A counter is used
// when it was read now and last time from the same source, the grid power fills
// in otherwise.
func (m *Meter) drawn(now time.Time, imported, energy float64, source string) float64 {
powerWs := imported * now.Sub(m.lastSample).Seconds()
// in otherwise: for a gap longer than MaxGap the power after it says nothing
// about the gap, it counts at the limit. A reading the counter cannot have
// reached, or one lower than the last, does not count until it is confirmed.
func (m *Meter) drawn(now time.Time, imported, energy float64, source string, limit float64) float64 {
interval := now.Sub(m.lastSample)
powerWs := imported * interval.Seconds()
m.glitch = false

if source == SourcePower || source != m.source || m.stale {
// what counts where the counter does not tell
fallback := func() float64 {
if interval > MaxGap {
m.gapped = true
return limit * interval.Seconds()
}
return powerWs
}

switch d := (energy - m.lastEnergy) * 3600e3; {
case d < 0:
// counter reset or replaced
return powerWs
if source == SourcePower || source != m.source || m.stale {
m.rebase(source, energy)
return fallback()
}

// grid power counted for a lower reading that turned out to be a glitch
var counted float64
back := false

if m.pending {
switch {
case energy < m.pendingEnergy:
// still falling: keep waiting
fb := fallback()
m.pendingEnergy, m.pendingWs = energy, m.pendingWs+fb
return fb

case energy >= m.lastEnergy:
// back at the old level: the counter covers what the grid power counted
counted, back = m.pendingWs, true

default:
// the lower level holds: a reset or a replaced counter, continue from it
m.lastEnergy = m.pendingEnergy
}

case d > 0:
m.pending, m.pendingWs = false, 0
}

d := (energy - m.lastEnergy) * 3600e3

if d < 0 {
fb := fallback()
m.pending, m.pendingEnergy, m.pendingWs = true, energy, fb
m.glitch = true
return fb
}

// no grid connection draws this much: not a measurement, the grid power
// counts (also what the counter was late with) and the next step starts here
if d > maxGridPower*max(interval, time.Second).Seconds() {
drawn := fallback() + m.unmovedWs
m.unmovedWs, m.unmovedSince = 0, time.Time{}
m.rebase(source, energy)
m.glitch = true
return drawn
}

m.lastEnergy = energy

if back {
m.unmovedWs, m.unmovedSince = 0, time.Time{}
return max(0, d-counted)
}

if d > 0 {
m.unmovedWs, m.unmovedSince = 0, time.Time{}
return d
}
Expand All @@ -107,7 +185,8 @@ func (m *Meter) drawn(now time.Time, imported, energy float64, source string) fl

// Update takes the sample of a cycle and returns the window's state. A window
// that ends with it and was metered from its start is returned as Completed,
// with the battery power added back for the demand without it.
// with the battery power added back for the demand without it (as far as it
// went to the house, not to the grid).
func (m *Meter) Update(s Sample, set Settings) State {
now := s.Now
source := s.Source
Expand All @@ -123,9 +202,14 @@ func (m *Meter) Update(s Sample, set Settings) State {
var drawn, demand float64
if !m.lastSample.IsZero() {
wasStale := m.stale
drawn = m.drawn(now, imported, s.Energy, source)
demand = max(0, drawn+s.BatteryPower*now.Sub(m.lastSample).Seconds())
drawn = m.drawn(now, imported, s.Energy, source, set.Limit)
// what the battery discharged into the grid was not the house's demand. With
// a counter this is an approximation: the import counter knows no export.
demand = max(0, drawn+(s.BatteryPower+min(0, s.GridPower))*now.Sub(m.lastSample).Seconds())
res.StaleNow = m.stale && !wasStale
res.GlitchNow = m.glitch
} else {
m.rebase(source, s.Energy)
}

if !m.windowStart.Equal(start) {
Expand All @@ -134,8 +218,8 @@ func (m *Meter) Update(s Sample, set Settings) State {
if !m.lastSample.IsZero() && m.lastSample.Before(start) && now.Sub(m.lastSample) <= MaxGap {
after := now.Sub(start).Seconds() / now.Sub(m.lastSample).Seconds()

// only a window metered from its start counts for the statistics
if m.meteredFrom.Equal(m.windowStart) && !m.windowStart.IsZero() {
// only a window metered from its start, without a gap, counts for the statistics
if m.meteredFrom.Equal(m.windowStart) && !m.windowStart.IsZero() && !m.gapped {
res.Completed = &Completed{m.windowStart, m.windowWs + drawn*(1-after), m.demandWs + demand*(1-after)}
}

Expand All @@ -148,15 +232,14 @@ func (m *Meter) Update(s Sample, set Settings) State {

m.windowStart = start
m.isFrozen = false
m.stale = false
m.stale, m.gapped = false, false
m.unmovedWs, m.unmovedSince = 0, time.Time{}
} else {
m.windowWs += drawn
m.demandWs += demand
}

m.lastSample = now
m.source, m.lastEnergy = source, s.Energy
if m.stale {
source = SourcePower
}
Expand Down
144 changes: 144 additions & 0 deletions core/peak/meter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,44 @@ func TestMeterUnmetered(t *testing.T) {
h.update(0)
assert.Equal(t, h.now, h.st.MeteredFrom)
assert.Equal(t, 5000.0, h.st.Allowed)

// a gap inside a window counts at the limit, not at the power after it, and
// the window is not handed out for the statistics
h = newHarness(0)
h.update(9000)
h.sample(9000, time.Minute)
assert.InDelta(t, 9000*60, h.st.DrawnWs, 0.001)

h.now = time.Date(2026, 9, 25, 10, 9, 0, 0, time.UTC)
h.update(500)
assert.InDelta(t, 9000*60+5000*480, h.st.DrawnWs, 0.001, "8 minutes at the limit")

h.sample(500, 6*time.Minute)
assert.Equal(t, time.Date(2026, 9, 25, 10, 15, 0, 0, time.UTC), h.st.Start)
assert.Nil(t, h.st.Completed)

// a source changing across the gap is the same
h = newHarness(0)
h.counterNow(9000, 100)
h.counter(9000, 100.075)
h.now = time.Date(2026, 9, 25, 10, 9, 0, 0, time.UTC)
h.update(500)
assert.InDelta(t, 270000+5000*510, h.st.DrawnWs, 0.001)

// the same counter before and after covers the gap
h = newHarness(0)
h.counterNow(9000, 100)
h.counter(9000, 100.075)
h.now = time.Date(2026, 9, 25, 10, 9, 0, 0, time.UTC)
h.counterNow(500, 100.5)
assert.InDelta(t, 0.5*3600e3, h.st.DrawnWs, 0.001)

for range 12 {
h.counter(0, 100.5)
}
if assert.NotNil(t, h.st.Completed, "metered from its start by the counter") {
assert.InDelta(t, 0.5*3600e3, h.st.Completed.DrawnWs, 0.001)
}
}

// TestMeterLateCounter verifies that a counter updating less often than
Expand Down Expand Up @@ -192,4 +230,110 @@ func TestMeterCompleted(t *testing.T) {
assert.InDelta(t, 4000, c.DrawnWs/peak.Window.Seconds(), 0.001)
assert.InDelta(t, 6000, c.DemandWs/peak.Window.Seconds(), 0.001)
}

// 10:30 to 10:45 exporting 5 kW while the battery discharges 3 kW: nothing
// drawn, and without the battery nothing would have been
for range 30 {
h.now = h.now.Add(peak.Cycle)
h.st = h.m.Update(peak.Sample{Now: h.now, GridPower: -5000, BatteryPower: 3000, Source: peak.SourcePower}, h.set)
}

c = h.st.Completed
if assert.NotNil(t, c) {
assert.Equal(t, time.Date(2026, 9, 25, 10, 30, 0, 0, time.UTC), c.Start)
assert.Zero(t, c.DrawnWs)
assert.Zero(t, c.DemandWs)
}

// 10:45 to 11:00 exporting 2 kW while the battery discharges 3 kW: without the
// battery the house would have drawn 1 kW
for range 30 {
h.now = h.now.Add(peak.Cycle)
h.st = h.m.Update(peak.Sample{Now: h.now, GridPower: -2000, BatteryPower: 3000, Source: peak.SourcePower}, h.set)
}

c = h.st.Completed
if assert.NotNil(t, c) {
assert.Zero(t, c.DrawnWs)
assert.InDelta(t, 1000, c.DemandWs/peak.Window.Seconds(), 0.001)
}
}

// TestMeterCounterGlitch verifies that a counter reading 0 or lower for a moment
// does not count its return as a draw, while a real reset stays correct. 3kW are
// 90000Ws per 30s cycle, 0.025kWh.
func TestMeterCounterGlitch(t *testing.T) {
const (
kWhStep = 0.025
wsStep = 90000.0
)

// the reading drops for one cycle: the grid power counts, and the counter's
// return does not count that interval a second time
for name, dip := range map[string]float64{"zero": 0, "half a kWh back": 12344.5} {
t.Run(name, func(t *testing.T) {
h := newHarness(0)
h.counterNow(3000, 12345)
h.counter(3000, 12345+kWhStep)
assert.InDelta(t, wsStep, h.st.DrawnWs, 0.001)

h.counter(3000, dip)
assert.True(t, h.st.GlitchNow)
assert.InDelta(t, 2*wsStep, h.st.DrawnWs, 0.001)

h.counter(3000, 12345+3*kWhStep)
assert.False(t, h.st.GlitchNow)
assert.InDelta(t, 3*wsStep, h.st.DrawnWs, 0.001)
assert.Equal(t, peak.SourceMeter, h.st.Source)

h.counter(3000, 12345+4*kWhStep)
assert.InDelta(t, 4*wsStep, h.st.DrawnWs, 0.001)
assert.InDelta(t, 3000, h.st.Avg, 0.001)
})
}

t.Run("real reset", func(t *testing.T) {
h := newHarness(0)
h.counterNow(3000, 1000)
h.counter(3000, 1000+kWhStep)

// the counter starts again: the reset interval counts by the grid power,
// the next reading confirms the new level and counts from it
h.counter(3000, 0)
assert.InDelta(t, 2*wsStep, h.st.DrawnWs, 0.001)
h.counter(3000, kWhStep)
assert.InDelta(t, 3*wsStep, h.st.DrawnWs, 0.001)
h.counter(3000, 2*kWhStep)
assert.InDelta(t, 4*wsStep, h.st.DrawnWs, 0.001)
assert.False(t, h.st.GlitchNow)
})

t.Run("two zero readings", func(t *testing.T) {
h := newHarness(0)
h.counterNow(3000, 12345)
h.counter(3000, 12345+kWhStep)
h.counter(3000, 0)
h.counter(3000, 0)

// the reading that is back is further ahead than any connection draws
h.counter(3000, 12345+4*kWhStep)
assert.True(t, h.st.GlitchNow)
assert.InDelta(t, 4*wsStep, h.st.DrawnWs, 0.001, "the grid power counts, the counter's step does not")

h.counter(3000, 12345+5*kWhStep)
assert.InDelta(t, 5*wsStep, h.st.DrawnWs, 0.001)
})

t.Run("jump ahead", func(t *testing.T) {
h := newHarness(0)
h.counterNow(3000, 100)
h.counter(3000, 100+kWhStep)

h.counter(3000, 200)
assert.True(t, h.st.GlitchNow)
assert.InDelta(t, 2*wsStep, h.st.DrawnWs, 0.001)

h.counter(3000, 200+kWhStep)
assert.InDelta(t, 3*wsStep, h.st.DrawnWs, 0.001)
})
}
3 changes: 3 additions & 0 deletions core/peak/peak.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ const (
// an energy counter standing still while the grid power says this much was
// drawn over MaxGap has stopped updating
staleWs = 20 * 3600.0 // 20Wh

// no grid connection draws more: a counter advancing faster is no measurement
maxGridPower = 100e3 // W
)

// Where the energy drawn in the window comes from
Expand Down
Loading
Loading