api: split MeterEnergy into MeterImport and MeterExport (#29788)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
andig 2026-05-09 16:20:18 +02:00 • committed by GitHub
parent 37e5659936
commit 7a5b1e7560
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
128 changed files with 570 additions and 435 deletions

View file

@ -12,7 +12,7 @@ import (
)
// ChargeRater is responsible for providing charged energy amount
// by implementing api.ChargeRater. It uses the charge meter's TotalEnergy or
// by implementing api.ChargeRater. It uses the charge meter's ImportEnergy or
// keeps track of consumed energy by regularly updating consumed power.
type ChargeRater struct {
sync.Mutex
@ -39,19 +39,19 @@ func NewChargeRater(log *util.Logger, meter api.Meter) *ChargeRater {
}
}
// StartCharge records meter start energy. If meter does not supply TotalEnergy,
// StartCharge records meter start energy. If meter does not supply ImportEnergy,
// start time is recorded and charged energy set to zero.
func (cr *ChargeRater) StartCharge(continued bool) {
cr.Lock()
defer cr.Unlock()
// time is needed if MeterEnergy is not supported
// time is needed if MeterImport 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 {
if m, ok := api.Cap[api.MeterImport](cr.meter); ok {
if f, err := m.ImportEnergy(); err == nil {
cr.startEnergy = &f
cr.log.DEBUG.Printf("charge start energy: %.3fkWh", f)
} else if !loadpoint.AcceptableError(err) {
@ -66,7 +66,7 @@ func (cr *ChargeRater) StartCharge(continued bool) {
}
}
// StopCharge records meter stop energy. If meter does not supply TotalEnergy,
// StopCharge records meter stop energy. If meter does not supply ImportEnergy,
// stop time is recorded and accumulating energy though SetChargePower stopped.
func (cr *ChargeRater) StopCharge() {
cr.Lock()
@ -75,8 +75,8 @@ func (cr *ChargeRater) StopCharge() {
cr.charging = false
// get end energy amount
if m, ok := api.Cap[api.MeterEnergy](cr.meter); ok {
if f, err := m.TotalEnergy(); err == nil {
if m, ok := api.Cap[api.MeterImport](cr.meter); ok {
if f, err := m.ImportEnergy(); err == nil {
if cr.startEnergy != nil {
cr.chargedEnergy += f - *cr.startEnergy
}
@ -95,8 +95,8 @@ func (cr *ChargeRater) ResetCharge() {
defer cr.Unlock()
// get end energy amount
if m, ok := api.Cap[api.MeterEnergy](cr.meter); ok {
if f, err := m.TotalEnergy(); err == nil {
if m, ok := api.Cap[api.MeterImport](cr.meter); ok {
if f, err := m.ImportEnergy(); err == nil {
if cr.startEnergy != nil {
cr.chargedEnergy += f - *cr.startEnergy
cr.log.DEBUG.Printf("charge final energy: %.3fkWh", cr.chargedEnergy)
@ -122,7 +122,7 @@ func (cr *ChargeRater) SetChargePower(power float64) {
}
// update energy amount if not provided by meter
if !api.HasCap[api.MeterEnergy](cr.meter) {
if !api.HasCap[api.MeterImport](cr.meter) {
// convert power to energy in kWh
cr.chargedEnergy += power / 1e3 * float64(cr.clck.Since(cr.start)) / float64(time.Hour)
// move timestamp
@ -142,13 +142,13 @@ 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 m, ok := api.Cap[api.MeterImport](cr.meter); ok {
f, err := m.ImportEnergy()
if err != nil {
return 0, fmt.Errorf("charge total import: %v", err)
}
// late-latch baseline if StartCharge could not read TotalEnergy
// late-latch baseline if StartCharge could not read ImportEnergy
// (e.g. OCPP transaction recovery before first MeterValues frame)
if cr.startEnergy == nil {
cr.startEnergy = &f

View file

@ -62,20 +62,20 @@ func TestWrappedMeter(t *testing.T) {
defer ctrl.Finish()
mm := api.NewMockMeter(ctrl)
me := api.NewMockMeterEnergy(ctrl)
me := api.NewMockMeterImport(ctrl)
type EnergyDecorator struct {
api.Meter
api.MeterEnergy
api.MeterImport
}
cm := &EnergyDecorator{Meter: mm, MeterEnergy: me}
cm := &EnergyDecorator{Meter: mm, MeterImport: me}
cr := NewChargeRater(util.NewLogger("foo"), cm)
clck := clock.NewMock()
cr.clck = clck
me.EXPECT().TotalEnergy().Return(2.0, nil)
me.EXPECT().ImportEnergy().Return(2.0, nil)
cr.StartCharge(false)
@ -85,7 +85,7 @@ func TestWrappedMeter(t *testing.T) {
clck.Add(time.Hour)
cr.SetChargePower(0)
me.EXPECT().TotalEnergy().Return(3.0, nil)
me.EXPECT().ImportEnergy().Return(3.0, nil)
cr.StopCharge()
@ -98,12 +98,12 @@ func TestWrappedMeter(t *testing.T) {
cr.SetChargePower(1e3)
// continue
me.EXPECT().TotalEnergy().Return(10.0, nil)
me.EXPECT().ImportEnergy().Return(10.0, nil)
cr.StartCharge(true)
clck.Add(time.Hour) // actual timing ignored as energy comes from meter
me.EXPECT().TotalEnergy().Return(12.0, nil)
me.EXPECT().ImportEnergy().Return(12.0, nil)
cr.StopCharge()
@ -114,53 +114,53 @@ func TestWrappedMeter(t *testing.T) {
// 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
// the first successful ImportEnergy() 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)
me := api.NewMockMeterImport(ctrl)
type EnergyDecorator struct {
api.Meter
api.MeterEnergy
api.MeterImport
}
cm := &EnergyDecorator{Meter: mm, MeterEnergy: me}
cm := &EnergyDecorator{Meter: mm, MeterImport: 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"))
me.EXPECT().ImportEnergy().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"))
me.EXPECT().ImportEnergy().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)
me.EXPECT().ImportEnergy().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)
me.EXPECT().ImportEnergy().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)
me.EXPECT().ImportEnergy().Return(944.080, nil)
cr.StopCharge()