From 338e8b4a461f5e9b26d615d4921c3c23ae4938ad Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 5 May 2026 17:49:33 +0200 Subject: [PATCH] Session energy: latch baseline late if meter not ready at start (#29656) --- core/wrapper/chargerater.go | 30 ++++++++++++----- core/wrapper/chargerater_test.go | 58 ++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/core/wrapper/chargerater.go b/core/wrapper/chargerater.go index 349d58a01..939f234f9 100644 --- a/core/wrapper/chargerater.go +++ b/core/wrapper/chargerater.go @@ -21,7 +21,7 @@ type ChargeRater struct { meter api.Meter charging bool start time.Time - startEnergy float64 + startEnergy *float64 // nil until baseline successfully read from meter chargedEnergy float64 } @@ -47,11 +47,12 @@ func (cr *ChargeRater) StartCharge(continued bool) { // time is needed if MeterEnergy is not supported cr.start = cr.clck.Now() + cr.startEnergy = nil // get end energy amount if m, ok := api.Cap[api.MeterEnergy](cr.meter); ok { if f, err := m.TotalEnergy(); err == nil { - cr.startEnergy = f + cr.startEnergy = &f cr.log.DEBUG.Printf("charge start energy: %.3fkWh", f) } else if !loadpoint.AcceptableError(err) { cr.log.ERROR.Printf("charge total import: %v", err) @@ -76,7 +77,9 @@ func (cr *ChargeRater) StopCharge() { // get end energy amount if m, ok := api.Cap[api.MeterEnergy](cr.meter); ok { if f, err := m.TotalEnergy(); err == nil { - cr.chargedEnergy += f - cr.startEnergy + if cr.startEnergy != nil { + cr.chargedEnergy += f - *cr.startEnergy + } cr.log.DEBUG.Printf("charge final energy: %.3fkWh", cr.chargedEnergy) } else if !loadpoint.AcceptableError(err) { cr.log.ERROR.Printf("charge total import: %v", err) @@ -94,10 +97,12 @@ func (cr *ChargeRater) ResetCharge() { // get end energy amount if m, ok := api.Cap[api.MeterEnergy](cr.meter); ok { if f, err := m.TotalEnergy(); err == nil { - cr.chargedEnergy += f - cr.startEnergy - cr.log.DEBUG.Printf("charge final energy: %.3fkWh", cr.chargedEnergy) + if cr.startEnergy != nil { + cr.chargedEnergy += f - *cr.startEnergy + cr.log.DEBUG.Printf("charge final energy: %.3fkWh", cr.chargedEnergy) + } - cr.startEnergy = f + cr.startEnergy = &f } else if !loadpoint.AcceptableError(err) { cr.log.ERROR.Printf("charge total import: %v", err) } @@ -139,11 +144,18 @@ func (cr *ChargeRater) ChargedEnergy() (float64, error) { // get current energy amount if m, ok := api.Cap[api.MeterEnergy](cr.meter); ok { f, err := m.TotalEnergy() - if err == nil { - return cr.chargedEnergy + f - cr.startEnergy, nil + if err != nil { + return 0, fmt.Errorf("charge total import: %v", err) } - return 0, fmt.Errorf("charge total import: %v", err) + // late-latch baseline if StartCharge could not read TotalEnergy + // (e.g. OCPP transaction recovery before first MeterValues frame) + if cr.startEnergy == nil { + cr.startEnergy = &f + cr.log.DEBUG.Printf("charge start energy: %.3fkWh", f) + } + + return cr.chargedEnergy + f - *cr.startEnergy, nil } // return charged energy sofar if meter is not used diff --git a/core/wrapper/chargerater_test.go b/core/wrapper/chargerater_test.go index 8af799d1a..9bc6c7628 100644 --- a/core/wrapper/chargerater_test.go +++ b/core/wrapper/chargerater_test.go @@ -1,6 +1,7 @@ package wrapper import ( + "errors" "testing" "time" @@ -110,3 +111,60 @@ func TestWrappedMeter(t *testing.T) { t.Errorf("energy: %.1f %v", f, err) } } + +// TestDeferredBaseline covers the OCPP transaction-recovery case: the meter is +// not yet readable when StartCharge fires, so the baseline must be latched on +// the first successful TotalEnergy() read instead of defaulting to zero +// (which would cause the lifetime register to be reported as session energy). +func TestDeferredBaseline(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mm := api.NewMockMeter(ctrl) + me := api.NewMockMeterEnergy(ctrl) + + type EnergyDecorator struct { + api.Meter + api.MeterEnergy + } + + cm := &EnergyDecorator{Meter: mm, MeterEnergy: me} + + cr := NewChargeRater(util.NewLogger("foo"), cm) + clck := clock.NewMock() + cr.clck = clck + + // meter not yet available at StartCharge — recovered transaction before first MeterValues + me.EXPECT().TotalEnergy().Return(0.0, errors.New("not available")) + + cr.StartCharge(true) + + // first read also fails — must surface the error, not a bogus delta + me.EXPECT().TotalEnergy().Return(0.0, errors.New("not available")) + + if _, err := cr.ChargedEnergy(); err == nil { + t.Errorf("expected error while meter unavailable") + } + + // first successful read latches the baseline (lifetime register, e.g. 939 kWh) + me.EXPECT().TotalEnergy().Return(939.080, nil) + + if f, err := cr.ChargedEnergy(); f != 0 || err != nil { + t.Errorf("expected 0 on baseline-latch read, got %.3f %v", f, err) + } + + // subsequent reads return delta against the latched baseline + me.EXPECT().TotalEnergy().Return(942.080, nil) + + if f, err := cr.ChargedEnergy(); f != 3 || err != nil { + t.Errorf("expected 3kWh delta, got %.3f %v", f, err) + } + + me.EXPECT().TotalEnergy().Return(944.080, nil) + + cr.StopCharge() + + if f, err := cr.ChargedEnergy(); f != 5 || err != nil { + t.Errorf("final energy: %.1f %v", f, err) + } +}