From 72b4b250ee52c1b572e87fd4bf1ca3838c7b4350 Mon Sep 17 00:00:00 2001 From: "frahlg-agents[bot]" Date: Wed, 21 Oct 2026 06:19:16 +0200 Subject: [PATCH] fix(telemetry): dark offline PV counts as 0 W so live load stays known --- .changeset/dark-offline-pv-keeps-load.md | 5 ++ go/internal/api/api_history_quality.go | 3 + go/internal/api/api_history_quality_test.go | 26 +++++++++ go/internal/telemetry/forecast.go | 31 ++++++++++ .../telemetry/live_measurement_test.go | 57 +++++++++++++++++++ 5 files changed, 122 insertions(+) create mode 100644 .changeset/dark-offline-pv-keeps-load.md diff --git a/.changeset/dark-offline-pv-keeps-load.md b/.changeset/dark-offline-pv-keeps-load.md new file mode 100644 index 000000000..21c55f993 --- /dev/null +++ b/.changeset/dark-offline-pv-keeps-load.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep showing home use at night when the solar inverter goes offline after sunset. If an offline or silent solar source last reported about 0 W (within 50 W), the live view counts it as 0 W, so grid and battery still give home use instead of a blank. An inverter that drops out while producing still shows as unknown, and history and forecasts still record solar as not measured. diff --git a/go/internal/api/api_history_quality.go b/go/internal/api/api_history_quality.go index a53a7c21c..01ef104d3 100644 --- a/go/internal/api/api_history_quality.go +++ b/go/internal/api/api_history_quality.go @@ -29,6 +29,9 @@ func (s *Server) qualifyStatusHistory(resp map[string]any, siteMeter string) { } if !r.Valid { missing |= state.MissingLoad + } else if r.PVAssumedZero { + // Every PV source is either measured or dark and offline (0 W). + missing &^= state.MissingPV } if opts.PVInvalidReason != "" { missing |= state.MissingPV diff --git a/go/internal/api/api_history_quality_test.go b/go/internal/api/api_history_quality_test.go index 92bfd4e49..cd7c029a4 100644 --- a/go/internal/api/api_history_quality_test.go +++ b/go/internal/api/api_history_quality_test.go @@ -134,3 +134,29 @@ func TestLiveHistoryQualityConfiguredPVWithoutMeasurementIsUnknown(t *testing.T) t.Fatal("unmeasured configured PV reported as zero", resp) } } + +// Anders' night case: Growatt drops offline after sunset while the Pixii +// meter and battery keep reporting. PV reads 0 and the home load stays shown. +func TestStatusDarkOfflinePVKeepsLoad(t *testing.T) { + tel := telemetry.NewStore() + for _, name := range []string{"pixii", "growatt"} { + tel.EnsureDriverHealth(name) + } + tel.Update("pixii", telemetry.DerMeter, 780, nil, nil) + tel.Update("pixii", telemetry.DerBattery, -200, nil, nil) + tel.RecordDriverSuccess("pixii") + tel.Update("growatt", telemetry.DerPV, 0, nil, nil) + tel.DriverHealthMut("growatt").SetOffline() + srv := New(&Deps{Tel: tel}) + resp := map[string]any{} + srv.qualifyStatusHistory(resp, "pixii") + if resp["pv_w"] != float64(0) || resp["load_w"] != float64(980) || resp["history_reason"] != "" { + t.Fatal(resp) + } + tel.Update("growatt", telemetry.DerPV, -3000, nil, nil) + tel.DriverHealthMut("growatt").SetOffline() + srv.qualifyStatusHistory(resp, "pixii") + if resp["pv_w"] != nil || resp["load_w"] != nil || resp["history_reason"] != "offline:growatt:pv" { + t.Fatal(resp) + } +} diff --git a/go/internal/telemetry/forecast.go b/go/internal/telemetry/forecast.go index b5a31152f..2d900533b 100644 --- a/go/internal/telemetry/forecast.go +++ b/go/internal/telemetry/forecast.go @@ -54,6 +54,8 @@ type ForecastSource struct { Flow ForecastFlow PowerW *float64 SoC *float64 + // AssumedZero marks a dark PV source counted as 0 W while offline. + AssumedZero bool } type ForecastReading struct { @@ -63,6 +65,8 @@ type ForecastReading struct { GridW, PVW, BatteryW, EVW, V2XW, HouseholdW float64 Valid, PVValid bool Reason, PVReason string + // PVAssumedZero: at least one dark, offline PV source was counted as 0 W. + PVAssumedZero bool } // ForecastMeasurement reads power and health under one lock. A command fault @@ -114,6 +118,14 @@ func SourceFailure(reason string) bool { return reason == "nonfinite_pv_sum" } +// PVDarkThresholdW is the largest last PV output (absolute watts) for which an +// inverter that goes offline is taken to have shut down for lack of sun. +const PVDarkThresholdW = 50.0 + +func pvDarkAtLastReading(w float64) bool { + return !math.IsNaN(w) && !math.IsInf(w, 0) && math.Abs(w) <= PVDarkThresholdW +} + func (s *Store) forecastMeasurementLocked(now time.Time, siteMeter string, opts ForecastOptions) ForecastReading { return s.measurementLocked(now, siteMeter, opts, true) } @@ -245,6 +257,25 @@ func (s *Store) measurementLocked(now time.Time, siteMeter string, opts Forecast case pv && powerW > 0: reason = "invalid_sign:" } + // A PV inverter that drops off the bus after sunset is producing + // nothing. On the live path, an offline or stale PV source whose last + // reading was near zero counts as 0 W so the meter and battery still + // give a home load. Daytime outages (last reading producing) and + // forecast/history alignment keep failing as before. + if pv && !alignSources && (reason == "offline:" || reason == "stale:") && r != nil && pvDarkAtLastReading(r.RawW) { + zero := 0.0 + source.PowerW = &zero + source.AssumedZero = true + out.Sources[k] = source + // Not measured: history and the PV model keep it unknown. + out.Known[DerPV] = false + out.PVAssumedZero = true + if out.PVValid { + out.PVValid = false + out.PVReason = "assumed_zero:" + k + } + continue + } if reason != "" { fail(reason+k, pv) out.Known[f.DerType] = false diff --git a/go/internal/telemetry/live_measurement_test.go b/go/internal/telemetry/live_measurement_test.go index add4cd002..6409fe1fd 100644 --- a/go/internal/telemetry/live_measurement_test.go +++ b/go/internal/telemetry/live_measurement_test.go @@ -70,3 +70,60 @@ func TestLiveMeasurementAllowsIndependentCurrentSourceTimes(t *testing.T) { }) } } + +func nightSite(t *testing.T, lastPV float64) *Store { + t.Helper() + s := NewStore() + for _, driver := range []string{"pixii", "growatt"} { + s.EnsureDriverHealth(driver) + } + s.Update("pixii", DerMeter, 780, nil, nil) + s.Update("pixii", DerBattery, 300, nil, nil) + s.Update("growatt", DerPV, lastPV, nil, nil) + s.DriverHealthMut("growatt").Status = StatusOffline + return s +} + +func TestLiveMeasurementOfflineDarkPVCountsAsZero(t *testing.T) { + s := nightSite(t, -3) + live := s.LiveMeasurementNow("pixii", ForecastOptions{}) + if !live.Valid || live.Known[DerPV] || !live.PVAssumedZero || live.PVW != 0 || live.HouseholdW != 480 { + t.Fatalf("offline dark PV hid load: valid=%t reason=%q pv=%v load=%v", live.Valid, live.Reason, live.PVW, live.HouseholdW) + } + src := live.Sources["growatt:pv"] + if !src.AssumedZero || src.PowerW == nil || *src.PowerW != 0 { + t.Fatal("assumed-zero PV source not marked") + } +} + +func TestLiveMeasurementOfflineDarkPVWithDischargingBattery(t *testing.T) { + s := nightSite(t, 0) + s.Update("pixii", DerBattery, -500, nil, nil) + live := s.LiveMeasurementNow("pixii", ForecastOptions{}) + if !live.Valid || live.HouseholdW != 1280 { + t.Fatalf("want load 1280, got valid=%t reason=%q load=%v", live.Valid, live.Reason, live.HouseholdW) + } +} + +func TestLiveMeasurementOfflineProducingPVStaysUnknown(t *testing.T) { + s := nightSite(t, -2500) + live := s.LiveMeasurementNow("pixii", ForecastOptions{}) + if live.Valid || live.Known[DerPV] || live.Reason != "offline:growatt:pv" { + t.Fatalf("daytime PV outage was guessed: valid=%t reason=%q", live.Valid, live.Reason) + } +} + +func TestForecastOfflineDarkPVStaysUnknown(t *testing.T) { + s := nightSite(t, 0) + if r := s.ForecastMeasurementNow("pixii", ForecastOptions{}); r.Valid || r.Known[DerPV] { + t.Fatal("forecast/history path accepted an assumed PV value") + } +} + +func TestLiveMeasurementOfflineMeterStillFails(t *testing.T) { + s := nightSite(t, 0) + s.DriverHealthMut("pixii").Status = StatusOffline + if live := s.LiveMeasurementNow("pixii", ForecastOptions{}); live.Valid || live.Known[DerMeter] { + t.Fatal("offline site meter produced a load") + } +}