Skip to content
Closed
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/ev-common-balance-alignment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Avoid false EV stops when the common balance revisits an older site observation after an observed EV stop/restart, and avoid a full battery discharge fallback when a fresh battery observation already precedes the latest site reading. Keep independent latest-site, phase, pending-order and unknown-history bounds.
5 changes: 5 additions & 0 deletions .changeset/ev-confirmed-power-and-resume.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Retain fresh confirmations of change-only power samples in the bounded control buffer without changing source times or counting repeated confirmations as new physical samples. Require ten seconds of fresh, meaningful charging before ending EV resume tracking, so small startup pulses cannot cancel recovery.
5 changes: 5 additions & 0 deletions .changeset/ev-fuse-budget-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Report debug-level, rate-limited common/latest site, EV-credit, import and phase-limit evidence when an EV fuse budget falls below the charging minimum, without changing charging limits.
5 changes: 5 additions & 0 deletions .changeset/ev-latest-fallback-credit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Keep qualified latest-site EV replacement credit when a common bracket is unavailable, and avoid retaining an obsolete older grid demand when no positive EV remains to align. Latest-site, phase, missing-measurement and pending-battery bounds still apply.
5 changes: 5 additions & 0 deletions .changeset/ev-positive-offer-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Report sustained zero EV power despite an accepted positive limited offer, distinguish site power limits from main-fuse overload, and retry acknowledged resumes after FTW's own zero offer at a bounded rate until measured charging or the retry limit.
5 changes: 5 additions & 0 deletions .changeset/ev-zero-credit-provenance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Include per-charger source, confirmation and receipt times and zero-credit reasons in bounded debug-level EV budget diagnostics. Preserve conservative charging limits when source observations are uncertain.
6 changes: 5 additions & 1 deletion go/cmd/ftw/app_link_plan_push_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,12 @@ func TestAPlugInReachesAStreamingPhoneAsAPlan(t *testing.T) {
mgr.Observe("easee", true, 0, 0, true)
cable := owner.waitPlan(t, func(p appproto.Plan) bool { return len(p.Chargers) == 1 && p.Chargers[0].PluggedIn })
tookCable := time.Since(plugged)
// A push reads plan revision and charger windows separately. A solve
// finishing between those reads can expose the new windows with the
// preceding revision. Wait for the published revision as well as the
// windows; the delivery deadline below still applies to that full result.
got := owner.waitPlan(t, func(p appproto.Plan) bool {
return len(p.Chargers) == 1 && p.Chargers[0].PluggedIn && !p.Chargers[0].Pending && len(p.Chargers[0].Windows) > 0
return p.Rev > first.Rev && len(p.Chargers) == 1 && p.Chargers[0].PluggedIn && !p.Chargers[0].Pending && len(p.Chargers[0].Windows) > 0
})
took := time.Since(plugged)
t.Logf("plug-in to the cable on a phone: %v (pending %v); to the plan: %v, %d windows, %d Wh",
Expand Down
40 changes: 40 additions & 0 deletions go/internal/api/api_clamped_no_draw_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package api

import (
"github.com/srcfl/ftw/go/internal/telemetry"
"testing"
"time"
)

func TestPositiveEVClampDoesNotHideNoPowerResponse(t *testing.T) {
now := time.Now()
connected := true
for _, tc := range []struct {
name, core string
sent, actual float64
fresh bool
age time.Duration
want string
}{
{"accepted ten amp offer, reason 78", "fuse_limit", 6900, 0, true, 3 * time.Minute, "no_power_response"},
{"intentional zero", "fuse_limit", 0, 0, true, 3 * time.Minute, "fuse_limit"},
{"still in grace", "fuse_limit", 6900, 0, true, time.Minute, "fuse_limit"},
{"charging at limited rate", "fuse_limit", 6900, 6800, true, 3 * time.Minute, "fuse_limit"},
{"stale charger", "fuse_limit", 6900, 0, false, 3 * time.Minute, "telemetry_stale"},
{"stale site", "site_meter_stale", 6900, 0, true, 3 * time.Minute, "site_meter_stale"},
{"cooldown", "fuse_cooldown", 6900, 0, true, 3 * time.Minute, "fuse_cooldown"},
} {
t.Run(tc.name, func(t *testing.T) {
f := ControlFeedback{Kind: "ev", RequestedA: watts(15), OfferedA: watts(10), SentW: watts(tc.sent), ActualW: watts(tc.actual), ObservedAtMs: now.UnixMilli(), DeviceReason: "limited by dynamic charger current"}
cmd := telemetry.CommandEvidence{Result: "accepted", Since: now.Add(-tc.age), PowerMismatchSince: now.Add(-tc.age)}
classifyControlFeedback(&f, cmd, true, tc.fresh, tc.core, &connected, now)
setControlStatus(&f)
if f.Reason != tc.want {
t.Fatalf("got %+v want %s", f, tc.want)
}
if tc.want == "no_power_response" && f.Status != "not_following" {
t.Fatalf("zero power must be visible: %+v", f)
}
})
}
}
10 changes: 10 additions & 0 deletions go/internal/api/api_control_feedback.go
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,16 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence,
set("waiting", "not_connected", "info")
return
}
// A positive clamp explains the lower offer, not prolonged zero delivery.
// Keep known safety pauses and unconfirmed/stale commands authoritative.
if f.Kind == "ev" && commanded && cmd.Result == "accepted" &&
coreReason != "site_meter_stale" && coreReason != "site_phase_currents_stale" && coreReason != "fuse_cooldown" &&
f.SentW != nil && *f.SentW > 100 && f.ActualW != nil && math.Abs(*f.ActualW) < 100 &&
f.OfferedA != nil && *f.OfferedA > 0 && !cmd.Since.IsZero() && now.Sub(cmd.Since) >= 2*time.Minute &&
!cmd.PowerMismatchSince.IsZero() && now.Sub(cmd.PowerMismatchSince) >= 2*time.Minute && f.ObservedAtMs >= cmd.Since.UnixMilli() {
set("warning", "no_power_response", "warning")
return
}
if f.RequestedA != nil && f.DeviceLimitA != nil && *f.RequestedA > *f.DeviceLimitA+0.5 {
set("limited", "device_limit", "warning")
return
Expand Down
2 changes: 2 additions & 0 deletions go/internal/control/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,8 @@ type State struct {
// command so battery and EV cooperatively share the fuse budget.
// Recomputed every dispatch tick.
FuseEVMaxW float64
// Caller holds the control lock; diagnostic output is limited to once per minute.
evFuseEvidenceAt time.Time
// FuseSaturated signals that the joint allocator had to scale battery
// and/or EV demand to fit under the fuse this tick. Used to gate the
// loadpoint controller's read of FuseEVMaxW and as the trigger for
Expand Down
2 changes: 1 addition & 1 deletion go/internal/control/ev_battery_headroom.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st
return targets
}
now := state.now()
windows := store.ControlWindows(now.Add(-telemetry.ControlWindowDuration), now)
windows := store.ConfirmedControlWindows(now.Add(-telemetry.ControlWindowDuration), now)
need := make(map[string]float64, len(state.evFuseDemands))
phaseMode := make(map[string]string, len(state.evFuseDemands))
for _, demand := range state.evFuseDemands {
Expand Down
48 changes: 48 additions & 0 deletions go/internal/control/ev_confirmed_transition_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package control

import (
"encoding/json"
"github.com/srcfl/ftw/go/internal/loadpoint"
"github.com/srcfl/ftw/go/internal/telemetry"
"math"
"testing"
"time"
)

func TestEVFuseConfirmedSteadyPowerSurvivesRateChange(t *testing.T) {
for _, tc := range []struct {
name string
before, grid, want float64
missing, stopped bool
}{
{"known charging then rate decrease", 6183, 7019, 5238, false, false},
{"new household load still limits", 6183, 10000, 2257, false, false},
{"new start earns no guessed credit", 0, 7019, 481, false, false},
{"missing observation revokes old credit", 6183, 7019, 481, true, false},
{"observed stop before restart retains zero credit", 6183, 7019, 481, false, true},
} {
t.Run(tc.name, func(t *testing.T) {
store := telemetry.NewStore()
emit := func(driver string, kind telemetry.DerType, w float64, at time.Time, confirmed bool) {
d, _ := json.Marshal(map[string]any{"control_power_observed_at": at.Format(time.RFC3339Nano), "control_power_confirmed": confirmed})
store.Update(driver, kind, w, nil, d)
store.RecordDriverSuccess(driver)
}
emit("car", telemetry.DerEV, tc.before, time.Now().Add(-time.Minute), true)
if tc.missing {
store.Update("car", telemetry.DerEV, tc.before, nil, []byte(`{"control_power_available":false}`))
}
emit("meter", telemetry.DerMeter, tc.grid, time.Now(), false)
if tc.stopped {
emit("car", telemetry.DerEV, 0, time.Now(), true)
}
emit("car", telemetry.DerEV, 4757, time.Now(), true)
st := NewState(0, 0, "meter")
st.PeakImportCeilingW = 7500
got, _ := EVFuseBudget(store, st, []loadpoint.State{{ID: "car", DriverName: "car", PluggedIn: true}}, []loadpoint.Config{{ID: "car", DriverName: "car", PhaseMode: "3p"}}, 17250)
if math.Abs(got-tc.want) > .01 {
t.Fatalf("cap=%v want=%v", got, tc.want)
}
})
}
}
83 changes: 83 additions & 0 deletions go/internal/control/ev_fuse_alignment_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package control

import (
"encoding/json"
"github.com/srcfl/ftw/go/internal/loadpoint"
"github.com/srcfl/ftw/go/internal/telemetry"
"math"
"testing"
"time"
)

func TestEVFuseAlignment(t *testing.T) {
for _, tc := range []struct {
name string
restart, grid, want float64
}{
{"restart does not rewrite old balance", 7527, 7154, 7230},
{"new household load still stops", 7527, 12000, 3027},
{"latest low EV still stops", 286, 7154, 632},
} {
t.Run(tc.name, func(t *testing.T) {
now := time.Now()
store := telemetry.NewStore()
emit := func(driver string, kind telemetry.DerType, ago time.Duration, w float64) {
d := map[string]any{"control_power_observed_at": now.Add(-ago).Format(time.RFC3339Nano)}
if kind == telemetry.DerMeter {
d["l1_a"], d["l2_a"], d["l3_a"] = 10.0, 11.2, 10.0
}
b, _ := json.Marshal(d)
store.Update(driver, kind, w, nil, b)
store.RecordDriverSuccess(driver)
}
emit("battery", telemetry.DerBattery, 7*time.Second, -1000)
emit("car", telemetry.DerEV, 7*time.Second, 6836)
emit("meter", telemetry.DerMeter, 6*time.Second, 7106)
emit("car", telemetry.DerEV, 5*time.Second, 6836)
emit("car", telemetry.DerEV, 4*time.Second, 286)
emit("battery", telemetry.DerBattery, 3*time.Second, -1000)
emit("car", telemetry.DerEV, 2*time.Second, tc.restart)
emit("meter", telemetry.DerMeter, time.Second, tc.grid)
st := NewState(0, 0, "meter")
st.SiteFuseAmps, st.SiteFuseVoltage, st.SiteFusePhases = 25, 230, 3
st.PeakImportCeilingW = 7500
st.DriverLimits = map[string]PowerLimits{"battery": {MaxChargeW: 10000, MaxDischargeW: 10000}}
got, _ := EVFuseBudget(store, st, []loadpoint.State{{ID: "car", DriverName: "car", PluggedIn: true}}, []loadpoint.Config{{ID: "car", DriverName: "car", PhaseMode: "3p"}}, 17250)
if math.Abs(got-tc.want) > .01 {
t.Fatalf("cap=%v want=%v", got, tc.want)
}
})
}
}

func TestEVFuseLatestFallbackUsesSameBatteryBaseline(t *testing.T) {
now := time.Now()
store := telemetry.NewStore()
for _, p := range []struct {
driver string
kind telemetry.DerType
ago time.Duration
w float64
}{
{"battery", telemetry.DerBattery, 2 * time.Millisecond, -990.37234241847},
{"meter", telemetry.DerMeter, time.Millisecond, 6.832},
} {
d, _ := json.Marshal(map[string]any{"control_power_observed_at": now.Add(-p.ago).Format(time.RFC3339Nano)})
store.Update(p.driver, p.kind, p.w, nil, d)
store.RecordDriverSuccess(p.driver)
}
st := NewState(0, 0, "meter")
st.PeakImportCeilingW = 7500
st.DriverLimits = map[string]PowerLimits{"battery": {MaxChargeW: 10000, MaxDischargeW: 10000}}
got, _ := EVFuseBudget(store, st, nil, nil, 17250)
if math.Abs(got-7493.168) > .01 {
t.Fatalf("cap=%v want7493.168", got)
}
// A pending zero order can remove the observed discharge. Even with
// the preceding fresh measurement, that loss must still be reserved.
pendingEVOrder(store, 0, now.Add(-3*time.Millisecond), "accepted")
got, _ = EVFuseBudget(store, st, nil, nil, 17250)
if math.Abs(got-(7493.168-990.37234241847)) > .01 {
t.Fatalf("pending discharge release lost: cap=%v", got)
}
}
Loading
Loading