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
7 changes: 7 additions & 0 deletions .changeset/ocpp-last-power.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"ftw": patch
---

OCPP charger power uses one accept rule for dispatch and forecast, so a
stale, per-phase, negative, or energy-only sample cannot publish a phantom EV load.
1.6 Available/unplug now zeros last power like 2.0.1.
105 changes: 97 additions & 8 deletions go/internal/ocpp/forecast_power_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package ocpp

import (
"fmt"
"math"
"strings"
"testing"
"time"
Expand All @@ -16,12 +17,14 @@ import (
)

type forecastOCPPFixture struct {
h *Handler
tel *telemetry.Store
power func(float64, time.Time)
energy func()
status func()
stop func()
h *Handler
tel *telemetry.Store
power func(float64, time.Time)
energy func()
status func()
available func()
stop func()
phases func(l1, l2, l3 float64, at time.Time)
}

func newForecastOCPPFixture(t *testing.T, version string) forecastOCPPFixture {
Expand All @@ -41,7 +44,17 @@ func newForecastOCPPFixture(t *testing.T, version string) forecastOCPPFixture {
f.status = func() {
h.OnStatusNotification("charger", &core.StatusNotificationRequest{ConnectorId: 1, Status: core.ChargePointStatusCharging})
}
f.available = func() {
h.OnStatusNotification("charger", &core.StatusNotificationRequest{ConnectorId: 1, Status: core.ChargePointStatusAvailable})
}
f.stop = func() { h.OnStopTransaction("charger", &core.StopTransactionRequest{}) }
f.phases = func(l1, l2, l3 float64, at time.Time) {
h.OnMeterValues("charger", &core.MeterValuesRequest{ConnectorId: 1, MeterValue: []types16.MeterValue{{Timestamp: types16.NewDateTime(at), SampledValue: []types16.SampledValue{
{Value: fmt.Sprint(l1), Measurand: types16.MeasurandPowerActiveImport, Phase: types16.PhaseL1},
{Value: fmt.Sprint(l2), Measurand: types16.MeasurandPowerActiveImport, Phase: types16.PhaseL2},
{Value: fmt.Sprint(l3), Measurand: types16.MeasurandPowerActiveImport, Phase: types16.PhaseL3},
}}}})
}
} else {
v := &handlerV201{h}
f.power = func(w float64, at time.Time) {
Expand All @@ -53,9 +66,19 @@ func newForecastOCPPFixture(t *testing.T, version string) forecastOCPPFixture {
f.status = func() {
v.OnStatusNotification("charger", &availability.StatusNotificationRequest{EvseID: 1, ConnectorID: 1, ConnectorStatus: availability.ConnectorStatusOccupied})
}
f.available = func() {
v.OnStatusNotification("charger", &availability.StatusNotificationRequest{EvseID: 1, ConnectorID: 1, ConnectorStatus: availability.ConnectorStatusAvailable})
}
f.stop = func() {
v.OnTransactionEvent("charger", &transactions.TransactionEventRequest{EventType: transactions.TransactionEventEnded})
}
f.phases = func(l1, l2, l3 float64, at time.Time) {
v.OnMeterValues("charger", &meter.MeterValuesRequest{EvseID: 1, MeterValue: []types201.MeterValue{{Timestamp: *types201.NewDateTime(at), SampledValue: []types201.SampledValue{
{Value: l1, Measurand: types201.MeasurandPowerActiveImport, Phase: types201.PhaseL1},
{Value: l2, Measurand: types201.MeasurandPowerActiveImport, Phase: types201.PhaseL2},
{Value: l3, Measurand: types201.MeasurandPowerActiveImport, Phase: types201.PhaseL3},
}}}})
}
}
return f
}
Expand Down Expand Up @@ -143,8 +166,8 @@ func TestForecastOCPPRejectsFutureAndOutOfOrderSamples(t *testing.T) {
if !r.Valid || r.EVW != 700 {
t.Fatalf("out-of-order power replaced accepted measurement: %+v", r)
}
if raw := f.tel.Get("charger", telemetry.DerEV); raw.RawW != 9000 {
t.Fatal("forecast qualification changed existing dispatch publication")
if raw := f.tel.Get("charger", telemetry.DerEV); raw == nil || raw.RawW != 700 {
t.Fatalf("out-of-order power replaced dispatch lastPowerW: %+v", raw)
}
})
}
Expand All @@ -157,8 +180,74 @@ func TestForecastOCPP201TransactionPowerAndMissingPower(t *testing.T) {
if f.reading().Valid {
t.Fatal("power-free transaction event became zero measurement")
}
at := time.Now()
f.power(700, at)
v.OnTransactionEvent("charger", &transactions.TransactionEventRequest{EventType: transactions.TransactionEventUpdated, MeterValue: []types201.MeterValue{{Timestamp: *types201.NewDateTime(at), SampledValue: []types201.SampledValue{
{Value: 16, Measurand: types201.MeasurandCurrentImport},
{Value: 1234, Measurand: types201.MeasurandEnergyActiveImportRegister},
}}}})
if raw := f.tel.Get("charger", telemetry.DerEV); raw == nil || raw.RawW != 700 {
t.Fatalf("energy-only transaction event wrote lastPowerW=%v", raw)
}
time.Sleep(2 * time.Millisecond)
v.OnTransactionEvent("charger", &transactions.TransactionEventRequest{EventType: transactions.TransactionEventUpdated, MeterValue: []types201.MeterValue{{Timestamp: *types201.NewDateTime(time.Now()), SampledValue: []types201.SampledValue{{Value: 0, Measurand: types201.MeasurandPowerActiveImport}}}}})
if r := f.reading(); !r.Valid || r.EVW != 0 {
t.Fatalf("real transaction zero rejected: %+v", r)
}
}

func TestOCPPLastPowerSharedAcceptRule(t *testing.T) {
for _, version := range []string{"1.6", "2.0.1"} {
t.Run(version, func(t *testing.T) {
f := newForecastOCPPFixture(t, version)
at := time.Now()
f.power(700, at)
f.energy()
if raw := f.tel.Get("charger", telemetry.DerEV); raw == nil || raw.RawW != 700 {
t.Fatalf("energy-only sample overwrote last power: %+v", raw)
}
f.power(-50, at.Add(time.Millisecond))
if raw := f.tel.Get("charger", telemetry.DerEV); raw == nil || raw.RawW != 700 {
t.Fatalf("negative import published: %+v", raw)
}
f.phases(300, 250, 150, at)
if raw := f.tel.Get("charger", telemetry.DerEV); raw == nil || raw.RawW != 700 {
t.Fatalf("phased samples overwrote accepted total: %+v", raw)
}
f.available()
if raw := f.tel.Get("charger", telemetry.DerEV); raw == nil || raw.RawW != 0 {
t.Fatalf("Available left lastPowerW set: %+v", raw)
}
if r := f.reading(); r.Valid {
t.Fatalf("Available published a measured zero: %+v", r)
}

f2 := newForecastOCPPFixture(t, version)
f2.phases(300, 250, 150, time.Now())
if raw := f2.tel.Get("charger", telemetry.DerEV); raw == nil || raw.RawW != 700 {
t.Fatalf("phase-only samples not summed: %+v", raw)
}

f3 := newForecastOCPPFixture(t, version)
f3.power(700, time.Now())
f3.h.OnDisconnect("charger")
if raw := f3.tel.Get("charger", telemetry.DerEV); raw == nil || raw.RawW != 0 {
t.Fatalf("disconnect left lastPowerW set: %+v", raw)
}
})
}
}

func TestMeterPowerWPrefersUnphasedThenSum(t *testing.T) {
w, ok := meterPowerW([]powerSample{{w: 700}, {w: 300, phase: "L1"}})
if !ok || w != 700 {
t.Fatalf("unphased+phase: got %v ok=%v", w, ok)
}
w, ok = meterPowerW([]powerSample{{w: 300, phase: "L1"}, {w: 250, phase: "L2"}, {w: 150, phase: "L3"}})
if !ok || w != 700 {
t.Fatalf("phase sum: got %v ok=%v", w, ok)
}
if _, ok := meterPowerW([]powerSample{{w: -1}, {w: math.NaN(), phase: "L1"}}); ok {
t.Fatal("negative/NaN samples accepted")
}
}
84 changes: 64 additions & 20 deletions go/internal/ocpp/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -389,8 +389,7 @@ func (h *Handler) OnConnect(id string) {
s.connectionGeneration++
s.connectedKnown = false
s.charging = false
s.lastPowerW = 0
s.forecastPower.Known = false
s.clearMeasuredPower()
s.identityCurrent = false
s.featureProfiles = ""
s.steerable = nil
Expand All @@ -416,8 +415,7 @@ func (h *Handler) OnDisconnect(id string) {
h.cancelIdentityProbeLocked(s)
s.connectedKnown = false
s.charging = false
s.lastPowerW = 0
s.forecastPower.Known = false
s.clearMeasuredPower()
h.mu.Unlock()
// Push a zero so the dispatch clamp releases — otherwise the last known
// non-zero w would survive until staleness kicks in.
Expand Down Expand Up @@ -488,6 +486,7 @@ func (h *Handler) OnStatusNotification(id string, req *core.StatusNotificationRe
case core.ChargePointStatusAvailable, core.ChargePointStatusUnavailable:
s.connected = false
s.charging = false
s.clearMeasuredPower()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve power when another connector becomes available

When an OCPP 1.6 charge point has multiple connectors, an Available, Unavailable, or Faulted notification for one idle connector now clears the charger-wide lastPowerW, even if another connector is still drawing power. Since req.ConnectorId is ignored and state is shared by charge-point ID, this publishes zero and removes the EV-discharge safety clamp; track and aggregate connector state, or clear power only when no connector remains active.

Useful? React with 👍 / 👎.

case core.ChargePointStatusPreparing,
core.ChargePointStatusFinishing,
core.ChargePointStatusSuspendedEV,
Expand All @@ -501,6 +500,7 @@ func (h *Handler) OnStatusNotification(id string, req *core.StatusNotificationRe
case core.ChargePointStatusFaulted:
s.connected = true
s.charging = false
s.clearMeasuredPower()
}
h.mu.Unlock()

Expand All @@ -521,6 +521,11 @@ func (h *Handler) OnMeterValues(id string, req *core.MeterValuesRequest) (*core.
h.mu.Lock()
received := time.Now()
for _, mv := range req.MeterValue {
measured := received
if mv.Timestamp != nil {
measured = mv.Timestamp.Time
}
var samples []powerSample
for _, sv := range mv.SampledValue {
measurand := sv.Measurand
// OCPP 1.6 default measurand if unspecified.
Expand All @@ -533,17 +538,13 @@ func (h *Handler) OnMeterValues(id string, req *core.MeterValuesRequest) (*core.
}
switch measurand {
case types.MeasurandPowerActiveImport:
if sv.Unit != "" && sv.Unit != types.UnitOfMeasureW && sv.Unit != types.UnitOfMeasureKW {
continue
}
if sv.Unit == types.UnitOfMeasureKW {
val *= 1000
}
s.lastPowerW = val
if sv.Phase == "" && (sv.Unit == "" || sv.Unit == types.UnitOfMeasureW || sv.Unit == types.UnitOfMeasureKW) {
measured := received
if mv.Timestamp != nil {
measured = mv.Timestamp.Time
}
s.recordForecastPower(val, measured, received)
}
samples = append(samples, powerSample{w: val, phase: string(sv.Phase)})
case types.MeasurandEnergyActiveImportRegister:
if sv.Unit == types.UnitOfMeasureKWh {
val *= 1000
Expand All @@ -553,6 +554,9 @@ func (h *Handler) OnMeterValues(id string, req *core.MeterValuesRequest) (*core.
}
}
}
if w, ok := meterPowerW(samples); ok {
s.recordPower(w, measured, received)
}
}
h.mu.Unlock()

Expand Down Expand Up @@ -593,8 +597,7 @@ func (h *Handler) OnStopTransaction(id string, req *core.StopTransactionRequest)
sessionWh := float64(req.MeterStop) - s.sessionStartMeterWh
s.transactionID = -1
s.charging = false
s.lastPowerW = 0
s.forecastPower.Known = false
s.clearMeasuredPower()
s.sessionMeterWh = sessionWh
h.mu.Unlock()

Expand Down Expand Up @@ -652,13 +655,54 @@ func (h *Handler) pushReading(id string, s *chargerState) {
h.tel.Update(id, telemetry.DerEV, w, nil, blob)
}

// recordForecastPower is separate from the existing status/dispatch power.
// Only a real aggregate power measurand may refresh it. Samples older than a
// connection or the accepted sample, and future/nonfinite values, cannot revive
// a stale or synthesized reading. The caller holds h.mu.
func (s *chargerState) recordForecastPower(w float64, measured, received time.Time) {
// powerSample is one Power.Active.Import after unit conversion. An empty phase
// is the charger total; anything else is one phase of that total.
type powerSample struct {
w float64
phase string
}

// meterPowerW picks one watts value for a MeterValue: the unphased total when
// present, otherwise the sum of finite, non-negative phase samples.
func meterPowerW(samples []powerSample) (float64, bool) {
var total, sum float64
hasTotal, hasPhase := false, false
for _, s := range samples {
if math.IsNaN(s.w) || math.IsInf(s.w, 0) || s.w < 0 {
continue
}
if s.phase == "" {
total = s.w
hasTotal = true
} else {
sum += s.w
hasPhase = true
}
}
if hasTotal {
return total, true
}
if hasPhase {
return sum, true
}
return 0, false
}

// recordPower is the shared accept rule for forecast and dispatch lastPowerW.
// Present, unphased-or-summed, finite, >= 0, and not older than the last
// accepted timestamp. Samples from before this socket or in the future cannot
// revive a stale or synthesized reading. The caller holds h.mu.
func (s *chargerState) recordPower(w float64, measured, received time.Time) bool {
if math.IsNaN(w) || math.IsInf(w, 0) || w < 0 || measured.IsZero() || measured.After(received) || measured.Before(s.powerConnectedAt) || measured.UnixMilli() <= s.forecastPower.MeasuredAtMS {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow bounded clock skew when accepting power

When the charger clock is even slightly ahead of the CSMS clock, every otherwise-current sample satisfies measured.After(received) and is rejected; a clock behind the CSMS can likewise fail the connection-time check for an extended period. Because recordPower is now the sole writer of lastPowerW, this also suppresses dispatch telemetry and can let the home battery discharge while the EV is charging. Use a quantified skew tolerance rather than comparing the two machines' wall clocks with zero tolerance.

AGENTS.md reference: AGENTS.md:L37-L37

Useful? React with 👍 / 👎.

return
return false
}
s.lastPowerW = w
s.forecastPower = telemetry.ForecastPowerSample{Version: 1, Known: true, Watts: w, MeasuredAtMS: measured.UnixMilli(), ReceivedAtMS: received.UnixMilli()}
return true
}

// clearMeasuredPower zeros dispatch watts without recording a measured sample.
func (s *chargerState) clearMeasuredPower() {
s.lastPowerW = 0
s.forecastPower.Known = false
}
Loading