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/dark-offline-pv-keeps-load.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions go/internal/api/api_history_quality.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions go/internal/api/api_history_quality_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
31 changes: 31 additions & 0 deletions go/internal/telemetry/forecast.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions go/internal/telemetry/live_measurement_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading