diff --git a/core/lm/README.md b/core/lm/README.md index b853062199..7bbbd9bfcd 100644 --- a/core/lm/README.md +++ b/core/lm/README.md @@ -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. @@ -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 diff --git a/core/peak/meter.go b/core/peak/meter.go index bfe2a1f2f7..88b2e168df 100644 --- a/core/peak/meter.go +++ b/core/peak/meter.go @@ -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 } @@ -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 @@ -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 } @@ -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 @@ -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) { @@ -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)} } @@ -148,7 +232,7 @@ 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 @@ -156,7 +240,6 @@ func (m *Meter) Update(s Sample, set Settings) State { } m.lastSample = now - m.source, m.lastEnergy = source, s.Energy if m.stale { source = SourcePower } diff --git a/core/peak/meter_test.go b/core/peak/meter_test.go index 17bc97f545..e9fa0bd54e 100644 --- a/core/peak/meter_test.go +++ b/core/peak/meter_test.go @@ -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 @@ -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) + }) } diff --git a/core/peak/peak.go b/core/peak/peak.go index 39ad2544e7..6f8845e086 100644 --- a/core/peak/peak.go +++ b/core/peak/peak.go @@ -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 diff --git a/core/site_peakshaving.go b/core/site_peakshaving.go index 38a027f474..54edc71217 100644 --- a/core/site_peakshaving.go +++ b/core/site_peakshaving.go @@ -76,6 +76,7 @@ type peakState struct { // Persisted, so a restart in the middle of a control still hands back. owned bool writeErr map[string]string // the last error logged per output, see writeOutput + readErr string // the last error logged reading the energy sensor, see logEnergyRead // battery type Marstek (Omnibattery), see site_peak_omni.go: evcc drives // Omnibattery's own peak shaving and switches the battery to manual control @@ -816,7 +817,8 @@ func writeErrorText(err error) string { // peakEnergy returns the grid import counter in kWh and where it came from: the // grid meter, else the Home Assistant sensor. Without either, the source is the -// grid power. +// grid power. A 0 of the sensor is no reading, like evcc takes it for the grid +// meter. func (site *Site) peakEnergy() (float64, string) { s := site.peak() @@ -830,16 +832,44 @@ func (site *Site) peakEnergy() (float64, string) { } if get != nil { - v, err := get() + v, err := nonZeroEnergy(get()) + site.logEnergyRead(entity, err) + if err == nil { return v, peak.SourceEntity } - site.log.WARN.Printf("peak shaving: energy %s: %v, using the grid power", entity, err) } return 0, peak.SourcePower } +// logEnergyRead reports the result of reading the energy sensor, every cycle. A +// failure is logged once as a warning and then at debug level until the read +// works again (once at info), like a failed write. +func (site *Site) logEnergyRead(entity string, err error) { + s := site.peak() + + var text string + if err != nil { + text = err.Error() + } + + s.mu.Lock() + last := s.readErr + s.readErr = text + s.mu.Unlock() + + switch { + case err == nil && last != "": + site.log.INFO.Printf("peak shaving: energy %s: ok again", entity) + case err == nil: + case text == last: + site.log.DEBUG.Printf("peak shaving: energy %s: %s", entity, text) + default: + site.log.WARN.Printf("peak shaving: energy %s: %s, using the grid power", entity, text) + } +} + // setPeakGridEnergy takes the grid meter's import counter of this cycle, nil if // the meter has none func (site *Site) setPeakGridEnergy(kWh *float64) { @@ -872,6 +902,10 @@ func (site *Site) updatePeakWindow(gridPower, batteryPower float64) { site.log.WARN.Printf("peak shaving: the %s energy counter stopped updating, using the grid power until the window ends", source) } + if w.GlitchNow { + site.log.WARN.Printf("peak shaving: the %s energy counter jumped to %.3f kWh, using the grid power for now", source, energy) + } + site.publish(keys.PeakShavingWindowAvg, w.Avg) site.publish(keys.PeakShavingAllowed, w.Allowed) site.publish(keys.PeakShavingSource, w.Source) diff --git a/core/site_peakshaving_test.go b/core/site_peakshaving_test.go index 68115cea1a..1948cada1e 100644 --- a/core/site_peakshaving_test.go +++ b/core/site_peakshaving_test.go @@ -498,6 +498,67 @@ func TestPeakWindowSources(t *testing.T) { s.energyGet = func() (float64, error) { return 1, nil } energyStep(site, clk, 4000, nil) assert.InDelta(t, 420000+120000, s.window.DrawnWs, 0.001) + + // back at the old level it was a glitch: the counter covers the interval the + // grid power counted, 0.05kWh since 50.2 less 120000Ws + s.energyGet = func() (float64, error) { return 50.25, nil } + energyStep(site, clk, 4000, nil) + assert.InDelta(t, 540000+60000, s.window.DrawnWs, 0.001) + assert.Equal(t, peak.SourceEntity, s.window.Source) + + // a 0 is no reading, as for the grid meter: the grid power fills in, and the + // next reading is a new baseline + s.energyGet = func() (float64, error) { return 0, nil } + energyStep(site, clk, 2000, nil) + assert.InDelta(t, 600000+60000, s.window.DrawnWs, 0.001) + assert.Equal(t, peak.SourcePower, s.window.Source) + + s.energyGet = func() (float64, error) { return 50.3, nil } + energyStep(site, clk, 2000, nil) + assert.InDelta(t, 660000+60000, s.window.DrawnWs, 0.001) + assert.Equal(t, peak.SourceEntity, s.window.Source) +} + +// TestPeakEnergyReadLoggedOnce verifies that a sensor failing for many cycles, or +// reporting 0, is logged once as a warning and then at debug level, and its +// return once at info +func TestPeakEnergyReadLoggedOnce(t *testing.T) { + var buf bytes.Buffer + + site := &Site{log: testLogger(&buf)} + s := site.peak() + + var ( + val float64 + err error + ) + s.energyEntity = "sensor.energy" + s.energyGet = func() (float64, error) { return val, err } + + count := func(level string) int { return strings.Count(buf.String(), level) } + + for range 5 { + _, source := site.peakEnergy() + assert.Equal(t, peak.SourcePower, source, "0 is no reading") + } + assert.Equal(t, 1, count("WARN")) + assert.Equal(t, 4, count("DEBUG")) + + // the same cause stays quiet, another one is a new line + err = errors.New("unreachable") + site.peakEnergy() + site.peakEnergy() + assert.Equal(t, 2, count("WARN")) + + // the reading is back, once + err, val = nil, 12.5 + for range 3 { + v, source := site.peakEnergy() + assert.Equal(t, 12.5, v) + assert.Equal(t, peak.SourceEntity, source) + } + assert.Equal(t, 1, count("INFO")) + assert.Contains(t, buf.String(), "energy sensor.energy: ok again") } // TestPeakReserveKeepsExternalMode: below the reserve peak shaving keeps the