diff --git a/.changeset/ev-common-balance-alignment.md b/.changeset/ev-common-balance-alignment.md new file mode 100644 index 000000000..8f597ecd7 --- /dev/null +++ b/.changeset/ev-common-balance-alignment.md @@ -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. diff --git a/.changeset/ev-confirmed-power-and-resume.md b/.changeset/ev-confirmed-power-and-resume.md new file mode 100644 index 000000000..2a7d87e3f --- /dev/null +++ b/.changeset/ev-confirmed-power-and-resume.md @@ -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. diff --git a/.changeset/ev-fuse-budget-evidence.md b/.changeset/ev-fuse-budget-evidence.md new file mode 100644 index 000000000..985e58451 --- /dev/null +++ b/.changeset/ev-fuse-budget-evidence.md @@ -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. diff --git a/.changeset/ev-latest-fallback-credit.md b/.changeset/ev-latest-fallback-credit.md new file mode 100644 index 000000000..d33d1cff4 --- /dev/null +++ b/.changeset/ev-latest-fallback-credit.md @@ -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. diff --git a/.changeset/ev-positive-offer-recovery.md b/.changeset/ev-positive-offer-recovery.md new file mode 100644 index 000000000..d057fbe67 --- /dev/null +++ b/.changeset/ev-positive-offer-recovery.md @@ -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. diff --git a/.changeset/ev-zero-credit-provenance.md b/.changeset/ev-zero-credit-provenance.md new file mode 100644 index 000000000..93a494f9a --- /dev/null +++ b/.changeset/ev-zero-credit-provenance.md @@ -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. diff --git a/go/cmd/ftw/app_link_plan_push_test.go b/go/cmd/ftw/app_link_plan_push_test.go index ee1748271..8527ce567 100644 --- a/go/cmd/ftw/app_link_plan_push_test.go +++ b/go/cmd/ftw/app_link_plan_push_test.go @@ -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", diff --git a/go/internal/api/api_clamped_no_draw_test.go b/go/internal/api/api_clamped_no_draw_test.go new file mode 100644 index 000000000..d1f8293bb --- /dev/null +++ b/go/internal/api/api_clamped_no_draw_test.go @@ -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) + } + }) + } +} diff --git a/go/internal/api/api_control_feedback.go b/go/internal/api/api_control_feedback.go index 73826f215..72086b4dd 100644 --- a/go/internal/api/api_control_feedback.go +++ b/go/internal/api/api_control_feedback.go @@ -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 diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 8d894f8f1..fc8694fa4 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -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 diff --git a/go/internal/control/ev_battery_headroom.go b/go/internal/control/ev_battery_headroom.go index 209873f17..584d0d2f4 100644 --- a/go/internal/control/ev_battery_headroom.go +++ b/go/internal/control/ev_battery_headroom.go @@ -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 { diff --git a/go/internal/control/ev_confirmed_transition_test.go b/go/internal/control/ev_confirmed_transition_test.go new file mode 100644 index 000000000..e39463e67 --- /dev/null +++ b/go/internal/control/ev_confirmed_transition_test.go @@ -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) + } + }) + } +} diff --git a/go/internal/control/ev_fuse_alignment_test.go b/go/internal/control/ev_fuse_alignment_test.go new file mode 100644 index 000000000..ac7945d7f --- /dev/null +++ b/go/internal/control/ev_fuse_alignment_test.go @@ -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) + } +} diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 1008b0d84..a6e37c696 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -2,6 +2,7 @@ package control import ( "encoding/json" + "log/slog" "math" "sort" "time" @@ -24,6 +25,9 @@ func finiteFuseValue(w float64) bool { return !math.IsNaN(w) && !math.IsInf(w, 0 type evFuseBalance struct { gridW, poolW, pendingW float64 latestPoolW, latestPendingW float64 + latestGridW float64 + siteAt time.Time + evEvidence []evFuseEvidence } // EVFuseBudget refreshes the common EV cap before a charger command, also @@ -43,12 +47,39 @@ func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State // The common balance includes later observed DER increases. Keep the // newest site/phase read as an independent bound so the same increase is // not counted once in its grid watts and again as a pending command. - cap := evHeadroomW(state.effectiveImportCeilingW(fuseMaxW), balance.gridW+balance.pendingW, balance.poolW) - latestCap := evHeadroomW(state.phaseImportCeilingW(fuseMaxW, meter), meter.RawW+balance.latestPendingW, balance.latestPoolW) + commonCeiling := state.effectiveImportCeilingW(fuseMaxW) + latestCeiling := state.phaseImportCeilingW(fuseMaxW, meter) + cap := evHeadroomW(commonCeiling, balance.gridW+balance.pendingW, balance.poolW) + latestCap := evHeadroomW(latestCeiling, meter.RawW+balance.latestPendingW, balance.latestPoolW) + // Keep the newest site and phase bounds authoritative even when the + // common historical balance has more replacement credit. + phaseCap := latestCap + phaseKnown := false if phaseW, known := evPhaseIncreaseW(meter, state, configs, balance.latestPoolW); known { - latestCap = math.Min(latestCap, math.Max(0, balance.latestPoolW+phaseW-balance.latestPendingW)) + phaseKnown = true + phaseCap = math.Max(0, balance.latestPoolW+phaseW-balance.latestPendingW) } - return math.Min(cap, latestCap), true + finalCap := math.Min(cap, math.Min(latestCap, phaseCap)) + minimumW := 0.0 + for _, cfg := range configs { + for _, point := range points { + if point.PluggedIn && point.ID == cfg.ID && cfg.MinChargeW > 0 && (minimumW == 0 || cfg.MinChargeW < minimumW) { + minimumW = cfg.MinChargeW + } + } + } + if minimumW > 0 && finalCap < minimumW && (state.evFuseEvidenceAt.IsZero() || now.Before(state.evFuseEvidenceAt) || now.Sub(state.evFuseEvidenceAt) >= time.Minute) { + state.evFuseEvidenceAt = now + slog.Debug("EV fuse budget below charging minimum", "minimum_charge_w", minimumW, + "common_site_at_ms", balance.siteAt.UnixMilli(), "latest_site_received_at_ms", meter.UpdatedAt.UnixMilli(), + "physical_fuse_w", fuseMaxW, "configured_import_ceiling_w", state.PeakImportCeilingW, + "common_ceiling_w", commonCeiling, "latest_ceiling_w", latestCeiling, + "common_grid_w", balance.gridW, "common_ev_credit_w", balance.poolW, "common_pending_battery_w", balance.pendingW, + "latest_grid_w", balance.latestGridW, "latest_ev_credit_w", balance.latestPoolW, "latest_pending_battery_w", balance.latestPendingW, + "ev_credit_evidence", balance.evEvidence, + "common_cap_w", cap, "latest_site_cap_w", latestCap, "phase_bound_known", phaseKnown, "phase_cap_w", phaseCap, "final_cap_w", finalCap) + } + return finalCap, true } // A managed EV earns replacement credit only across a site observation that @@ -57,10 +88,11 @@ func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State // The existing bounded telemetry ring owns the samples; no second cache or // filtered load estimate participates in the hard limit. func coherentEVBalance(store *telemetry.Store, state *State, meter *telemetry.DerReading, points []loadpoint.State, now time.Time) evFuseBalance { - balance := evFuseBalance{gridW: meter.RawW} + balance := evFuseBalance{gridW: meter.RawW, siteAt: meter.UpdatedAt} + balance.latestGridW = balance.gridW siteAt := meter.UpdatedAt from := now.Add(-telemetry.ControlWindowDuration) - windows := store.ControlWindows(from, now) + windows := store.ConfirmedControlWindows(from, now) batteries := pendingBatterySeriesForFuse(store, state, windows, now) sites := windows[state.SiteMeterDriver+":"+telemetry.DerMeter.String()].Points if current, ok := qualifiedFuseObservation(meter, now); ok { @@ -83,11 +115,19 @@ func coherentEVBalance(store *telemetry.Store, state *State, meter *telemetry.De r := store.Get(point.DriverName, telemetry.DerEV) h := store.DriverHealth(point.DriverName) current, ok := qualifiedFuseObservation(r, now) + evidence := evFuseEvidence{ControlPowerEvidence: telemetry.DescribeControlPower(point.DriverName, r, h, current, ok, now)} + balance.evEvidence = append(balance.evEvidence, evidence) if !ok || h == nil || !h.TelemetryLive() || current.PowerW <= 0 { continue // Unknown EV power stays inside the measured site demand. } ps := windows[point.DriverName+":"+telemetry.DerEV.String()].Points ps = append(ps, current) // Includes explicit change-only confirmations. + credit := evPowerAtLatestSite(ps, siteAt, current) + balance.evEvidence[len(balance.evEvidence)-1].LatestCreditW = credit + balance.evEvidence[len(balance.evEvidence)-1].Points = len(ps) + if credit <= 0 { + balance.evEvidence[len(balance.evEvidence)-1].Reason = "no_bracket_or_observed_stop" + } series = append(series, evSeries{math.Min(current.PowerW, r.RawW), current, ps}) } for _, ev := range series { @@ -99,9 +139,15 @@ func coherentEVBalance(store *telemetry.Store, state *State, meter *telemetry.De if site.At.Before(from) || site.At.After(now) || !finiteFuseValue(site.PowerW) { continue } + // Without a positive managed EV observation, an older high site + // sample has no replacement credit to align. Use the latest site + // fallback instead of retaining demand from a completed EV stop. + if len(series) == 0 && !site.At.Equal(siteAt) { + continue + } credit, coherent := 0.0, true for _, ev := range series { - w, ok := bracketedEVPower(ev.points, site.At, ev.latest) + w, ok := bracketedEVPowerAt(ev.points, site.At, ev.latest) if !ok { coherent = false break @@ -112,11 +158,27 @@ func coherentEVBalance(store *telemetry.Store, state *State, meter *telemetry.De balance.pendingW, coherent = pendingBatteryIncreaseAt(batteries, site.At) } if coherent { - balance.gridW, balance.poolW = site.PowerW, credit + balance.gridW, balance.poolW, balance.siteAt = site.PowerW, credit, site.At return balance } } - balance.pendingW, _ = pendingBatteryIncreaseAt(batteries, siteAt) + // No common bracket exists. Keep the EV credit already qualified at + // this latest site instant; dropping it here would count the same EV + // as household demand in a second bound on the very same site reading. + balance.poolW = balance.latestPoolW + // A battery already observed before this latest site read uses the + // same baseline as the latest-site guard. + // A first battery observation AFTER the site read cannot establish how + // much earlier discharge the meter contains: retain its full fallback. + balance.pendingW = 0 + for _, battery := range batteries { + if battery.fresh && !battery.current.Seen().After(siteAt) { + balance.pendingW += math.Max(0, battery.upperW-battery.current.PowerW) + } else { + reserve, _ := pendingBatteryIncreaseAt([]pendingBatterySeries{battery}, siteAt) + balance.pendingW += reserve + } + } return balance } @@ -128,7 +190,10 @@ func qualifiedFuseObservation(r *telemetry.DerReading, now time.Time) (telemetry return p, ok && !p.Seen().After(now) && now.Sub(p.Seen()) <= telemetry.ControlWindowDuration } -func bracketedEVPower(points []telemetry.ControlObservation, at time.Time, latest float64) (float64, bool) { +// bracketedEVPowerAt evaluates the historical site instant. Later EV +// stops are handled by the separate latest-site/phase bounds, not by +// retroactively subtracting them from this older grid observation. +func bracketedEVPowerAt(points []telemetry.ControlObservation, at time.Time, latest float64) (float64, bool) { var before, after telemetry.ControlObservation hasBefore, hasAfter := false, false for _, p := range points { @@ -146,6 +211,14 @@ func bracketedEVPower(points []telemetry.ControlObservation, at time.Time, lates return 0, false } baseline := math.Max(0, math.Min(latest, math.Min(before.PowerW, after.PowerW))) + return baseline, true +} + +func bracketedEVPower(points []telemetry.ControlObservation, at time.Time, latest float64) (float64, bool) { + baseline, known := bracketedEVPowerAt(points, at, latest) + if !known { + return 0, false + } // Another slow device may force an older common site observation. A // later observed stop still removes its replacement credit, even if // the charger has since started again. diff --git a/go/internal/control/ev_fuse_evidence.go b/go/internal/control/ev_fuse_evidence.go new file mode 100644 index 000000000..3eade9c45 --- /dev/null +++ b/go/internal/control/ev_fuse_evidence.go @@ -0,0 +1,10 @@ +package control + +import "github.com/srcfl/ftw/go/internal/telemetry" + +// Budget-specific fields accompany the immutable diagnostic snapshot. +type evFuseEvidence struct { + telemetry.ControlPowerEvidence + LatestCreditW float64 `json:"latest_credit_w"` + Points int `json:"points"` +} diff --git a/go/internal/control/ev_fuse_evidence_test.go b/go/internal/control/ev_fuse_evidence_test.go new file mode 100644 index 000000000..a1afa81f6 --- /dev/null +++ b/go/internal/control/ev_fuse_evidence_test.go @@ -0,0 +1,44 @@ +package control + +import ( + "encoding/json" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func TestEVFuseZeroEvidenceKeepsSourceAndReceiptTimes(t *testing.T) { + now := time.Now() + sourceAt := now.Add(-time.Minute) + for _, tc := range []struct{ name, data, reason string }{ + {"confirmed cloud zero", `"control_power_confirmed":true`, "observed_nonpositive_power"}, + {"unavailable source", `"control_power_available":false`, "invalid_or_unavailable_source"}, + } { + t.Run(tc.name, func(t *testing.T) { + store := telemetry.NewStore() + data := []byte(`{"control_power_observed_at":"` + sourceAt.Format(time.RFC3339Nano) + `",` + tc.data + `}`) + store.Update("car", telemetry.DerEV, 0, nil, data) + store.RecordDriverSuccess("car") + store.Update("meter", telemetry.DerMeter, 12323, nil, []byte(`{"l1_a":20.45,"l2_a":17.05,"l3_a":15.87}`)) + st := NewState(0, 0, "meter") + st.SiteFuseAmps, st.SiteFuseVoltage, st.SiteFusePhases = 25, 230, 3 + balance := coherentEVBalance(store, st, store.Get("meter", telemetry.DerMeter), []loadpoint.State{{ID: "car", DriverName: "car", PluggedIn: true}}, time.Now()) + if len(balance.evEvidence) != 1 { + t.Fatalf("missing zero-credit evidence: %+v", balance) + } + e := balance.evEvidence[0] + if e.Reason != tc.reason || e.ReportedSourceAt != sourceAt.Format(time.RFC3339Nano) || !e.ReceivedAt.After(e.SourceAt) || e.LatestCreditW != 0 { + t.Fatalf("evidence=%+v", e) + } + if _, err := json.Marshal(e); err != nil { + t.Fatal(err) + } + capW, _ := EVFuseBudget(store, st, []loadpoint.State{{ID: "car", DriverName: "car", PluggedIn: true}}, []loadpoint.Config{{ID: "car", DriverName: "car", PhaseMode: "3p", MinChargeW: 4140}}, 17250) + if capW >= 4140 { + t.Fatalf("uncertainty must not authorize charging: cap=%v", capW) + } + }) + } +} diff --git a/go/internal/control/ev_fuse_fallback_test.go b/go/internal/control/ev_fuse_fallback_test.go new file mode 100644 index 000000000..8ba46e741 --- /dev/null +++ b/go/internal/control/ev_fuse_fallback_test.go @@ -0,0 +1,67 @@ +package control + +import ( + "encoding/json" + "math" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func TestEVFuseFallbackKeepsLatestEVProof(t *testing.T) { + for _, tc := range []struct { + name string + ev, grid, want float64 + oldSite, evAfter, offline bool + phase float64 + }{ + {name: "01:55 preceding EV without common bracket", ev: 6820, grid: 6587, want: 7733}, + {name: "real household increase", ev: 6820, grid: 12000, want: 2320}, + {name: "later EV cannot fund earlier site", ev: 6820, grid: 6587, want: 913, evAfter: true}, + {name: "missing EV earns no credit", ev: 6820, grid: 6587, want: 913, offline: true}, + {name: "01:58 stopped EV does not retain older high grid", ev: 0, grid: -99.39, want: 7599.39, oldSite: true}, + {name: "phase limit remains authoritative", ev: 6820, grid: 6587, want: 6130, phase: 25}, + } { + 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 { + a := tc.phase + if a == 0 { + a = 10 + } + d["l1_a"], d["l2_a"], d["l3_a"] = a, a, a + } + b, _ := json.Marshal(d) + store.Update(driver, kind, w, nil, b) + store.RecordDriverSuccess(driver) + } + emit("battery", telemetry.DerBattery, 5*time.Second, -1000) + if tc.oldSite { + emit("meter", telemetry.DerMeter, 4*time.Second, 6640) + } + emit("battery", telemetry.DerBattery, 3*time.Second, -1000) + age := 2 * time.Second + if tc.evAfter { + age = 500 * time.Millisecond + } + emit("car", telemetry.DerEV, age, tc.ev) + emit("meter", telemetry.DerMeter, time.Second, tc.grid) + if tc.offline { + store.Remove("car") + } + 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) + } + }) + } +} diff --git a/go/internal/control/ev_fuse_incident_test.go b/go/internal/control/ev_fuse_incident_test.go new file mode 100644 index 000000000..85f23565f --- /dev/null +++ b/go/internal/control/ev_fuse_incident_test.go @@ -0,0 +1,82 @@ +package control + +import ( + "bytes" + "encoding/json" + "log/slog" + "math" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// This is a synthetic ambiguity reproducer inspired by issue #1674, not a +// replay of missing field-level control observations. A delayed EV start and +// a new household load can present the same latest site/EV readings. Until +// the physical flow is confirmed, neither can earn guessed replacement credit. +func TestImportCeilingStartSkewEvidence(t *testing.T) { + + store := telemetry.NewStore() + at := time.Now().Add(-10 * time.Second) + emit := func(driver string, kind telemetry.DerType, w float64, when time.Time, phases bool) { + data := map[string]any{"control_power_observed_at": when.Format(time.RFC3339Nano), "control_power_available": true} + if phases { + data["l1_a"], data["l2_a"], data["l3_a"] = 10.01, 11.19, 10.02 + } + b, _ := json.Marshal(data) + store.Update(driver, kind, w, nil, b) + store.RecordDriverSuccess(driver) + } + emit("car", telemetry.DerEV, 0, at, false) + emit("meter", telemetry.DerMeter, 900, at.Add(time.Second), true) + emit("meter", telemetry.DerMeter, 7100, at.Add(5*time.Second), true) + st := NewState(0, 0, "meter") + st.SiteFuseAmps, st.SiteFuseVoltage, st.SiteFusePhases = 25, 230, 3 + st.PeakImportCeilingW = 7500 + now := time.Now() + st.clock = func() time.Time { return now } + var log bytes.Buffer + old := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&log, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer slog.SetDefault(old) + capW, known := EVFuseBudget(store, st, []loadpoint.State{{ID: "a", DriverName: "car", PluggedIn: true}}, []loadpoint.Config{{ID: "a", DriverName: "car", PhaseMode: "3p", MinChargeW: 4140}}, 17250) + if !known || math.Abs(capW-400) > .001 { + t.Fatalf("unconfirmed EV cannot earn credit: cap=%v known=%v", capW, known) + } + var e map[string]any + if err := json.Unmarshal(log.Bytes(), &e); err != nil { + t.Fatal(err) + } + if e["level"] != "DEBUG" { + t.Fatalf("budget evidence level=%v want DEBUG", e["level"]) + } + for key, want := range map[string]float64{"configured_import_ceiling_w": 7500, "physical_fuse_w": 17250, "latest_grid_w": 7100, "latest_ev_credit_w": 0, "latest_site_cap_w": 400, "final_cap_w": 400} { + if e[key] != want { + t.Fatalf("%s=%v want %v", key, e[key], want) + } + } + // The 400 W cap is below the field site's 4140 W minimum and thus + // explains why that possible observation sequence would stop charging. + if capW >= 4140 { + t.Fatal("incident minimum-step condition not reproduced") + } + + // Repeated blocked ticks must not flood the production log ring. + before := log.Len() + _, _ = EVFuseBudget(store, st, []loadpoint.State{{ID: "a", DriverName: "car", PluggedIn: true}}, []loadpoint.Config{{ID: "a", DriverName: "car", PhaseMode: "3p", MinChargeW: 4140}}, 17250) + if log.Len() != before { + t.Fatal("unchanged blocked verdict was logged again inside one minute") + } + // Once actual EV power brackets a site observation, the same site reading + // earns replacement credit. Diagnostic logging must not alter that recovery. + emit("car", telemetry.DerEV, 6200, at.Add(6*time.Second), false) + emit("meter", telemetry.DerMeter, 7100, at.Add(7*time.Second), true) + emit("car", telemetry.DerEV, 6200, at.Add(8*time.Second), false) + now = time.Now() + capW, known = EVFuseBudget(store, st, []loadpoint.State{{ID: "a", DriverName: "car", PluggedIn: true}}, []loadpoint.Config{{ID: "a", DriverName: "car", PhaseMode: "3p", MinChargeW: 4140}}, 17250) + if !known || math.Abs(capW-6600) > .001 { + t.Fatalf("confirmed EV recovery changed: cap=%v known=%v", capW, known) + } +} diff --git a/go/internal/loadpoint/controller_resume.go b/go/internal/loadpoint/controller_resume.go index 49017b2eb..aa9b89cb1 100644 --- a/go/internal/loadpoint/controller_resume.go +++ b/go/internal/loadpoint/controller_resume.go @@ -3,14 +3,17 @@ package loadpoint import ( "context" "log/slog" + "math" "time" ) type resumeOffer struct { - driver, device, session string - generation uint64 - nextAttempt time.Time - retryDelay time.Duration + driver, device, session string + generation uint64 + nextAttempt time.Time + retryDelay time.Duration + acknowledgedAttempts int + deliverySince, deliveryAt time.Time } // resumeAfterZeroOffer remembers our standdown independently of commanded W. @@ -30,11 +33,47 @@ func (c *Controller) resumeAfterZeroOffer(ctx context.Context, cfg Config, sampl c.resumeOffers[cfg.ID] = proof return } - if sample.PowerW >= DeliveringW { - delete(c.resumeOffers, cfg.ID) + if !exists { + return + } + // A small startup pulse is not successful recovery. Require fresh, + // advancing observations near a supported charging step for ten seconds. + // The current offer can be lower than the configured 3p minimum after a + // phase change. Leave the shared DeliveringW semantics unchanged. + minimum := cfg.MinChargeW + if minimum <= 0 { + minimum = offerW + } + // Allow ten percent variation around the nominal-voltage minimum. + floor := math.Max(DeliveringW, 0.9*math.Min(minimum, offerW)) + observedAt := sample.PowerAt + if observedAt.IsZero() { + observedAt = now // Legacy telemetry uses the fresh-read contract. + } + fresh := !sample.PowerUnavailable && !observedAt.After(now) && now.Sub(observedAt) <= min(sample.PowerWindow(), 10*time.Second) + if fresh && finite(sample.PowerW) && sample.PowerW >= floor { + if previous.deliverySince.IsZero() || observedAt.Before(previous.deliveryAt) || observedAt.Sub(previous.deliveryAt) > 10*time.Second { + previous.deliverySince = observedAt + } + previous.deliveryAt = observedAt + if observedAt.Sub(previous.deliverySince) >= 10*time.Second { + delete(c.resumeOffers, cfg.ID) + } else { + c.resumeOffers[cfg.ID] = previous + } + return // Already drawing: observe it rather than sending another resume. + } + previous.deliverySince, previous.deliveryAt = time.Time{}, time.Time{} + c.resumeOffers[cfg.ID] = previous + if now.Before(previous.nextAttempt) { return } - if !exists || now.Before(previous.nextAttempt) { + if previous.acknowledgedAttempts >= 3 { + if previous.acknowledgedAttempts == 3 { + slog.Warn("loadpoint resume acknowledged but no charge observed", "lp", cfg.ID, "driver", cfg.DriverName) + previous.acknowledgedAttempts++ + c.resumeOffers[cfg.ID] = previous + } return } if err := c.sendDispatchWithDeadline(ctx, cfg.DriverName, []byte(`{"action":"ev_resume"}`)); err != nil { @@ -45,5 +84,11 @@ func (c *Controller) resumeAfterZeroOffer(ctx context.Context, cfg Config, sampl "retry_at", previous.nextAttempt, "err", err) return } - delete(c.resumeOffers, cfg.ID) + // An HTTP acknowledgement does not establish that the contactor closed. + // Retry at a bounded rate, under the current dispatch gates, and stop after + // three acknowledged attempts. Only observed delivery clears the proof. + previous.acknowledgedAttempts++ + previous.nextAttempt = now.Add(30 * time.Second) + previous.retryDelay = 0 + c.resumeOffers[cfg.ID] = previous } diff --git a/go/internal/loadpoint/controller_resume_after_zero_test.go b/go/internal/loadpoint/controller_resume_after_zero_test.go index 782bde8bc..0454abbee 100644 --- a/go/internal/loadpoint/controller_resume_after_zero_test.go +++ b/go/internal/loadpoint/controller_resume_after_zero_test.go @@ -194,9 +194,16 @@ func TestTickCancelsResumeRetryWhenSessionProofChanges(t *testing.T) { } samples["easee"] = sample c.Tick(context.Background(), now.Add(10*time.Second)) + zeroAt := now.Add(15 * time.Second) + if change == "delivering" { + // Recovery needs sustained delivery, not one startup pulse. + c.Tick(context.Background(), now.Add(15*time.Second)) + c.Tick(context.Background(), now.Add(20*time.Second)) + zeroAt = now.Add(25 * time.Second) + } sample.Connected, sample.ConnectionUnknown, sample.PowerW = true, false, 0 samples["easee"] = sample - c.Tick(context.Background(), now.Add(15*time.Second)) + c.Tick(context.Background(), zeroAt) if attempts != 1 { t.Fatalf("stale retry crossed %s boundary: %d attempts", change, attempts) } diff --git a/go/internal/loadpoint/controller_resume_confirm_test.go b/go/internal/loadpoint/controller_resume_confirm_test.go new file mode 100644 index 000000000..77bde4902 --- /dev/null +++ b/go/internal/loadpoint/controller_resume_confirm_test.go @@ -0,0 +1,73 @@ +package loadpoint + +import ( + "context" + "testing" + "time" +) + +func TestResumeAcknowledgementWaitsForMeasuredCharge(t *testing.T) { + now := time.Date(2026, 10, 17, 0, 0, 0, 0, time.UTC) + sender := &fakeSender{} + cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 11000, AllowedStepsW: []float64{0, 4140, 6900, 11000}} + samples := map[string]EVSample{"easee": {Connected: true, RequestActive: true, DeviceID: "box", SessionID: "plug-1", ConnectionGeneration: 1}} + dir := &Directive{SlotStart: now, SlotEnd: now.Add(time.Hour), LoadpointEnergyWh: map[string]float64{"garage": 0}} + c := newTestController(t, []Config{cfg}, dir, samples, sender) + c.Tick(context.Background(), now) + dir.LoadpointEnergyWh["garage"] = 11000 + for _, seconds := range []int{5, 10, 35, 65, 95, 125} { + c.Tick(context.Background(), now.Add(time.Duration(seconds)*time.Second)) + } + if n := countAction(sender.calls, "ev_resume"); n != 3 { + t.Fatalf("need bounded retries until delivery, got %d resumes: %+v", n, sender.calls) + } + sample := samples["easee"] + sample.PowerW = 6900 + samples["easee"] = sample + for _, seconds := range []int{130, 135, 140} { + sample.PowerAt = now.Add(time.Duration(seconds) * time.Second) + samples["easee"] = sample + c.Tick(context.Background(), sample.PowerAt) + } + if _, exists := c.resumeOffers[cfg.ID]; exists { + t.Fatal("sustained measured charging did not clear recovery proof") + } + sample.PowerW = 0 + samples["easee"] = sample + c.Tick(context.Background(), now.Add(160*time.Second)) + if n := countAction(sender.calls, "ev_resume"); n != 3 { + t.Fatalf("measured delivery must clear resume proof, got %d", n) + } +} + +func TestAcknowledgedResumeRetryRespectsSessionAndStops(t *testing.T) { + for _, stop := range []string{"session", "safety", "plan_pause"} { + t.Run(stop, func(t *testing.T) { + now := time.Date(2026, 10, 17, 0, 0, 0, 0, time.UTC) + sender := &fakeSender{} + cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 11000} + samples := map[string]EVSample{"easee": {Connected: true, RequestActive: true, DeviceID: "box", SessionID: "one", ConnectionGeneration: 1}} + dir := &Directive{SlotStart: now, SlotEnd: now.Add(time.Hour), LoadpointEnergyWh: map[string]float64{"garage": 0}} + c := newTestController(t, []Config{cfg}, dir, samples, sender) + c.Tick(context.Background(), now) + dir.LoadpointEnergyWh["garage"] = 11000 + c.Tick(context.Background(), now.Add(5*time.Second)) + if countAction(sender.calls, "ev_resume") != 1 { + t.Fatal(sender.calls) + } + switch stop { + case "session": + sample := samples["easee"] + sample.SessionID = "two" + sample.ConnectionGeneration = 2 + samples["easee"] = sample + case "plan_pause": + dir.LoadpointEnergyWh["garage"] = 0 + } + c.TickWithDispatch(context.Background(), now.Add(35*time.Second), stop != "safety") + if countAction(sender.calls, "ev_resume") != 1 { + t.Fatalf("retry crossed %s: %+v", stop, sender.calls) + } + }) + } +} diff --git a/go/internal/loadpoint/controller_resume_delivery_test.go b/go/internal/loadpoint/controller_resume_delivery_test.go new file mode 100644 index 000000000..81daa07f7 --- /dev/null +++ b/go/internal/loadpoint/controller_resume_delivery_test.go @@ -0,0 +1,62 @@ +package loadpoint + +import ( + "context" + "testing" + "time" +) + +func TestResumeRequiresSustainedMeaningfulDelivery(t *testing.T) { + for _, tc := range []struct { + name string + power, offer float64 + gap, stale, unavailable bool + clear bool + }{ + {name: "130 W pulse", power: 130, offer: 4830}, + {name: "800 W pulse", power: 800, offer: 4830}, + {name: "full power briefly", power: 4757, offer: 4830, gap: true}, + {name: "cached high sample", power: 4757, offer: 4830, stale: true}, + {name: "unknown high power", power: 4757, offer: 4830, unavailable: true}, + {name: "sustained charging", power: 4757, offer: 4830, clear: true}, + {name: "voltage tolerance at minimum", power: 3800, offer: 4140, clear: true}, + {name: "one phase offer", power: 1350, offer: 1380, clear: true}, + } { + t.Run(tc.name, func(t *testing.T) { + now := time.Now() + cfg := Config{ID: "car", DriverName: "easee", MinChargeW: 4140} + resumes := 0 + c := &Controller{send: func(context.Context, string, []byte) error { resumes++; return nil }} + sample := EVSample{DeviceID: "box", SessionID: "one", ConnectionGeneration: 1} + c.resumeAfterZeroOffer(context.Background(), cfg, sample, 0, now) + c.resumeAfterZeroOffer(context.Background(), cfg, sample, tc.offer, now.Add(time.Second)) + for _, seconds := range []int{5, 10, 15} { + sample.PowerW = tc.power + sample.PowerUnavailable = tc.unavailable + sample.PowerAt = now.Add(time.Duration(seconds) * time.Second) + if tc.stale { + sample.PowerAt = now.Add(5 * time.Second) + } + if tc.gap && seconds == 10 { + sample.PowerW = 0 + } + c.resumeAfterZeroOffer(context.Background(), cfg, sample, tc.offer, now.Add(time.Duration(seconds)*time.Second)) + } + _, exists := c.resumeOffers[cfg.ID] + if exists == tc.clear { + t.Fatalf("resume proof exists=%v want clear=%v", exists, tc.clear) + } + sample.PowerW = 0 + sample.PowerUnavailable = false + sample.PowerAt = now.Add(35 * time.Second) + c.resumeAfterZeroOffer(context.Background(), cfg, sample, tc.offer, now.Add(35*time.Second)) + want := 2 + if tc.clear { + want = 1 + } + if resumes != want { + t.Fatalf("resumes=%v want=%v", resumes, want) + } + }) + } +} diff --git a/go/internal/loadpoint/manual_clamped_no_draw_test.go b/go/internal/loadpoint/manual_clamped_no_draw_test.go new file mode 100644 index 000000000..6fb6af3d2 --- /dev/null +++ b/go/internal/loadpoint/manual_clamped_no_draw_test.go @@ -0,0 +1,22 @@ +package loadpoint + +import ( + "testing" + "time" +) + +func TestPositiveClampDoesNotHideAcceptedZeroDraw(t *testing.T) { + at := time.Date(2026, 10, 17, 0, 12, 0, 0, time.UTC) + h := ManualHold{PowerW: 10350, StartedAt: at} + st := State{Phases: 3, VoltageV: 230, CommandedKnown: true, CommandedW: 6900, CommandedReason: "fuse_limit", CommandedSinceMs: at.UnixMilli()} + ch := ChargerReading{Known: true, UpdatedAt: at.Add(8 * time.Minute), LimitKnown: true, LimitA: 10, Reason: "limited by dynamic charger current"} + m := ManualStatusFrom(h, true, st, ch, at.Add(8*time.Minute)) + if m.State != ManualNotDrawing || m.CommandedA != 10 || m.RequestedA != 15 || m.LimitReason != "fuse_limit" { + t.Fatalf("positive clamp masks zero delivery: %+v", m) + } + st.CommandedW = 0 + ch.LimitA = 0 + if m := ManualStatusFrom(h, true, st, ch, at.Add(8*time.Minute)); m.State != ManualLimited { + t.Fatalf("intentional zero must remain limited: %+v", m) + } +} diff --git a/go/internal/loadpoint/manual_status.go b/go/internal/loadpoint/manual_status.go index 6b47121a2..c1654b2ad 100644 --- a/go/internal/loadpoint/manual_status.go +++ b/go/internal/loadpoint/manual_status.go @@ -186,6 +186,9 @@ func ManualStatusFrom(h ManualHold, held bool, st State, ch ChargerReading, now return m } + if clamp { + m.LimitReason = st.CommandedReason + } limitMatches := m.ChargerLimitKnown && m.CommandedA >= 0 && math.Abs(ch.LimitA-m.CommandedA) < 1 switch { case ch.Unavailable || st.PowerUnavailable: @@ -205,6 +208,8 @@ func ManualStatusFrom(h ManualHold, held bool, st State, ch ChargerReading, now if clamp { m.LimitReason = st.CommandedReason } + case limitMatches && m.CommandedW >= manualChargingFloorW && elapsed >= manualAcceptGrace: + m.State = ManualNotDrawing case clamp: m.State = ManualLimited m.LimitReason = st.CommandedReason diff --git a/go/internal/telemetry/control_confirmed_anchor_test.go b/go/internal/telemetry/control_confirmed_anchor_test.go new file mode 100644 index 000000000..58679cfa5 --- /dev/null +++ b/go/internal/telemetry/control_confirmed_anchor_test.go @@ -0,0 +1,101 @@ +package telemetry + +import ( + "fmt" + "testing" + "time" +) + +func confirmedAnchorData(at time.Time) []byte { + return []byte(fmt.Sprintf(`{"control_power_observed_at":%q,"control_power_confirmed":true}`, at.Format(time.RFC3339Nano))) +} + +func TestConfirmedControlAnchorRetainsSourceTimeAndOneSample(t *testing.T) { + now := time.Now() + source := now.Add(-time.Minute) + s := NewStore() + for i := 10; i >= 1; i-- { + s.recordControlObservation("car", DerEV, 6183, confirmedAnchorData(source), now.Add(-time.Duration(i)*time.Second)) + } + points := copyConfirmedControlPoints(s.controlObservations["car:ev"], now.Add(-20*time.Second), now) + if len(points) != 1 || !points[0].At.Equal(source) || !points[0].Seen().Equal(now.Add(-time.Second)) { + t.Fatalf("confirmed anchor lost or duplicated: %+v", points) + } + if physical := copyControlPoints(points, now.Add(-20*time.Second), now); len(physical) != 0 { + t.Fatalf("response verifier received an old confirmed anchor: %+v", physical) + } + if w := observationWindow(points, now.Add(-20*time.Second), now); w.Count != 0 || w.Usable(now) { + t.Fatalf("confirmation manufactured source samples: %+v", w) + } + if expired := copyConfirmedControlPoints(points, now, now.Add(time.Second)); len(expired) != 0 { + t.Fatal("expired confirmation remained usable") + } +} + +func TestConfirmedControlAnchorMissingOrConflictingDataBreaksProof(t *testing.T) { + now := time.Now() + source := now.Add(-time.Minute) + for _, tc := range []struct { + name string + data []byte + power float64 + }{ + {"missing", []byte(`{"control_power_available":false,"control_power_confirmed":true}`), 6183}, + {"unconfirmed old source", []byte(fmt.Sprintf(`{"control_power_observed_at":%q}`, source.Format(time.RFC3339Nano))), 6183}, + {"different power at same source time", confirmedAnchorData(source), 0}, + } { + t.Run(tc.name, func(t *testing.T) { + s := NewStore() + s.recordControlObservation("car", DerEV, 6183, confirmedAnchorData(source), now.Add(-time.Second)) + s.recordControlObservation("car", DerEV, tc.power, tc.data, now) + if ps := s.controlObservations["car:ev"]; len(ps) != 0 { + t.Fatalf("invalid proof retained: %+v", ps) + } + }) + } +} + +func TestConfirmedControlAnchorRetainsStopAndBoundsFrozenConfirmation(t *testing.T) { + now := time.Now() + s := NewStore() + source := now.Add(-time.Minute) + s.recordControlObservation("car", DerEV, 6183, confirmedAnchorData(source), now.Add(-time.Second)) + s.recordControlObservation("car", DerEV, 0, confirmedAnchorData(now), now) + s.recordControlObservation("car", DerEV, 4757, confirmedAnchorData(now.Add(time.Second)), now.Add(time.Second)) + ps := copyConfirmedControlPoints(s.controlObservations["car:ev"], now.Add(-20*time.Second), now.Add(time.Second)) + if len(ps) != 3 || ps[1].PowerW != 0 { + t.Fatalf("stop was lost: %+v", ps) + } + s.recordControlObservation("car", DerEV, 4757, confirmedAnchorData(now.Add(time.Second)), now.Add(10*time.Second)) + frozen := copyConfirmedControlPoints(s.controlObservations["car:ev"], now, now.Add(5*time.Second)) + for _, p := range frozen { + if p.Seen().After(now.Add(5 * time.Second)) { + t.Fatalf("confirmation escaped frozen interval: %+v", p) + } + } +} + +func TestConfirmedControlAnchorDoesNotManufactureResponseWindow(t *testing.T) { + now := time.Now() + source := now.Add(-15 * time.Second) + s := NewStore() + for i := 10; i >= 1; i-- { + s.recordControlObservation("car", DerEV, 6183, confirmedAnchorData(source), now.Add(-time.Duration(i)*time.Second)) + } + ps := copyConfirmedControlPoints(s.controlObservations["car:ev"], now.Add(-20*time.Second), now) + w := observationWindow(ps, now.Add(-20*time.Second), now) + if len(ps) != 1 || w.Count != 1 || w.Usable(now) { + t.Fatalf("ten confirmations must remain one physical sample: points=%+v window=%+v", ps, w) + } +} + +func TestConfirmedControlWindowsSeparateBoundsFromResponseSamples(t *testing.T) { + s := NewStore() + now := time.Now() + s.Update("car", DerEV, 6183, nil, confirmedAnchorData(now.Add(-time.Minute))) + physical := s.ControlWindows(now.Add(-20*time.Second), time.Now())["car:ev"] + bounds := s.ConfirmedControlWindows(now.Add(-20*time.Second), time.Now())["car:ev"] + if len(physical.Points) != 0 || len(bounds.Points) != 1 || physical.Window.Count != 0 || bounds.Window.Count != 0 { + t.Fatalf("confirmed interval became physical response evidence: physical=%+v bounds=%+v", physical, bounds) + } +} diff --git a/go/internal/telemetry/control_observations.go b/go/internal/telemetry/control_observations.go index 59612953f..1740822f8 100644 --- a/go/internal/telemetry/control_observations.go +++ b/go/internal/telemetry/control_observations.go @@ -40,7 +40,7 @@ func (s *Store) recordControlObservation(driver string, kind DerType, power floa k := key(driver, kind) invalidate := func() { delete(s.controlObservations, k) } p, known := ControlPowerObservation(power, data, now) - if !known || now.Sub(p.At) > 10*time.Second { + if !known || now.Sub(p.Seen()) > 10*time.Second { invalidate() return } @@ -50,6 +50,18 @@ func (s *Store) recordControlObservation(driver string, kind DerType, power floa } points := s.controlObservations[k] if len(points) > 0 && !at.After(points[len(points)-1].At) { + last := &points[len(points)-1] + if at.Equal(last.At) { + if p.PowerW != last.PowerW { + invalidate() // One source time cannot prove two power levels. + return + } + // A change-only source confirms the same physical sample. Keep + // its source time and a single point; extend only its confirmation. + if p.ConfirmedAt.After(last.ConfirmedAt) { + last.ConfirmedAt = p.ConfirmedAt + } + } return } if kind != DerEV && kind != DerBattery && kind != DerV2X && len(points) > 0 && at.Unix() == points[len(points)-1].At.Unix() { @@ -102,6 +114,17 @@ func (s *Store) controlBaseline(now time.Time) map[string]ControlBaseline { return result } func (s *Store) ControlWindows(from, now time.Time) map[string]ControlBaseline { + return s.controlWindows(from, now, false) +} + +// ConfirmedControlWindows also retains a change-only source's confirmed +// interval for time-aligned power bounds. Window still counts only distinct +// physical measurements. Response verification uses ControlWindows instead. +func (s *Store) ConfirmedControlWindows(from, now time.Time) map[string]ControlBaseline { + return s.controlWindows(from, now, true) +} + +func (s *Store) controlWindows(from, now time.Time, confirmations bool) map[string]ControlBaseline { s.mu.RLock() defer s.mu.RUnlock() result := map[string]ControlBaseline{} @@ -109,7 +132,11 @@ func (s *Store) ControlWindows(from, now time.Time) map[string]ControlBaseline { if rd.DerType == DerVehicle { continue } - result[k] = ControlBaseline{rd.Driver, rd.DerType, observationWindow(s.controlObservations[k], from, now), copyControlPoints(s.controlObservations[k], from, now)} + points := copyControlPoints(s.controlObservations[k], from, now) + if confirmations { + points = copyConfirmedControlPoints(s.controlObservations[k], from, now) + } + result[k] = ControlBaseline{rd.Driver, rd.DerType, observationWindow(s.controlObservations[k], from, now), points} } return result } @@ -118,6 +145,24 @@ func copyControlPoints(points []ControlObservation, from, until time.Time) []Con var out []ControlObservation for _, p := range points { if !p.At.Before(from) && !p.At.After(until) { + if p.ConfirmedAt.After(until) { + p.ConfirmedAt = until + } + out = append(out, p) + } + } + return out +} + +func copyConfirmedControlPoints(points []ControlObservation, from, until time.Time) []ControlObservation { + var out []ControlObservation + for _, p := range points { + if !p.Seen().Before(from) && !p.At.After(until) { + // Keep confirmed anchors for time-aligned bounds, without + // moving At or counting them as new response measurements. + if p.ConfirmedAt.After(until) { + p.ConfirmedAt = until + } out = append(out, p) } } diff --git a/go/internal/telemetry/control_power_evidence.go b/go/internal/telemetry/control_power_evidence.go new file mode 100644 index 000000000..8b7b955ab --- /dev/null +++ b/go/internal/telemetry/control_power_evidence.go @@ -0,0 +1,63 @@ +package telemetry + +import ( + "encoding/json" + "time" +) + +// ControlPowerEvidence captures the same EV reading used by the budget, not a later status +// snapshot. It is diagnostic only and never supplies replacement credit. +type ControlPowerEvidence struct { + ReportedSourceAt string `json:"reported_source_at"` + Driver string `json:"driver"` + Reason string `json:"reason"` + RawW float64 `json:"raw_w"` + PowerW float64 `json:"control_power_w"` + ReceivedAt time.Time `json:"received_at"` + SourceAt time.Time `json:"source_at"` + ConfirmedAt time.Time `json:"confirmed_at"` + SourceValid bool `json:"source_valid"` + Qualified bool `json:"qualified"` + TelemetryLive bool `json:"telemetry_live"` +} + +// DescribeControlPower describes the supplied decision snapshot, including rejected +// or offline readings, for diagnostics only. It never authorizes control. +func DescribeControlPower(driver string, r *DerReading, health *DriverHealth, p ControlObservation, qualified bool, now time.Time) ControlPowerEvidence { + e := ControlPowerEvidence{Driver: driver, Reason: "missing_reading", Qualified: qualified} + if r == nil { + return e + } + e.RawW, e.ReceivedAt = r.RawW, r.UpdatedAt + // Keep the source timestamp even when qualification rejected the reading. + var reported struct { + ControlPowerObservedAt string `json:"control_power_observed_at"` + PowerObservedAt string `json:"power_observed_at"` + } + _ = json.Unmarshal(r.Data, &reported) + e.ReportedSourceAt = reported.ControlPowerObservedAt + if e.ReportedSourceAt == "" { + e.ReportedSourceAt = reported.PowerObservedAt + } + source, valid := ControlPowerObservation(r.RawW, r.Data, r.UpdatedAt) + e.PowerW, e.SourceAt, e.ConfirmedAt, e.SourceValid = source.PowerW, source.At, source.ConfirmedAt, valid + if !valid { + e.SourceAt, e.ConfirmedAt = time.Time{}, time.Time{} + } + e.TelemetryLive = health != nil && health.TelemetryLive() + switch { + case !valid: + e.Reason = "invalid_or_unavailable_source" + case r.UpdatedAt.After(now) || now.Sub(r.UpdatedAt) > ControlPowerMaxAge(r.Data): + e.Reason = "stale_or_future_receipt" + case !qualified: + e.Reason = "stale_or_future_control_observation" + case !e.TelemetryLive: + e.Reason = "telemetry_not_live" + case p.PowerW <= 0: + e.Reason = "observed_nonpositive_power" + default: + e.Reason = "qualified" + } + return e +} diff --git a/web/app.js b/web/app.js index 589250457..89b408346 100644 --- a/web/app.js +++ b/web/app.js @@ -2866,12 +2866,12 @@ case "limited": if (m.limit_reason === "charger_limit") return "The charger limits this request to " + cmdA + " (" + reqA + " requested)."; if (m.limit_reason === "fuse_cooldown") { - return "Paused: main-fuse protection — " + reqA + " requested; charging resumes on its own." + sinceP; + return "Paused by site power limits — " + reqA + " requested; charging resumes on its own." + sinceP; } if (m.limit_reason === "site_meter_stale") { return "Paused for safety: site-meter data is stale — " + reqA + " requested; charging resumes when telemetry recovers."; } - return "Main fuse limits this charge to " + cmdA + " right now (" + reqA + " requested)." + sinceP; + return "Site power limits cap this charge at " + cmdA + " right now (" + reqA + " requested)." + sinceP; } return "FTW received " + reqA + ". Waiting for charger status." + end; } @@ -2919,7 +2919,7 @@ ? "Charging on plan until " + evFmtClock(lp.plan_next_end_ms) + "." + kwPlanned : "Charging."; if (lp.commanded_reason === "fuse_limit") { - text += " Rate is limited by the main fuse right now."; + text += " Rate is limited by site power limits right now."; } } else if (lp.goal_complete === true) { text = "The car confirmed this charging goal is complete."; @@ -2944,7 +2944,7 @@ // The specific pauses beat the generic branches — this is the // line that stops an operator debugging cable and charger while // the box is protecting the main fuse (#1009). - text = "Paused: main-fuse protection — the house is using the headroom; charging resumes on its own." + kwPlanned; + text = "Paused by site power limits — the house is using the headroom; charging resumes on its own." + kwPlanned; tone = "var(--text)"; } else if (lp.commanded_known && !lp.commanded_w && lp.commanded_reason === "site_meter_stale") { text = "Paused for safety: site-meter data is stale — charging resumes when telemetry recovers."; diff --git a/web/control-feedback.js b/web/control-feedback.js index 95fda5faa..18a184e63 100644 --- a/web/control-feedback.js +++ b/web/control-feedback.js @@ -63,7 +63,7 @@ const REASON_TITLES = { no_plan_budget: 'Waiting for the plan', pv_surplus_pause: 'Waiting for spare solar', wake_kick: 'Starting the charger', vehicle_complete: 'Car is full', vehicle_limit_completion: 'Car reached its limit', vehicle_not_requesting: 'Car not taking power', battery_full: 'Battery full', battery_nearly_full: 'Battery nearly full', battery_nearly_empty: 'Battery nearly empty', - core_limit: 'Limited by FTW', fuse_limit: 'Limited by the main fuse', fuse_cooldown: 'Waiting after a fuse limit', + core_limit: 'Limited by FTW', fuse_limit: 'Limited by site power limits', fuse_cooldown: 'Waiting after a site power limit', charger_limit: 'Limited by FTW’s charger setting', device_limit: 'Charger limit', offered_current_lower: 'Charger offers less current', device_fault: 'Device fault', telemetry_stale: 'No fresh readings', readings_lost: 'Lost control', command_failed: 'Commands failing', command_unconfirmed: 'Command not confirmed', default_failed: 'Cannot hand back control', @@ -92,8 +92,8 @@ function sentence(row) { case 'battery_nearly_full': return `Taking ${controlPower(actual)} of ${controlPower(row.sent_w)} at ${percent(row.battery_soc)}. A battery charges slower when it is nearly full.`; case 'battery_nearly_empty': return `Giving ${controlPower(actual)} of ${controlPower(row.sent_w)} at ${percent(row.battery_soc)}. A battery limits discharge when it is nearly empty.`; case 'core_limit': return `FTW’s safety limits set the target to ${target(row)}.`; - case 'fuse_limit': return `FTW lowered the rate to ${target(row)} to protect the main fuse. It rises when the house uses less.`; - case 'fuse_cooldown': return 'FTW waits a moment after a fuse limit before it raises the rate.'; + case 'fuse_limit': return `FTW lowered the rate to ${target(row)} to stay within the site’s fuse and import limits.`; + case 'fuse_cooldown': return 'FTW waits a moment after a site power limit before it raises the rate.'; case 'charger_limit': return 'The charger limit in FTW’s settings caps the rate.'; case 'device_limit': return `The charger’s own limit is ${amps(row.device_limit_a)}, below the ${amps(row.requested_a)} FTW asked for.`; case 'offered_current_lower': return `The charger offers ${amps(row.offered_a)}, below the ${amps(row.requested_a)} FTW asked for.`; diff --git a/web/control-feedback.test.mjs b/web/control-feedback.test.mjs index 00654f005..bdef3a5ea 100644 --- a/web/control-feedback.test.mjs +++ b/web/control-feedback.test.mjs @@ -140,3 +140,13 @@ test('the summary answers the question for the whole site', () => { assert.equal(controlSummary([row({status: 'not_controlled', reason: 'observe_only'})]).tone, 'neutral'); assert.equal(controlSummary([row()], false).tone, 'stale'); }); + + +test('limited positive EV offer does not explain prolonged zero delivery', () => { + const missing=controlStatus(row({kind:'ev',status:'not_following',reason:'no_power_response',severity:'warning',requested_w:10350,sent_w:6900,actual_w:0,requested_a:15,offered_a:10,device_reason:'limited by dynamic charger current'})); + assert.equal(missing.title,'Not following'); + assert.match(missing.text,/6.9 kW charge.*no power/); + const capped=controlStatus(row({kind:'ev',status:'limited',reason:'fuse_limit',sent_w:6900,actual_w:6800})); + assert.equal(capped.title,'Limited by site power limits'); + assert.match(capped.text,/fuse and import limits/); +}); diff --git a/web/ev-commanded-reason.test.mjs b/web/ev-commanded-reason.test.mjs index 017440d88..745ec3bc5 100644 --- a/web/ev-commanded-reason.test.mjs +++ b/web/ev-commanded-reason.test.mjs @@ -9,7 +9,7 @@ const source = readFileSync(new URL('./app.js', import.meta.url), 'utf8'); test('every pause reason has its own sentence', () => { assert.match(source, /commanded_reason === "fuse_cooldown" \|\| lp\.commanded_reason === "fuse_limit"/); - assert.match(source, /Paused: main-fuse protection/); + assert.match(source, /Paused by site power limits/); assert.match(source, /commanded_reason === "site_meter_stale"/); assert.match(source, /Paused for safety: site-meter data is stale/); assert.match(source, /commanded_reason === "pv_surplus_pause"/); @@ -20,5 +20,5 @@ test('every pause reason has its own sentence', () => { assert.match(source, /The ready time has passed, so FTW is not charging/); assert.match(source, /Battery level is assumed, not read from the car/); // A fuse-limited ongoing charge says so too. - assert.match(source, /Rate is limited by the main fuse right now/); + assert.match(source, /Rate is limited by site power limits right now/); }); diff --git a/web/ev-manual-feedback.test.mjs b/web/ev-manual-feedback.test.mjs index 4076398db..96aba8e1f 100644 --- a/web/ev-manual-feedback.test.mjs +++ b/web/ev-manual-feedback.test.mjs @@ -25,7 +25,7 @@ test('the status line follows the charger through every state', () => { assert.match(statusText, /Waiting for the car to start drawing/); assert.match(statusText, /but the car is not drawing/); assert.match(statusText, /The charger has not acted on/); - assert.match(statusText, /Main fuse limits this charge/); + assert.match(statusText, /Site power limits cap this charge/); // The charger's own words are part of the sentence. assert.match(statusText, /Charger reports: " \+ m\.charger_reason/); // Elapsed time comes from the box's since_ms, not from click time. @@ -139,3 +139,14 @@ test('pending manual save is distinct from an actual write failure', () => { assert.doesNotMatch(render({ ...paused, manual_save_pending: false }).textContent, /Saving it/); assert.match(render({ ...paused, manual_save_error: true }).textContent, /could not be saved/); }); + + +test('ten amp positive clamp remains distinct from zero delivery with reason 78', () => { + const manual = {active:true,state:'not_drawing',requested_a:15,commanded_a:10,limit_reason:'fuse_limit',charger_reason:'limited by dynamic charger current'}; + const words = describeManual({manual_active:true,current_power_w:0,manual}); + assert.match(words,/Charger offers 10 A but the car is not drawing/); + assert.match(words,/limited by dynamic charger current/); + assert.doesNotMatch(words,/Main fuse|charging resumes on its own/); + const limited=describeManual({manual_active:true,current_power_w:0,manual:{...manual,state:'limited'}}); + assert.match(limited,/Site power limits cap this charge at 10 A/); +});