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

Plan charging in periods at allowed current steps, using fresh charging state across replans. Favor runs of at least five minutes after departure energy and safety needs, allow a short final top-up, and avoid extra starts for tiny savings. Update the bundled Energyplan worker with joint EV cost decisions and faster tariff planning.
2 changes: 2 additions & 0 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1761,6 +1761,7 @@ func main() {
ctrlMu.Lock()
noBatteryToEV := !(ctrl.BatteryCoversEV || boostActive)
ctrlMu.Unlock()
charging, chargeDuration := lpMgr.ChargingPeriod(st.ID)
specs = append(specs, &mpc.LoadpointSpec{
ID: st.ID,
CapacityWh: capWh,
Expand All @@ -1777,6 +1778,7 @@ func main() {
ChargeEfficiency: loadpoint.DefaultChargeEfficiency,
SurplusOnly: loadpoint.PlannerTreatsLoadpointAsSurplusOnly(st.SurplusOnly, deferGridPlan),
NoBatteryToEV: noBatteryToEV,
Charging: mpc.DefaultChargingPeriods(charging, chargeDuration.Seconds()),
})
}
return specs
Expand Down
46 changes: 46 additions & 0 deletions go/internal/loadpoint/charging_period.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package loadpoint

import "time"

// ChargingPeriod returns fresh observed charging, with the duration of the
// current uninterrupted run. Commanded power is not evidence of charging.
func (m *Manager) ChargingPeriod(id string) (bool, time.Duration) {
m.mu.RLock()
defer m.mu.RUnlock()
lp, ok := m.byID[id]
if !ok {
return false, 0
}
now := m.now()
window := lp.powerWindow
if window <= 0 {
window = (EVSample{}).PowerWindow()
}
if !lp.pluggedIn || lp.powerUnavailable || lp.currentPowerW < steadyChargeFloorW ||
lp.powerAt.IsZero() || lp.powerAt.After(now.Add(time.Second)) || now.Sub(lp.powerAt) > window ||
lp.chargingPeriodSince.IsZero() || lp.chargingPeriodSince.After(now) {
return false, 0
}
return true, now.Sub(lp.chargingPeriodSince)
}

// An observation gap cannot prove uninterrupted charging. Keep this planning
// history separate from the manager's interruption notification hysteresis.
func observeChargingPeriod(lp *loadpointRuntime, sample EVSample, now time.Time) {
at := sample.PowerAt
if at.IsZero() {
at = now
}
window := lp.powerWindow
if window <= 0 {
window = (EVSample{}).PowerWindow()
}
if !sample.Connected || sample.ConnectionUnknown || sample.PowerUnavailable || !finite(sample.PowerW) || sample.PowerW < steadyChargeFloorW ||
at.After(now.Add(time.Second)) || now.Sub(at) > sample.PowerWindow() {
lp.chargingPeriodSince = time.Time{}
return
}
if lp.chargingPeriodSince.IsZero() || !lp.pluggedIn || at.Before(lp.powerAt) || at.Sub(lp.powerAt) > window {
lp.chargingPeriodSince = now
}
}
102 changes: 102 additions & 0 deletions go/internal/loadpoint/charging_period_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package loadpoint

import (
"testing"
"time"
)

func TestChargingPeriodUsesFreshObservedSession(t *testing.T) {
m := NewManager()
now := time.Unix(1700000000, 0)
m.SetNowFn(func() time.Time { return now })
cfg := []Config{{ID: "car", DriverName: "test", MaxChargeW: 11000}}
m.Load(cfg)
check := func(want bool, duration time.Duration) {
t.Helper()
got, elapsed := m.ChargingPeriod("car")
if got != want || elapsed != duration {
t.Fatalf("charging=%v elapsed=%s, want %v/%s", got, elapsed, want, duration)
}
}
check(false, 0)
m.Observe("car", true, 4140, 0, true)
now = now.Add(20 * time.Second)
m.Observe("car", true, 4140, 23, true)
check(true, 20*time.Second)
m.Load(cfg)
check(true, 20*time.Second)
now = now.Add(31 * time.Second)
check(false, 0)
m.Observe("car", true, 4140, 23, true)
check(true, 0) // the observation gap cannot prove continuous charging
m.Observe("car", true, 0, 23, true)
check(false, 0)
now = now.Add(time.Minute)
m.Observe("car", true, 4140, 23, true)
check(true, 0)
m.ObserveSample("car", EVSample{Connected: true, PowerUnavailable: true, PowerAt: now})
check(false, 0)
m.Observe("car", false, 0, 0, false)
check(false, 0)
m.Observe("car", true, 4140, 0, true)
check(true, 0)
}

func TestChargingPeriodHonorsSourceCadence(t *testing.T) {
m := NewManager()
now := time.Unix(1700000000, 0)
m.SetNowFn(func() time.Time { return now })
m.Load([]Config{{ID: "car"}})
m.ObserveSample("car", EVSample{Connected: true, PowerW: 4140, PowerAt: now, PowerMaxAge: 2 * time.Minute})
now = now.Add(time.Minute)
if on, _ := m.ChargingPeriod("car"); !on {
t.Fatal("fresh slow source lost")
}
now = now.Add(61 * time.Second)
if on, _ := m.ChargingPeriod("car"); on {
t.Fatal("stale source credited")
}
}

func TestChargingPeriodResetsOnLostConnectionProof(t *testing.T) {
for _, tc := range []struct {
name string
identified bool
generation uint64
unknown bool
}{
{"unknown identified connection", true, 1, true},
{"unknown unidentified connection", false, 1, true},
{"new connection", true, 2, false},
} {
t.Run(tc.name, func(t *testing.T) {
m := NewManager()
now := time.Unix(1700000000, 0)
m.SetNowFn(func() time.Time { return now })
m.Load([]Config{{ID: "car"}})
m.observeConnectionProof("car", 1, false)
m.Observe("car", true, 4140, 0, true)
if tc.identified {
m.byID["car"].sessionDeviceID = "charger"
m.byID["car"].sessionID = "session"
}
now = now.Add(10 * time.Second)
if on, elapsed := m.ChargingPeriod("car"); !on || elapsed != 10*time.Second {
t.Fatalf("before connection loss: charging=%v elapsed=%s", on, elapsed)
}
m.observeConnectionProof("car", tc.generation, tc.unknown)
if on, elapsed := m.ChargingPeriod("car"); on || elapsed != 0 {
t.Fatalf("cached power survived connection loss: charging=%v elapsed=%s", on, elapsed)
}
m.observeConnectionProof("car", tc.generation, false)
if on, _ := m.ChargingPeriod("car"); on {
t.Fatal("connection proof alone restored charging")
}
now = now.Add(time.Second)
m.Observe("car", true, 4140, 0, true)
if on, elapsed := m.ChargingPeriod("car"); !on || elapsed != 0 {
t.Fatalf("fresh observation retained pre-gap duration: charging=%v elapsed=%s", on, elapsed)
}
})
}
}
4 changes: 4 additions & 0 deletions go/internal/loadpoint/loadpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,8 @@ type loadpointRuntime struct {
manualSavePending bool
energy *sessionEnergy
powerAt time.Time
powerWindow time.Duration
chargingPeriodSince time.Time
powerUnavailable bool
lastSavedEnergyWh float64
lastSavedEnergyAt time.Time
Expand Down Expand Up @@ -568,6 +570,8 @@ func (m *Manager) Load(cfgs []Config) {
lp.deliveredWhSession = existing.deliveredWhSession
lp.energy = existing.energy
lp.powerAt = existing.powerAt
lp.powerWindow = existing.powerWindow
lp.chargingPeriodSince = existing.chargingPeriodSince
lp.powerUnavailable = existing.powerUnavailable
lp.lastSavedEnergyWh = existing.lastSavedEnergyWh
lp.lastSavedEnergyAt = existing.lastSavedEnergyAt
Expand Down
10 changes: 10 additions & 0 deletions go/internal/loadpoint/session_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@ func (m *Manager) ObserveSample(id string, sample EVSample) {
baseline := lp.energy.counterWh - lp.energy.integralAt(lp.energy.counterAt)
lp.sessionPluginSoC -= baseline * DefaultChargeEfficiency / lp.VehicleCapacityWh
}
if changed {
lp.chargingPeriodSince = time.Time{}
}
observeChargingPeriod(lp, sample, m.now())

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 Clear charging history when connection proof is lost

When Controller.tickOne receives ConnectionUnknown, it calls observeConnectionProof and returns before reaching this observation, but that path never clears chargingPeriodSince, powerAt, or the cached power. A replan during the remaining power-freshness window therefore reports InitialCharging=true, and a fresh sample after a brief reconnect can continue the pre-gap duration, even though the socket gap means uninterrupted charging was not observed. Clear the charging-period evidence when connection proof becomes unknown or changes.

AGENTS.md reference: AGENTS.md:L22-L23

Useful? React with 👍 / 👎.

lp.powerWindow = sample.PowerWindow()
lp.powerAt = sample.PowerAt
if lp.powerAt.IsZero() {
lp.powerAt = m.now()
Expand Down Expand Up @@ -311,6 +316,11 @@ func (m *Manager) observeConnectionProof(id string, generation uint64, unknown b
if generation != 0 {
lp.connectionGeneration = generation
}
if unknown || changed {
// A fresh cached power sample cannot prove charging across a lost
// connection. Reset this even when no session identity is known.
lp.chargingPeriodSince = time.Time{}
}
if (!unknown && !changed) || (!changed && lp.sessionDeviceID == "" && lp.sessionID == "") {
m.mu.Unlock()
return
Expand Down
51 changes: 51 additions & 0 deletions go/internal/mpc/charging_periods.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package mpc

import "context"

// ChargingPeriods describes a preference, after deadline energy and physical
// reserves. It never delays Core safety, manual stop, or charge completion.
// StartCostOre is part of the optimizer preference, not the electricity bill.
type ChargingPeriods struct {
MinChargeSeconds float64 `json:"min_charge_seconds"`
StartCostOre float64 `json:"start_cost_ore"`
InitialCharging bool `json:"initial_charging"`
InitialChargeSeconds float64 `json:"initial_charge_seconds"`
}

// DefaultChargingPeriods favors runs of at least five minutes and avoids an
// extra start for less than five ore. A final top-up may be shorter.
func DefaultChargingPeriods(charging bool, seconds float64) ChargingPeriods {
return ChargingPeriods{MinChargeSeconds: 300, StartCostOre: 5,
InitialCharging: charging, InitialChargeSeconds: min(300, max(0, seconds))}
}

func (o *EnergyplanOptimizer) addChargingPeriods(ctx context.Context, request *externalRequest, p Params) error {
loads := p.activeLoadpoints()
needed := false
for _, lp := range loads {
needed = needed || lp.Charging.MinChargeSeconds > 0 || lp.Charging.StartCostOre > 0
}
if !needed {
return nil
}
// Negotiate against the running worker on every request. Restart or a
// binary rollback must not inherit a feature learned from another process.
probeCtx, cancel := context.WithTimeout(ctx, o.cfg.Timeout)
defer cancel()
info, err := o.Health(probeCtx)
if err != nil {
return err
}
if !optimizerHasFeature(info, "charging_periods") {
return nil
}
byID := make(map[string]ChargingPeriods, len(loads))
for _, lp := range loads {
byID[lp.ID] = lp.Charging
}
for i := range request.FlexLoads {
c := byID[request.FlexLoads[i].ID]
request.FlexLoads[i].Charging = &c
}
return nil
}
99 changes: 99 additions & 0 deletions go/internal/mpc/charging_periods_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package mpc

import (
"context"
"encoding/json"
"fmt"
"math"
"testing"
"time"
)

type chargingTransport struct{ feature bool }

func (c *chargingTransport) RoundTrip(context.Context, []byte) ([]byte, error) {
features := []string{"champion", "ev_duty"}
if c.feature {
features = append(features, "charging_periods")
}
return json.Marshal(map[string]any{"name": "ftw-solver", "version": "test", "protocol_version": 1, "features": features})
}
func (*chargingTransport) Health(context.Context) (OptimizerRuntimeInfo, error) {
return OptimizerRuntimeInfo{}, fmt.Errorf("native handshake required")
}
func (*chargingTransport) Close() error { return nil }

func TestChargingPeriodsNegotiatesEachWorkerAndKeepsLegacyWire(t *testing.T) {
slots, p := externalTestFixture()
p.Loadpoint = &LoadpointSpec{ID: "car", PluggedIn: true, CapacityWh: 60000, Levels: 11, SoCMax: 1, MaxChargeW: 11000, Charging: DefaultChargingPeriods(true, 120)}
transport := &chargingTransport{feature: true}
external, err := NewExternalOptimizer(ExternalOptimizerConfig{Transport: transport})
if err != nil {
t.Fatal(err)
}
o := &EnergyplanOptimizer{ExternalOptimizer: external}
for _, supported := range []bool{true, false, true} {
transport.feature = supported
r := external.buildRequest(slots, p)
if r.FlexLoads[0].Charging != nil {
t.Fatal("generic sidecar received an unnegotiated field")
}
if err := o.addChargingPeriods(context.Background(), &r, p); err != nil {
t.Fatal(err)
}
c := r.FlexLoads[0].Charging
if (c != nil) != supported {
t.Fatalf("supported %v, charging %+v", supported, c)
}
if supported && (c.MinChargeSeconds != 300 || c.StartCostOre != 5 || !c.InitialCharging || c.InitialChargeSeconds != 120) {
t.Fatalf("state lost: %+v", c)
}
}
}

func TestChargingPeriodsRejectsInvalidPreference(t *testing.T) {
for _, bad := range []float64{-1, math.Inf(1), math.NaN(), 86401} {
lp := &LoadpointSpec{ID: "car", PluggedIn: true, CapacityWh: 60000, Levels: 11, SoCMax: 1, MaxChargeW: 11000, Charging: ChargingPeriods{MinChargeSeconds: bad}}
if err := validateLoadpointSpecs([]*LoadpointSpec{lp}, map[string]string{}); err == nil {
t.Fatalf("accepted %v", bad)
}
}
}

func TestNativeChargingPeriodsContinueMeasuredRun(t *testing.T) {
template := nativeWorker(t, 500*time.Millisecond)
defer template.Close()
o, err := NewEnergyplanOptimizer(template.cfg.Command[0])
if err != nil {
t.Fatal(err)
}
defer o.Close()
start := time.Now().Add(time.Minute).Truncate(time.Minute)
slots := make([]Slot, 12)
for i := range slots {
slots[i] = Slot{StartMs: start.Add(time.Duration(i) * time.Minute).UnixMilli(), LenMin: 1, Confidence: 1, PriceOre: 100, Limits: PowerLimits{MaxImportW: 1000}}
}
p := Params{Mode: ModeArbitrage, CapacityWh: 10000, SoCMin: .1, SoCMax: .9, InitialSoC: .5, ChargeEfficiency: 1, DischargeEfficiency: 1,
Loadpoint: &LoadpointSpec{ID: "car", CapacityWh: 1000, Levels: 11, SoCMax: .1, TargetSoC: .1, TargetSlotIdx: 11, PluggedIn: true,
ChargeEfficiency: 1, MaxChargeW: 1000, AllowedStepsW: []float64{0, 1000}, Charging: DefaultChargingPeriods(true, 180)}}
plan, err := o.Optimize(context.Background(), slots, p)
if err != nil {
t.Fatal(err)
}
if err := ValidatePlan(slots, p, &plan); err != nil {
t.Fatal(err)
}
var req externalRequest
if err := json.Unmarshal(plan.OptimizerInput, &req); err != nil {
t.Fatal(err)
}
if len(req.FlexLoads) != 1 || req.FlexLoads[0].Charging == nil || !req.FlexLoads[0].Charging.InitialCharging {
t.Fatal("measured run missing from request")
}
if plan.Actions[0].LoadpointPowerW["car"] <= 0 {
t.Fatal("current charging was postponed")
}
if math.Abs(plan.TotalCostOre-10) > 1e-5 {
t.Fatalf("preference changed the bill: %g", plan.TotalCostOre)
}
}
4 changes: 3 additions & 1 deletion go/internal/mpc/energyplan.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ func NewEnergyplanOptimizer(binary string) (*EnergyplanOptimizer, error) {
return nil, err
}
external.timeBudget = energyplanTimeBudget
return &EnergyplanOptimizer{ExternalOptimizer: external}, nil
o := &EnergyplanOptimizer{ExternalOptimizer: external}
external.prepareRequest = o.addChargingPeriods
return o, nil
}

const (
Expand Down
2 changes: 1 addition & 1 deletion go/internal/mpc/energyplan_fault_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func TestNativeEnergyplanEVRecoveryFallbackFaults(t *testing.T) {
t.Cleanup(func() { _ = external.Close() })
wrapper := &EnergyplanOptimizer{ExternalOptimizer: external}
health, err := wrapper.Health(context.Background())
if err != nil || health.Version != "0.4.6" {
if err != nil || health.Version != "0.4.8" {
t.Fatalf("bundle health=%+v err=%v", health, err)
}
svc := shadowTestService(t)
Expand Down
2 changes: 1 addition & 1 deletion go/internal/mpc/energyplan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ func TestNativeEnergyplanDownsideAndAsyncShadow(t *testing.T) {
svc := shadowTestService(t)
svc.Optimizer = o
info, err := svc.Optimizer.(*EnergyplanOptimizer).Health(context.Background())
if err != nil || info.Name != "ftw-solver" || info.Version != "0.4.6" {
if err != nil || info.Name != "ftw-solver" || info.Version != "0.4.8" {
t.Fatalf("bundled worker health: %+v %v", info, err)
}
start := time.Now().UTC().Truncate(time.Hour)
Expand Down
Loading
Loading