From 2019035b5ea20f9fba2f95434e0375eca9836ee0 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Sun, 11 Oct 2026 21:50:28 +0200 Subject: [PATCH 1/3] Peak: Zaehler-Ausreisser verdirbt Fenster nicht mehr (Review B2) Ein Energiezaehler, der kurz 0 oder weniger meldet (HA-Neustart, Template mit float(0)), und danach wieder den echten Stand, wurde als Bezug gezaehlt: das Fenster, die Monatsspitze und Follow the Peak bekamen absurde Werte. - core/peak/meter.go: ein Stand unter dem letzten wird erst Basis, wenn der naechste Wert ihn bestaetigt (echter Reset bleibt richtig). Kommt der alte Stand zurueck, war es ein Ausreisser; was die Netzleistung dazwischen gezaehlt hat, zaehlt der Zaehlerschritt nicht doppelt. Ein Schritt ueber 100 kW gilt als keine Messung (Netzleistung zaehlt, neue Basis). - core/site_peakshaving.go: eine 0 des Energiesensors ist wie beim Netzzaehler kein Messwert (nonZeroEnergy); Lesefehler einmal je Ursache als Warnung, danach Debug, die Rueckkehr einmal als Info; Ausreisser des Zaehlers einmal als Warnung. - Tests: TestMeterCounterGlitch, TestPeakWindowSources erweitert, TestPeakEnergyReadLoggedOnce. Kein Hook. README Abschnitt Peak Shaving. Co-Authored-By: Claude Sonnet 5.5 --- core/lm/README.md | 14 ++++-- core/peak/meter.go | 83 +++++++++++++++++++++++++++++++---- core/peak/meter_test.go | 79 +++++++++++++++++++++++++++++++++ core/peak/peak.go | 3 ++ core/site_peakshaving.go | 40 +++++++++++++++-- core/site_peakshaving_test.go | 61 +++++++++++++++++++++++++ 6 files changed, 265 insertions(+), 15 deletions(-) diff --git a/core/lm/README.md b/core/lm/README.md index b853062199..885f814aa2 100644 --- a/core/lm/README.md +++ b/core/lm/README.md @@ -273,10 +273,16 @@ 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. 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. diff --git a/core/peak/meter.go b/core/peak/meter.go index bfe2a1f2f7..329cadf82f 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,85 @@ 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 + + // 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. +// in otherwise. A reading the counter cannot have reached, or one lower than +// the last, does not count until it is confirmed: the grid power fills in. func (m *Meter) drawn(now time.Time, imported, energy float64, source string) float64 { - powerWs := imported * now.Sub(m.lastSample).Seconds() + interval := now.Sub(m.lastSample) + powerWs := imported * interval.Seconds() + m.glitch = false if source == SourcePower || source != m.source || m.stale { + m.rebase(source, energy) return powerWs } - switch d := (energy - m.lastEnergy) * 3600e3; { - case d < 0: - // counter reset or replaced + // 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 + m.pendingEnergy, m.pendingWs = energy, m.pendingWs+powerWs + return powerWs + + 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 + } + + m.pending, m.pendingWs = false, 0 + } + + d := (energy - m.lastEnergy) * 3600e3 + + if d < 0 { + m.pending, m.pendingEnergy, m.pendingWs = true, energy, powerWs + m.glitch = true return powerWs + } + + // 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 := powerWs + m.unmovedWs + m.unmovedWs, m.unmovedSince = 0, time.Time{} + m.rebase(source, energy) + m.glitch = true + return drawn + } - case d > 0: + 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 } @@ -126,6 +191,9 @@ func (m *Meter) Update(s Sample, set Settings) State { drawn = m.drawn(now, imported, s.Energy, source) demand = max(0, drawn+s.BatteryPower*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) { @@ -156,7 +224,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..aca4ea2e15 100644 --- a/core/peak/meter_test.go +++ b/core/peak/meter_test.go @@ -193,3 +193,82 @@ func TestMeterCompleted(t *testing.T) { assert.InDelta(t, 6000, 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 From 652b06b2b0518369b7b1b4cbb6365d75603c2e32 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Sun, 11 Oct 2026 21:51:34 +0200 Subject: [PATCH 2/3] =?UTF-8?q?Peak:=20L=C3=BCcke=20im=20Fenster=20ohne=20?= =?UTF-8?q?Z=C3=A4hler=20wird=20am=20Limit=20gez=C3=A4hlt=20(Review=20B11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eine Lücke von mehr als 2 Minuten mitten im Fenster wurde bei Quelle Netzleistung oder bei einem Quellwechsel mit der Leistung danach hochgerechnet: nach 8 Minuten bei 9 kW und danach 500 W ging das Fenster mit einem viel zu kleinen Mittel in Statistik, Tarif und Follow the Peak. - core/peak/meter.go: die Lücke zählt am Limit, wie die README es für den Fensteranfang beschreibt; ein solches Fenster wird nicht als Completed geführt. Mit demselben Zähler vor und nach der Lücke bleibt die Zählerdifferenz maßgeblich. - Tests: TestMeterUnmetered erweitert (Netzleistung, Quellwechsel, Zähler). Kein Hook. README Abschnitt Peak Shaving. Co-Authored-By: Claude Sonnet 5.5 --- core/lm/README.md | 5 ++++- core/peak/meter.go | 39 ++++++++++++++++++++++++++------------- core/peak/meter_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 14 deletions(-) diff --git a/core/lm/README.md b/core/lm/README.md index 885f814aa2..0cc52aabdf 100644 --- a/core/lm/README.md +++ b/core/lm/README.md @@ -270,7 +270,10 @@ 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 reading, a 0 diff --git a/core/peak/meter.go b/core/peak/meter.go index 329cadf82f..e78685c11b 100644 --- a/core/peak/meter.go +++ b/core/peak/meter.go @@ -69,6 +69,7 @@ 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 @@ -87,16 +88,26 @@ func (m *Meter) rebase(source string, energy float64) { // 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. A reading the counter cannot have reached, or one lower than -// the last, does not count until it is confirmed: the grid power fills in. -func (m *Meter) drawn(now time.Time, imported, energy float64, source string) float64 { +// 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 + // what counts where the counter does not tell + fallback := func() float64 { + if interval > MaxGap { + m.gapped = true + return limit * interval.Seconds() + } + return powerWs + } + if source == SourcePower || source != m.source || m.stale { m.rebase(source, energy) - return powerWs + return fallback() } // grid power counted for a lower reading that turned out to be a glitch @@ -107,8 +118,9 @@ func (m *Meter) drawn(now time.Time, imported, energy float64, source string) fl switch { case energy < m.pendingEnergy: // still falling: keep waiting - m.pendingEnergy, m.pendingWs = energy, m.pendingWs+powerWs - return powerWs + 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 @@ -125,15 +137,16 @@ func (m *Meter) drawn(now time.Time, imported, energy float64, source string) fl d := (energy - m.lastEnergy) * 3600e3 if d < 0 { - m.pending, m.pendingEnergy, m.pendingWs = true, energy, powerWs + fb := fallback() + m.pending, m.pendingEnergy, m.pendingWs = true, energy, fb m.glitch = true - return powerWs + 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 := powerWs + m.unmovedWs + drawn := fallback() + m.unmovedWs m.unmovedWs, m.unmovedSince = 0, time.Time{} m.rebase(source, energy) m.glitch = true @@ -188,7 +201,7 @@ 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) + drawn = m.drawn(now, imported, s.Energy, source, set.Limit) demand = max(0, drawn+s.BatteryPower*now.Sub(m.lastSample).Seconds()) res.StaleNow = m.stale && !wasStale res.GlitchNow = m.glitch @@ -202,8 +215,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)} } @@ -216,7 +229,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 diff --git a/core/peak/meter_test.go b/core/peak/meter_test.go index aca4ea2e15..eab80dd535 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 From cf11fe1dc96082beb10080f8d67542be1291b58e Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Sun, 11 Oct 2026 21:52:12 +0200 Subject: [PATCH 3/3] =?UTF-8?q?Peak:=20Bedarf=20ohne=20Akku=20z=C3=A4hlt?= =?UTF-8?q?=20Netzeinspeisung=20nicht=20mit=20(Review=20B17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entlud der Akku, während das Netz einspeiste (z. B. Netzentladung), zählte die volle Entladeleistung als Bedarf ohne Akku, obwohl der Haushalt davon nichts bezogen hätte. Betroffen war nur die Statistik (Monats-Demand, Kosten ohne Akku, Ersparnis im Leistungstarif), nicht die Regelung. - core/peak/meter.go: demand = max(0, drawn + (Akkuleistung + min(0, Netzleistung)) * dt). Mit Zählerquelle eine Näherung, der Importzähler kennt die Einspeisung nicht. - Test: TestMeterCompleted um Fenster mit Einspeisung und Entladung erweitert. Kein Hook. README Abschnitt Peak Shaving. Co-Authored-By: Claude Sonnet 5.5 --- core/lm/README.md | 4 +++- core/peak/meter.go | 7 +++++-- core/peak/meter_test.go | 27 +++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/core/lm/README.md b/core/lm/README.md index 0cc52aabdf..7bbbd9bfcd 100644 --- a/core/lm/README.md +++ b/core/lm/README.md @@ -311,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 e78685c11b..88b2e168df 100644 --- a/core/peak/meter.go +++ b/core/peak/meter.go @@ -185,7 +185,8 @@ func (m *Meter) drawn(now time.Time, imported, energy float64, source string, li // 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 @@ -202,7 +203,9 @@ func (m *Meter) Update(s Sample, set Settings) State { if !m.lastSample.IsZero() { wasStale := m.stale drawn = m.drawn(now, imported, s.Energy, source, set.Limit) - demand = max(0, drawn+s.BatteryPower*now.Sub(m.lastSample).Seconds()) + // 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 { diff --git a/core/peak/meter_test.go b/core/peak/meter_test.go index eab80dd535..e9fa0bd54e 100644 --- a/core/peak/meter_test.go +++ b/core/peak/meter_test.go @@ -230,6 +230,33 @@ 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