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
11 changes: 11 additions & 0 deletions .changeset/ev-counter-time-and-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"ftw": patch
---

Keep charger measurement times when estimating EV energy. Use fresh power between delayed session-counter updates, reconcile overlapping energy once, and retain the estimate through a verified session restart. Missing or older counters no longer reset a confirmed battery level. Expose the estimate source and measurement ages.

Match Easee pauses to the current vendor session even when sessionEnd is populated. Bound power estimates to its reporting cadence. Replan when a restored EV level differs from the active plan.

Show when a stopped charge has reached its target, and distinguish an estimated battery level from one reported by the car.

Pause dispatch when charger power is unavailable and retain spent pulse energy across recovery. Compare EV progress with the allowed duty curve. Bound progress checkpoints to 30 seconds or 30 Wh, with immediate saves for stops and user corrections.
2 changes: 1 addition & 1 deletion drivers/BUNDLED_SOURCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"for coverage. Run scripts/sync-bundled-drivers.sh to update."
],
"repository": "srcfl/device-drivers",
"commit": "d560ca6d7df57a374c9998e1e90329857e3d15c3",
"commit": "d44fda113f171b5824ecbe178073731dfcf1c031",
"source_dir": "drivers/lua",
"drivers": [
"ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud",
Expand Down
38 changes: 28 additions & 10 deletions go/cmd/ftw/ev_observation.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,17 @@ func currentEVSample(r *telemetry.DerReading, health *telemetry.DriverHealth, wa
return loadpoint.EVSample{}, false
}
var d struct {
ConnectionGeneration uint64 `json:"connection_generation"`
ConnectionUnknown bool `json:"connection_unknown"`
Connected *bool `json:"connected"`
SessionWh float64 `json:"session_wh"`
RequestActive *bool `json:"request_active"`
SessionID string `json:"session_id"`
}
if json.Unmarshal(r.Data, &d) != nil || d.SessionWh < 0 {
ConnectionGeneration uint64 `json:"connection_generation"`
ConnectionUnknown bool `json:"connection_unknown"`
Connected *bool `json:"connected"`
SessionWh *float64 `json:"session_wh"`
RequestActive *bool `json:"request_active"`
SessionID string `json:"session_id"`
PowerAt string `json:"power_observed_at"`
PowerMaxAgeS int `json:"power_max_age_s"`
EnergyAt string `json:"energy_observed_at"`
}
if json.Unmarshal(r.Data, &d) != nil || (d.SessionWh != nil && *d.SessionWh < 0) {
return loadpoint.EVSample{}, false
}
if d.ConnectionUnknown {
Expand All @@ -40,8 +43,23 @@ func currentEVSample(r *telemetry.DerReading, health *telemetry.DriverHealth, wa
if d.RequestActive != nil {
active = *d.RequestActive
}
return loadpoint.EVSample{ConnectionGeneration: d.ConnectionGeneration, PowerW: r.SmoothedW, SessionWh: d.SessionWh,
Connected: *d.Connected, RequestActive: active, DeviceID: deviceID, SessionID: d.SessionID}, true
sample := loadpoint.EVSample{ConnectionGeneration: d.ConnectionGeneration, PowerW: r.RawW,
PowerAt: r.UpdatedAt, PowerMaxAge: time.Duration(min(max(d.PowerMaxAgeS, 0), 180)) * time.Second, Connected: *d.Connected, RequestActive: active, DeviceID: deviceID, SessionID: d.SessionID,
SessionWhUnavailable: d.SessionWh == nil}
if d.SessionWh != nil {
sample.SessionWh = *d.SessionWh
}
if d.PowerAt != "" {
at, err := time.Parse(time.RFC3339Nano, d.PowerAt)
sample.PowerAt = at
sample.PowerUnavailable = err != nil || at.After(now.Add(time.Second)) || (now.Sub(at) > sample.PowerWindow() && r.RawW > 0)
}
if d.EnergyAt != "" {
at, err := time.Parse(time.RFC3339Nano, d.EnergyAt)
sample.EnergyAt = at
sample.SessionWhUnavailable = sample.SessionWhUnavailable || err != nil || at.After(now.Add(time.Second))
}
return sample, true
}

// OCPP has no driver registry entry. Only the current adopted charger's
Expand Down
36 changes: 36 additions & 0 deletions go/cmd/ftw/ev_observation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,39 @@ func TestEVObservationPreservesSessionWhenCloudIsStale(t *testing.T) {
t.Fatalf("fresh OCPP unplug lost: %+v %v", s, ok)
}
}

func TestEVObservationKeepsSourceTimesAndMissingCounter(t *testing.T) {
now := time.Now().Truncate(time.Second)
health := &telemetry.DriverHealth{Status: telemetry.StatusOk}
data, _ := json.Marshal(map[string]any{"connected": true, "session_wh": 1000, "session_id": "same", "power_observed_at": now.Format(time.RFC3339Nano), "energy_observed_at": now.Add(-13 * time.Minute).Format(time.RFC3339Nano)})
r := &telemetry.DerReading{UpdatedAt: now, RawW: 6900, SmoothedW: 4200, Data: data}
s, ok := currentEVSample(r, health, time.Minute, now, false, "charger")
if !ok || s.PowerW != 6900 || !s.PowerAt.Equal(now) || !s.EnergyAt.Equal(now.Add(-13*time.Minute)) || s.PowerUnavailable || s.SessionWhUnavailable {
t.Fatalf("times or raw power lost: %+v", s)
}
r.Data = json.RawMessage(`{"connected":true,"session_id":"same"}`)
s, ok = currentEVSample(r, health, time.Minute, now, false, "charger")
if !ok || !s.Connected || !s.SessionWhUnavailable {
t.Fatalf("missing counter became zero or unplug: %+v", s)
}
r.Data = data
s, ok = currentEVSample(r, health, time.Minute, now.Add(40*time.Second), false, "charger")
if !ok || !s.PowerUnavailable {
t.Fatalf("old vendor power became fresh on receipt: %+v", s)
}
}

func TestEVSourceCadenceDoesNotExtendTransportWatchdog(t *testing.T) {
now := time.Now().Truncate(time.Second)
data, _ := json.Marshal(map[string]any{"connected": true, "session_wh": 1000, "power_observed_at": now.Add(-2 * time.Minute).Format(time.RFC3339), "power_max_age_s": 180})
r := &telemetry.DerReading{UpdatedAt: now, RawW: 6900, Data: data}
health := &telemetry.DriverHealth{Status: telemetry.StatusOk}
sample, ok := currentEVSample(r, health, time.Minute, now, false, "charger")
if !ok || sample.PowerUnavailable || sample.PowerMaxAge != 3*time.Minute {
t.Fatalf("source cadence rejected: %+v", sample)
}
r.UpdatedAt = now.Add(-2 * time.Minute)
if _, ok := currentEVSample(r, health, time.Minute, now, false, "charger"); ok {
t.Fatal("source cadence bypassed transport watchdog")
}
}
3 changes: 2 additions & 1 deletion go/internal/api/api_loadpoint_manual_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,8 @@ func TestLoadpointsCarryManualStatus(t *testing.T) {
t.Fatalf("after a stall: %+v", m)
}

// Power flows.
// Power flows and reaches the loadpoint on the next controller observation.
mgr.Observe("garage", true, 10800, 0, true)
tel.Update("easee", telemetry.DerEV, 10800, nil, json.RawMessage(`{"max_a":16,"charging":true}`))
if m = manual(); m.State != loadpoint.ManualCharging {
t.Fatalf("while charging: %+v", m)
Expand Down
21 changes: 15 additions & 6 deletions go/internal/loadpoint/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,11 @@ type EVSample struct {
ConnectionGeneration uint64 // process-local transport epoch, not durable session proof
PowerW float64
SessionWh float64
SessionWhUnavailable bool
PowerUnavailable bool
PowerAt time.Time
PowerMaxAge time.Duration
EnergyAt time.Time
Connected bool
RequestActive bool
DeviceID string
Expand Down Expand Up @@ -1560,9 +1565,9 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d
enteringSurplusPaused, _ := c.getSurplusPause(lpCfg.ID)
selfWithheld := surplusOn && enteringSurplusPaused
c.manager.SetSurplusWithheld(lpCfg.ID, selfWithheld)
c.manager.ObserveSession(lpCfg.ID, sample.Connected, sample.PowerW, sample.SessionWh, sample.RequestActive, sample.DeviceID, sample.SessionID)
c.manager.ObserveSample(lpCfg.ID, sample)
c.restoreManualHoldForSession(lpCfg.ID)
c.evaluateBatteryBoost(lpCfg.ID, now, sample.Connected, dispatchAllowed)
c.evaluateBatteryBoost(lpCfg.ID, now, sample.Connected, dispatchAllowed && !sample.PowerUnavailable)
if !sample.Connected {
delete(c.resumeOffers, lpCfg.ID)
c.resetSurplusSession(lpCfg.ID)
Expand All @@ -1583,14 +1588,14 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d
// outcome here; driverActuationTracker.update owns the timed retry.
return
}
if !dispatchAllowed {
if !dispatchAllowed || sample.PowerUnavailable {
// The observation above is deliberately retained: dashboards, SoC
// inference and plug/unplug state must stay live while the site-meter
// inference and plug/unplug state must stay live while a measurement
// safety gate is closed. Do not advance manual-hold completion timers
// or auto-wake state while we are the reason current is withheld; a
// persistent hold or schedule must resume normally after recovery.
// The outcome is deliberately not reported to dispatchOutcome: this
// is core withdrawing under a stale site meter, not core actuating,
// is core withdrawing under stale measurements, not core actuating,
// and the staleness tracker already owns that transition. A charger
// that refuses the standdown must not be excluded for it — the fault
// being handled is the meter's.
Expand All @@ -1600,7 +1605,11 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d
if hold, held := c.GetManualHold(lpCfg.ID, now); held {
manualUpdatedAt = hold.UpdatedAt
}
c.manager.setCommandedForManual(lpCfg.ID, 0, "site_meter_stale", manualUpdatedAt)
reason := "site_meter_stale"
if dispatchAllowed && sample.PowerUnavailable {
reason = "charger_power_stale"
}
c.manager.setCommandedForManual(lpCfg.ID, 0, reason, manualUpdatedAt)
payload, err := json.Marshal(map[string]any{
"action": "ev_set_current",
"power_w": 0,
Expand Down
61 changes: 36 additions & 25 deletions go/internal/loadpoint/controller_energy.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,53 +13,64 @@ type energyPoint struct {
type meteredEnergy struct {
driver, device, session string
generation uint64
last EVSample
powerWh, counterWh float64
meter sessionEnergy
points []energyPoint
}

// observeEnergy uses the charger counter when available, otherwise integrates
// successive fresh power readings. It never applies today's power to an entire
// elapsed slot. A transport/session change or an unmeasured gap breaks coverage.
// elapsed slot. A transport/session change resets delivery. Measurement gaps
// add no assumed charge and cannot erase energy already spent in the slot.
func (c *Controller) observeEnergy(cfg Config, sample EVSample, now time.Time) {
if c.energySamples == nil {
c.energySamples = make(map[string]*meteredEnergy)
}
if !sample.Connected || sample.ConnectionUnknown || math.IsNaN(sample.PowerW) || math.IsInf(sample.PowerW, 0) {
if !sample.Connected || sample.ConnectionUnknown {
delete(c.energySamples, cfg.ID)
return
}
if sample.PowerUnavailable || math.IsNaN(sample.PowerW) || math.IsInf(sample.PowerW, 0) {
// Dispatch pauses without measurements. Retain already spent energy so
// recovery cannot repeat the same pulse while its counter is delayed.
return
}
e := c.energySamples[cfg.ID]
if e == nil || e.driver != cfg.DriverName || e.device != sample.DeviceID || e.session != sample.SessionID || e.generation != sample.ConnectionGeneration {
if e == nil || e.driver != cfg.DriverName || e.device != sample.DeviceID || e.session != sample.SessionID || e.generation != sample.ConnectionGeneration || e.meter.counterRegressed(sample) {
e = &meteredEnergy{driver: cfg.DriverName, device: sample.DeviceID, session: sample.SessionID, generation: sample.ConnectionGeneration}
c.energySamples[cfg.ID] = e
}
if len(e.points) == 0 {
e.points = []energyPoint{{at: now}}
e.last = sample
return
measuredAt := now
if !sample.PowerAt.IsZero() {
measuredAt = sample.PowerAt
if !sample.SessionWhUnavailable && sample.EnergyAt.After(measuredAt) {
measuredAt = sample.EnergyAt
}
}
if sample.PowerMaxAge > 0 && !sample.PowerUnavailable && now.Sub(sample.PowerAt) <= sample.PowerWindow() {
measuredAt = now
}
previous := e.points[len(e.points)-1]
if !now.After(previous.at) {
if measuredAt.After(now) {
return
}
elapsed := now.Sub(previous.at)
counterKnown := finite(sample.SessionWh) && finite(e.last.SessionWh) && sample.SessionWh >= e.last.SessionWh && (sample.SessionWh > 0 || e.last.SessionWh > 0)
if elapsed > 30*time.Second && !counterKnown {
e.points = nil
e.powerWh, e.counterWh = 0, 0
} else {
if elapsed <= 30*time.Second {
e.powerWh += max(0, e.last.PowerW) * elapsed.Hours()
if len(e.points) > 0 {
previous := e.points[len(e.points)-1]
if !measuredAt.After(previous.at) {
return
}
if counterKnown {
e.counterWh += sample.SessionWh - e.last.SessionWh
// sessionEnergy leaves a measurement gap unintegrated. Keep its known
// delivery before the gap rather than reopening an already spent budget.
}
counterWasKnown := e.meter.counterKnown
wh := e.meter.observe(sample, now)
if !counterWasKnown && e.meter.counterKnown {
// A first counter includes energy from before this slot. Align prior
// power points to its baseline before calculating slot delivery.
baseline := e.meter.counterWh - e.meter.integralAt(e.meter.counterAt)
for i := range e.points {
e.points[i].wh += baseline
}
}
// Counters can update less often than power. Stop conservatively on
// either measured signal; adding their deltas would count energy twice.
e.points = append(e.points, energyPoint{at: now, wh: max(e.powerWh, e.counterWh)})
e.last = sample
e.points = append(e.points, energyPoint{at: measuredAt, wh: wh})
// Keep one boundary reading for a two-hour slot, with a hard cap for
// callers ticking faster than production's five-second loop.
for len(e.points) > 2048 || (len(e.points) > 2 && e.points[1].at.Before(now.Add(-2*time.Hour))) {
Expand Down
56 changes: 56 additions & 0 deletions go/internal/loadpoint/controller_energy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,59 @@ func TestDutyPlanCommandsOnPowerThenStopsAtEnergyBudget(t *testing.T) {
t.Fatalf("spent budget still commands %.0f", got)
}
}

func TestDutyDispatchStopsForUnavailablePowerAndKeepsSpentBudget(t *testing.T) {
start := time.Now().Truncate(time.Minute)
cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 11000}
dir := &Directive{SlotStart: start, SlotEnd: start.Add(15 * time.Minute), LoadpointEnergyWh: map[string]float64{"garage": 100}, LoadpointMaxPowerW: map[string]float64{"garage": 11000}}
samples := map[string]EVSample{"easee": {Connected: true, RequestActive: true, SessionWh: 1000, EnergyAt: start, PowerAt: start}}
sender := &fakeSender{}
c := newTestController(t, []Config{cfg}, dir, samples, sender)
c.Tick(context.Background(), start)
// A fresh counter proves that the whole pulse has been delivered.
s := samples["easee"]
s.SessionWh, s.EnergyAt, s.PowerAt = 1100, start.Add(35*time.Second), start.Add(35*time.Second)
samples["easee"] = s
c.Tick(context.Background(), s.PowerAt)
for sec := 40; sec <= 600; sec += 5 {
s.PowerUnavailable = true
samples["easee"] = s
c.Tick(context.Background(), start.Add(time.Duration(sec)*time.Second))
cmd, ok := lastSetCurrent(sender.calls)
if !ok || cmd.power != 0 {
t.Fatalf("missing power resumed a spent pulse at %ds: %+v", sec, cmd)
}
}
s.PowerUnavailable, s.PowerAt = false, start.Add(605*time.Second)
samples["easee"] = s
c.Tick(context.Background(), s.PowerAt)
if cmd, _ := lastSetCurrent(sender.calls); cmd.power != 0 {
t.Fatalf("recovery forgot the spent budget: %+v", cmd)
}
}

func TestUnavailablePowerPausesAndRetainsManualCharge(t *testing.T) {
start := time.Now().Truncate(time.Minute)
cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 11000}
samples := map[string]EVSample{"easee": {Connected: true, RequestActive: true, DeviceID: "easee:A", SessionID: "session-1", SessionWh: 1000}}
sender := &fakeSender{}
c := newTestController(t, []Config{cfg}, nil, samples, sender)
c.Tick(context.Background(), start)
c.SetManualHold(cfg.ID, ManualHold{PowerW: 11000, Persistent: true})
s := samples["easee"]
s.PowerUnavailable = true
samples["easee"] = s
c.Tick(context.Background(), start.Add(5*time.Minute))
if cmd, _ := lastSetCurrent(sender.calls); cmd.power != 0 {
t.Fatalf("unavailable power did not pause manual charge: %+v", cmd)
}
if _, held := c.GetManualHold(cfg.ID, start.Add(5*time.Minute)); !held {
t.Fatal("stale measurements erased the manual request")
}
s.PowerUnavailable = false
samples["easee"] = s
c.Tick(context.Background(), start.Add(5*time.Minute+5*time.Second))
if cmd, _ := lastSetCurrent(sender.calls); cmd.power <= 0 {
t.Fatalf("recovered measurements did not resume the request: %+v", cmd)
}
}
24 changes: 24 additions & 0 deletions go/internal/loadpoint/loadpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ type State struct {
// ChargingDeclined is a sustained vehicle-side refusal, not a battery level.
ChargingDeclined bool `json:"charging_declined"`
// SoCRetention reports whether the confirmed estimate can survive restart.
EnergySource string `json:"energy_source,omitempty"`
EnergyUpdatedAtMs int64 `json:"energy_updated_at_ms,omitempty"`
PowerUpdatedAtMs int64 `json:"power_updated_at_ms,omitempty"`
PowerUnavailable bool `json:"power_unavailable,omitempty"`
SoCRetention string `json:"soc_retention,omitempty"`
ID string `json:"id"`
DriverName string `json:"driver_name"`
Expand Down Expand Up @@ -335,6 +339,11 @@ type loadpointRuntime struct {
connectionGeneration uint64
manualRestoreUnconfirmed bool
manualSaveError bool
energy *sessionEnergy
powerAt time.Time
powerUnavailable bool
lastSavedEnergyWh float64
lastSavedEnergyAt time.Time
sessionDeviceID string
sessionID string
socRetention string
Expand Down Expand Up @@ -541,6 +550,11 @@ func (m *Manager) Load(cfgs []Config) {
lp.currentSoC = existing.currentSoC
lp.currentPowerW = existing.currentPowerW
lp.deliveredWhSession = existing.deliveredWhSession
lp.energy = existing.energy
lp.powerAt = existing.powerAt
lp.powerUnavailable = existing.powerUnavailable
lp.lastSavedEnergyWh = existing.lastSavedEnergyWh
lp.lastSavedEnergyAt = existing.lastSavedEnergyAt
lp.targetSoC = existing.targetSoC
lp.targetTime = existing.targetTime
lp.updatedAtMs = existing.updatedAtMs
Expand Down Expand Up @@ -1138,6 +1152,16 @@ func (lp *loadpointRuntime) snapshot() State {
st.VehicleCapacityWh = 60000
st.CapacitySource = "default"
}
if lp.energy != nil {
st.EnergySource = lp.energy.source
if !lp.energy.counterAt.IsZero() {
st.EnergyUpdatedAtMs = lp.energy.counterAt.UnixMilli()
}
}
st.PowerUnavailable = lp.powerUnavailable
if !lp.powerAt.IsZero() {
st.PowerUpdatedAtMs = lp.powerAt.UnixMilli()
}
if st.PluggedIn && st.SoCSource == "" && !lp.socConfirmed {
st.SoCSource = "assumed"
}
Expand Down
Loading