From 13bad7684ff769d0b714c8e85321fd499bc3a4c5 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Fri, 31 Jul 2026 10:20:46 +0200 Subject: [PATCH 1/2] feat(forecast): project irradiance onto each PV array's plane Radiation-bearing providers previously produced one flat estimate, rated x (W/m2 / 1000), which ignored panel orientation entirely: a south-facing 35 deg roof and a flat one got the same forecast. When per-plane geometry is configured (Weather tab: tilt/azimuth/kWp), global horizontal irradiance is now projected onto each plane via the existing sunpos physics and summed. Providers publishing only GHI get an Erbs correlation to split direct from diffuse first. Sites with no arrays keep the previous behaviour, and Forecast.Solar - which already returns site-calibrated watts - is deliberately left untouched so its numbers are not scaled twice. Split out of the original #718 per review: this half is user-visible and improves every radiation-bearing provider today. The STRANG client and performance scoring that shared that branch move to a follow-up PR. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com> --- .changeset/strang-poa-pv-forecast.md | 15 ++++ go/internal/forecast/forecast.go | 49 ++++++++++- go/internal/forecast/forecast_test.go | 94 ++++++++++++++++++++ go/internal/sunpos/sunpos.go | 118 ++++++++++++++++++++++---- go/internal/sunpos/sunpos_test.go | 87 +++++++++++++++++++ 5 files changed, 341 insertions(+), 22 deletions(-) create mode 100644 .changeset/strang-poa-pv-forecast.md diff --git a/.changeset/strang-poa-pv-forecast.md b/.changeset/strang-poa-pv-forecast.md new file mode 100644 index 00000000..687c8a7e --- /dev/null +++ b/.changeset/strang-poa-pv-forecast.md @@ -0,0 +1,15 @@ +--- +"ftw": minor +--- + +PV forecasts are now orientation-aware. When per-plane geometry is configured +(the Weather tab's PV arrays: tilt/azimuth/kWp), a radiation-bearing forecast +provider's global horizontal irradiance is projected onto each panel plane via +the physics `sunpos` model and summed, instead of the previous flat +`rated × (W/m² / 1000)` estimate that ignored panel orientation. Sites with no +arrays configured keep the existing behaviour, and providers that already return +site-calibrated watts (Forecast.Solar) are left untouched. + +Providers that publish only global horizontal irradiance get an Erbs correlation +to split it into direct and diffuse components before projection, so a +south-facing 35° roof and a flat one no longer receive the same forecast. diff --git a/go/internal/forecast/forecast.go b/go/internal/forecast/forecast.go index 08a55efb..6222871b 100644 --- a/go/internal/forecast/forecast.go +++ b/go/internal/forecast/forecast.go @@ -29,6 +29,7 @@ import ( "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/sunpos" ) // Provider is implemented by each weather source. @@ -243,10 +244,16 @@ func EstimatePVW(lat, lon float64, t time.Time, cloudPct *float64, ratedW float6 // Service wraps a provider + store + scheduler for forecasts. type Service struct { - Provider Provider - Store *state.Store - Lat, Lon float64 - RatedPVW float64 // total rated PV across all arrays (used for estimate) + Provider Provider + Store *state.Store + Lat, Lon float64 + RatedPVW float64 // total rated PV across all arrays (used for estimate) + + // Arrays holds per-plane geometry (tilt/azimuth/kWp) mirrored from the + // weather config. When set, a radiation-bearing provider's horizontal + // GHI is projected onto each plane via sunpos and summed, instead of the + // orientation-blind flat rated×(W/m²/1000) estimate. Empty → flat estimate. + Arrays []Array stop chan struct{} done chan struct{} @@ -286,10 +293,21 @@ func FromConfig(cfg *config.Weather, ratedPVW float64, st *state.Store, userAgen default: return nil } + // Mirror per-plane geometry for the POA path. Shared across all + // providers: forecast_solar already applies geometry server-side (so + // this stays unused there), but open_meteo / STRÅNG-style GHI providers + // use it to project irradiance onto each plane in fetchAndStore. + var arrays []Array + for _, a := range cfg.PVArrays { + if a.KWp > 0 { + arrays = append(arrays, Array{TiltDeg: a.TiltDeg, AzimuthDeg: a.AzimuthDeg, KWp: a.KWp}) + } + } return &Service{ Provider: p, Store: st, Lat: cfg.Latitude, Lon: cfg.Longitude, RatedPVW: ratedPVW, + Arrays: arrays, stop: make(chan struct{}), done: make(chan struct{}), } @@ -341,7 +359,11 @@ func (s *Service) fetchAndStore(ctx context.Context) { switch { case r.PVWEstimated != nil: pvW = *r.PVWEstimated + case r.SolarWm2 != nil && len(s.Arrays) > 0: + // Orientation-aware: project GHI onto each configured plane and sum. + pvW = poaPVWattsFromGHI(s.Lat, s.Lon, r.HourStart, *r.SolarWm2, s.Arrays) case r.SolarWm2 != nil && s.RatedPVW > 0: + // No geometry configured — flat, orientation-blind estimate. pvW = s.RatedPVW * (*r.SolarWm2) / 1000.0 default: pvW = EstimatePVW(s.Lat, s.Lon, r.HourStart, r.CloudCoverPct, s.RatedPVW) @@ -365,6 +387,25 @@ func (s *Service) fetchAndStore(ctx context.Context) { slog.Info("forecast fetched", "count", len(points), "provider", s.Provider.Name()) } +// poaPVWattsFromGHI converts a global-horizontal irradiance (W/m², positive) +// into expected DC PV output (W, positive) by projecting it onto each +// configured array's plane via sunpos and scaling by nameplate. This is the +// orientation-aware replacement for the flat rated×(W/m²/1000) estimate; it +// is used whenever the provider supplies GHI and the site has per-plane +// geometry. Returns 0 when the sun is down or no arrays produce output. +func poaPVWattsFromGHI(lat, lon float64, t time.Time, ghiWm2 float64, arrays []Array) float64 { + var total float64 + for _, a := range arrays { + if a.KWp <= 0 { + continue + } + poa := sunpos.POAFromGHI(t, lat, lon, ghiWm2, a.TiltDeg, a.AzimuthDeg) + // kWp×1000 = nameplate W at STC (1000 W/m²); scale by POA/1000. + total += a.KWp * 1000.0 * (poa / 1000.0) + } + return total +} + // Load returns forecasts in [sinceMs, untilMs]. func (s *Service) Load(sinceMs, untilMs int64) ([]state.ForecastPoint, error) { return s.Store.LoadForecasts(sinceMs, untilMs) diff --git a/go/internal/forecast/forecast_test.go b/go/internal/forecast/forecast_test.go index 73450fa0..e0cb5526 100644 --- a/go/internal/forecast/forecast_test.go +++ b/go/internal/forecast/forecast_test.go @@ -247,3 +247,97 @@ func TestFromConfigBuildsMetNo(t *testing.T) { if s.Lat != 59 { t.Errorf("lat: %f", s.Lat) } if s.RatedPVW != 10000 { t.Errorf("rated: %f", s.RatedPVW) } } + +func TestFromConfigPopulatesArrays(t *testing.T) { + st, _ := state.Open(filepath.Join(t.TempDir(), "t.db")) + defer st.Close() + cfg := &config.Weather{ + Provider: "open_meteo", Latitude: 59, Longitude: 18, + PVArrays: []config.PVArray{ + {Name: "south", KWp: 6, TiltDeg: 35, AzimuthDeg: 180}, + {Name: "east", KWp: 4, TiltDeg: 30, AzimuthDeg: 90}, + {Name: "empty", KWp: 0, TiltDeg: 10, AzimuthDeg: 200}, // skipped (kWp 0) + }, + } + s := FromConfig(cfg, 10000, st, "ua") + if s == nil { t.Fatal("expected service") } + if len(s.Arrays) != 2 { + t.Fatalf("expected 2 arrays (kWp>0 only), got %d", len(s.Arrays)) + } + if s.Arrays[0].KWp != 6 || s.Arrays[1].AzimuthDeg != 90 { + t.Errorf("array geometry mismatch: %+v", s.Arrays) + } +} + +// ---- POA-per-array (orientation-aware) estimate ---- + +func TestPOAPVWattsSumsArrays(t *testing.T) { + tt := time.Date(2026, 6, 21, 11, 0, 0, 0, time.UTC) + one := poaPVWattsFromGHI(59.3293, 18.0686, tt, 700, []Array{{TiltDeg: 35, AzimuthDeg: 180, KWp: 5}}) + two := poaPVWattsFromGHI(59.3293, 18.0686, tt, 700, []Array{ + {TiltDeg: 35, AzimuthDeg: 180, KWp: 5}, + {TiltDeg: 35, AzimuthDeg: 180, KWp: 5}, + }) + if one <= 0 { + t.Fatalf("expected positive POA watts, got %.1f", one) + } + if math.Abs(two-2*one) > 1e-6 { + t.Errorf("two identical arrays should double output: one=%.2f two=%.2f", one, two) + } +} + +func TestPOAPVWattsZeroAtNight(t *testing.T) { + tt := time.Date(2026, 12, 21, 23, 0, 0, 0, time.UTC) + w := poaPVWattsFromGHI(59.3293, 18.0686, tt, 500, []Array{{TiltDeg: 35, AzimuthDeg: 180, KWp: 10}}) + if w != 0 { + t.Errorf("night POA watts should be 0, got %.2f", w) + } +} + +// End-to-end: with per-plane geometry, a GHI-bearing provider's stored PV +// estimate comes from the POA path and differs from the flat rated×GHI/1000. +func TestServicePOAPathDiffersFromFlat(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "hourly": map[string]any{ + "time": []string{"2026-06-21T11:00"}, + "shortwave_radiation": []float64{700}, + "cloud_cover": []float64{5}, + "temperature_2m": []float64{20}, + }, + } + _ = json.NewEncoder(w).Encode(resp) + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + st, _ := state.Open(filepath.Join(t.TempDir(), "state.db")) + defer st.Close() + + p := NewOpenMeteo() + p.BaseURL = srv.URL + s := &Service{ + Provider: p, Store: st, Lat: 59.3293, Lon: 18.0686, RatedPVW: 10000, + Arrays: []Array{{TiltDeg: 35, AzimuthDeg: 180, KWp: 10}}, + } + s.fetchAndStore(context.Background()) + + tt := time.Date(2026, 6, 21, 11, 0, 0, 0, time.UTC) + rows, err := st.LoadForecasts(tt.UnixMilli(), tt.Add(time.Hour).UnixMilli()) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].PVWEstimated == nil { + t.Fatalf("expected 1 forecast with PV estimate, got %+v", rows) + } + got := *rows[0].PVWEstimated + flat := 10000 * 700.0 / 1000.0 // orientation-blind estimate = 7000 W + want := poaPVWattsFromGHI(59.3293, 18.0686, tt, 700, s.Arrays) + if math.Abs(got-want) > 1.0 { + t.Errorf("service should use POA path: got %.1f want %.1f", got, want) + } + if math.Abs(got-flat) < 1.0 { + t.Errorf("POA estimate should differ from flat %.0f, got %.1f", flat, got) + } + t.Logf("POA-per-array estimate %.0fW vs flat %.0fW", got, flat) +} diff --git a/go/internal/sunpos/sunpos.go b/go/internal/sunpos/sunpos.go index 8ce95cc3..787e7c27 100644 --- a/go/internal/sunpos/sunpos.go +++ b/go/internal/sunpos/sunpos.go @@ -125,35 +125,117 @@ func ClearSkyW(t time.Time, lat, lon float64) float64 { } // POA estimates plane-of-array irradiance for one tilted panel using the -// isotropic-sky model. Splits clear-sky horizontal irradiance into beam -// (DNI) and diffuse (DHI) components via a simple Erbs correlation, then -// projects each onto the panel. +// isotropic-sky model, driven by the package's own clear-sky prior. It splits +// that clear-sky horizontal irradiance into beam (DNI) and diffuse (DHI) +// components with a fixed 20% diffuse fraction, then projects each onto the +// panel. Used as the prior signal for the PV twin when no measured irradiance +// is available. // // Returns W/m² on the panel surface; clamped to ≥ 0. +// +// When a data source supplies measured irradiance, prefer the two variants +// below: POAFromComponents (GHI + DHI both known, e.g. SMHI STRÅNG params +// 117 + 122) or POAFromGHI (only GHI known, e.g. Open-Meteo shortwave). func POA(t time.Time, lat, lon, panelTiltDeg, panelAzDeg float64) float64 { sun := At(t, lat, lon) - if sun.ZenithDeg >= 90 { - return 0 - } ghi := ClearSkyW(t, lat, lon) - if ghi <= 0 { + // No measured diffuse component from the clear-sky prior, so keep the + // historical fixed 20% diffuse fraction for this variant. + return POAFromComponents(sun, ghi, 0.2*ghi, panelTiltDeg, panelAzDeg) +} + +// POAFromComponents projects measured global (GHI) and diffuse (DHI) +// horizontal irradiance onto a tilted panel using the isotropic-sky model, +// reusing AOI for the beam projection. All irradiances in W/m²; returns the +// plane-of-array irradiance in W/m², clamped ≥ 0. +// +// Use this when a source gives both GHI and DHI directly (e.g. SMHI STRÅNG +// parameters 117 + 122). When only GHI is available use POAFromGHI, which +// estimates the diffuse split via the Erbs correlation first. +func POAFromComponents(sun Position, ghi, dhi, panelTiltDeg, panelAzDeg float64) float64 { + if sun.ZenithDeg >= 90 || ghi <= 0 { return 0 } - // Erbs et al. (1982) clearness-based diffuse fraction. We don't have - // real GHI measurements, so kt comes from our own clear-sky model → - // always ~0.7-0.75 → diffuse ratio ~0.2. Conservative. - kd := 0.2 - dhi := ghi * kd - dni := (ghi - dhi) / math.Cos(sun.ZenithDeg*math.Pi/180) - + if dhi < 0 { + dhi = 0 + } + if dhi > ghi { + dhi = ghi + } + tiltR := panelTiltDeg * math.Pi / 180 + diffusePOA := dhi * (1 + math.Cos(tiltR)) / 2 // isotropic sky dome aoi := AOI(sun, panelTiltDeg, panelAzDeg) if aoi > 90 { - // Sun behind panel — only diffuse counts. - return dhi * (1 + math.Cos(panelTiltDeg*math.Pi/180)) / 2 + // Sun behind the panel — only diffuse reaches the surface. + return diffusePOA } + cosZ := math.Cos(sun.ZenithDeg * math.Pi / 180) + if cosZ < 0.01 { + // Sun on the horizon: beam projection is ill-conditioned + // (divide-by-~0) and diffuse dominates anyway. + return diffusePOA + } + dni := (ghi - dhi) / cosZ beamPOA := dni * math.Cos(aoi*math.Pi/180) - diffusePOA := dhi * (1 + math.Cos(panelTiltDeg*math.Pi/180)) / 2 out := beamPOA + diffusePOA - if out < 0 { out = 0 } + if out < 0 { + out = 0 + } return out } + +// POAFromGHI projects a measured/forecast global horizontal irradiance (GHI, +// W/m²) onto a tilted panel when no diffuse component is available. It +// estimates the diffuse fraction from the hourly clearness index via the Erbs +// et al. (1982) correlation, then delegates to POAFromComponents. +// +// Use this for radiation providers that expose shortwave/GHI but not diffuse +// (e.g. Open-Meteo shortwave_radiation, or SMHI STRÅNG global-only windows). +func POAFromGHI(t time.Time, lat, lon, ghi, panelTiltDeg, panelAzDeg float64) float64 { + sun := At(t, lat, lon) + if sun.ZenithDeg >= 90 || ghi <= 0 { + return 0 + } + cosZ := math.Cos(sun.ZenithDeg * math.Pi / 180) + i0h := extraterrestrialHorizontalW(t, cosZ) + kt := 0.0 + if i0h > 0 { + kt = ghi / i0h + } + dhi := ghi * ErbsDiffuseFraction(kt) + return POAFromComponents(sun, ghi, dhi, panelTiltDeg, panelAzDeg) +} + +// ErbsDiffuseFraction returns the diffuse fraction (DHI/GHI) for an hourly +// clearness index kt, per Erbs, Klein & Duffie (1982). Result is in +// [0.165, 1]: overcast skies (low kt) are almost entirely diffuse, clear +// skies (high kt) settle near 16.5% diffuse. +func ErbsDiffuseFraction(kt float64) float64 { + switch { + case kt <= 0: + return 1 + case kt <= 0.22: + return 1 - 0.09*kt + case kt <= 0.80: + return 0.9511 - 0.1604*kt + 4.388*kt*kt - 16.638*kt*kt*kt + 12.336*kt*kt*kt*kt + default: + return 0.165 + } +} + +// extraterrestrialHorizontalW returns top-of-atmosphere irradiance on a +// horizontal surface (W/m²) at time t for a solar cosine-zenith cosZ. Used as +// the denominator of the clearness index. Returns 0 when the sun is at/below +// the horizon. +func extraterrestrialHorizontalW(t time.Time, cosZ float64) float64 { + if cosZ <= 0 { + return 0 + } + doy := float64(t.UTC().YearDay()) + gamma := 2 * math.Pi * (doy - 1) / 365 + e0 := 1.000110 + + 0.034221*math.Cos(gamma) + 0.001280*math.Sin(gamma) + + 0.000719*math.Cos(2*gamma) + 0.000077*math.Sin(2*gamma) + const i0 = 1361.0 // solar constant W/m² + return i0 * e0 * cosZ +} diff --git a/go/internal/sunpos/sunpos_test.go b/go/internal/sunpos/sunpos_test.go index efe085fa..fb48f8d5 100644 --- a/go/internal/sunpos/sunpos_test.go +++ b/go/internal/sunpos/sunpos_test.go @@ -66,3 +66,90 @@ func TestPOAFlatEqualsGHI(t *testing.T) { t.Errorf("flat POA should equal GHI: ghi=%.1f poa=%.1f", ghi, poa) } } + +// A flat panel receives all of GHI regardless of the diffuse split, because +// beam-on-horizontal + diffuse-on-horizontal reconstructs GHI exactly. +func TestPOAFromComponentsFlatEqualsGHI(t *testing.T) { + tt := time.Date(2026, 6, 21, 11, 0, 0, 0, time.UTC) + sun := At(tt, 59.33, 18.07) + const ghi = 700.0 + for _, dhi := range []float64{0, 140, 350, 700} { + poa := POAFromComponents(sun, ghi, dhi, 0, 180) + if math.Abs(poa-ghi) > 0.5 { + t.Errorf("flat POA should equal GHI for dhi=%.0f: got %.2f want %.0f", dhi, poa, ghi) + } + } +} + +// A south-tilted panel at Stockholm winter noon (low sun) collects more than a +// flat one — the whole point of projecting onto the plane. +func TestPOAFromComponentsSouthTiltBeatsFlatInWinter(t *testing.T) { + tt := time.Date(2026, 12, 21, 11, 0, 0, 0, time.UTC) // ~noon local, low sun + sun := At(tt, 59.33, 18.07) + if sun.ZenithDeg >= 90 { + t.Skip("sun below horizon") + } + const ghi, dhi = 200.0, 60.0 + flat := POAFromComponents(sun, ghi, dhi, 0, 180) + tilt := POAFromComponents(sun, ghi, dhi, 45, 180) // 45° south + if tilt <= flat { + t.Errorf("south-tilted winter POA (%.1f) should beat flat (%.1f)", tilt, flat) + } +} + +// Sun behind the panel → only the diffuse dome contributes (no negative beam). +func TestPOAFromComponentsSunBehindPanelIsDiffuseOnly(t *testing.T) { + tt := time.Date(2026, 6, 21, 5, 0, 0, 0, time.UTC) // morning, sun in the east + sun := At(tt, 59.33, 18.07) + if sun.ZenithDeg >= 90 { + t.Skip("sun below horizon") + } + const ghi, dhi = 300.0, 90.0 + // A steep west-facing wall can't see the eastern morning sun's beam. + poa := POAFromComponents(sun, ghi, dhi, 90, 270) + diffuseOnly := dhi * (1 + math.Cos(90*math.Pi/180)) / 2 + if math.Abs(poa-diffuseOnly) > 0.5 { + t.Errorf("sun-behind panel should be diffuse-only %.2f, got %.2f", diffuseOnly, poa) + } +} + +// Erbs diffuse fraction: overcast → ~all diffuse, clear → floor at 0.165, +// monotonically non-increasing across the mid range. +func TestErbsDiffuseFraction(t *testing.T) { + if f := ErbsDiffuseFraction(0); f != 1 { + t.Errorf("kt=0 → fraction 1, got %.3f", f) + } + if f := ErbsDiffuseFraction(1.0); math.Abs(f-0.165) > 1e-9 { + t.Errorf("kt>0.8 → 0.165, got %.3f", f) + } + clear := ErbsDiffuseFraction(0.75) + murky := ErbsDiffuseFraction(0.30) + if !(clear < murky) { + t.Errorf("clearer sky should have less diffuse: clear=%.3f murky=%.3f", clear, murky) + } + if clear < 0.165 || clear > 1 { + t.Errorf("fraction out of [0.165,1]: %.3f", clear) + } +} + +// POAFromGHI (Erbs split) on a flat panel still reconstructs ~GHI. +func TestPOAFromGHIFlatApproxGHI(t *testing.T) { + tt := time.Date(2026, 6, 21, 11, 0, 0, 0, time.UTC) + const ghi = 600.0 + poa := POAFromGHI(tt, 59.33, 18.07, ghi, 0, 180) + if math.Abs(poa-ghi) > 0.5 { + t.Errorf("flat POAFromGHI should equal GHI: got %.2f want %.0f", poa, ghi) + } +} + +// Night → zero from both measured-irradiance variants regardless of input. +func TestPOAVariantsZeroAtNight(t *testing.T) { + tt := time.Date(2026, 12, 21, 23, 0, 0, 0, time.UTC) + sun := At(tt, 59.33, 18.07) + if p := POAFromComponents(sun, 500, 100, 35, 180); p != 0 { + t.Errorf("night POAFromComponents should be 0, got %.2f", p) + } + if p := POAFromGHI(tt, 59.33, 18.07, 500, 35, 180); p != 0 { + t.Errorf("night POAFromGHI should be 0, got %.2f", p) + } +} From 2cfca176ad4fdaaec0e5f71c8b5af34175d4ba96 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Fri, 31 Jul 2026 10:34:03 +0200 Subject: [PATCH 2/2] feat(weather): add STRANG as an irradiance source and declare source coverage STRANG becomes a real irradiance source rather than an unreferenced client, every external data source now says where in the world it works, and the location picker moves off Leaflet. STRANG's parameter set was mapped against the live API because SMHI's apidocs pages 404: exactly 116-122 exist. Identification was confirmed by physics rather than by guessing - at solar noon 121 + 122 = 723.0 + 87.5 = 810.5, exactly parameter 117, and 119 caps at 60, i.e. minutes within the hour. STRANG publishes no cloud cover; it is a radiation model. Cloudiness is instead derived from sunshine duration as 1 - minutes/60, which is observed rather than inferred but coarser: blind to thin cirrus, undefined at night. CloudCover() therefore returns an explicit unknown instead of defaulting to clear, because those two lead to opposite decisions. The new coverage registry makes an existing silence explicit. STRANG is Nordic-only and every price provider is European, so sites elsewhere were getting empty results with no explanation (#726). GET /api/data-sources now reports area, countries, licence and whether each source reaches this site, and the Weather tab renders it under the map. Bounds are advisory: STRANG's grid is rotated, so a lat/lon box can only ever be a superset - all four in-box corners were probed and returned no data. False is definitive, true means worth trying. Scoring now declines to start outside the domain instead of retrying nightly forever. Stacked on #718, which carries the plane-of-array wiring this builds on. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com> --- .changeset/strang-source-coverage.md | 46 ++++ README.md | 1 + docs/data-coverage.md | 154 ++++++++++++ go/cmd/ftw/main.go | 22 ++ go/internal/api/api.go | 143 +++++++++++ go/internal/api/api_datasources_test.go | 157 ++++++++++++ go/internal/api/api_pvperf_test.go | 91 +++++++ go/internal/coverage/coverage.go | 188 +++++++++++++++ go/internal/coverage/coverage_test.go | 174 ++++++++++++++ go/internal/forecast/forecast.go | 33 ++- go/internal/forecast/forecast_test.go | 80 +++++++ go/internal/mpc/external_optimizer.go | 6 +- go/internal/mpc/pvband.go | 37 +++ go/internal/mpc/pvband_test.go | 86 +++++++ go/internal/pvperf/calibration.go | 117 +++++++++ go/internal/pvperf/calibration_test.go | 153 ++++++++++++ go/internal/pvperf/pvperf.go | 88 +++++++ go/internal/pvperf/pvperf_test.go | 83 +++++++ go/internal/pvperf/service.go | 221 +++++++++++++++++ go/internal/pvperf/service_test.go | 132 ++++++++++ go/internal/state/pvperf.go | 138 +++++++++++ go/internal/state/pvperf_test.go | 119 +++++++++ go/internal/state/store.go | 28 +++ go/internal/strang/strang.go | 232 ++++++++++++++++++ go/internal/strang/strang_test.go | 306 ++++++++++++++++++++++++ web/components/ftw-bar-chart.js | 68 +++++- web/components/ftw-history-card.js | 113 ++++++++- web/components/index.js | 4 +- web/index.html | 10 +- web/settings/tabs/weather.js | 175 +++++++++++--- 30 files changed, 3156 insertions(+), 49 deletions(-) create mode 100644 .changeset/strang-source-coverage.md create mode 100644 docs/data-coverage.md create mode 100644 go/internal/api/api_datasources_test.go create mode 100644 go/internal/api/api_pvperf_test.go create mode 100644 go/internal/coverage/coverage.go create mode 100644 go/internal/coverage/coverage_test.go create mode 100644 go/internal/mpc/pvband.go create mode 100644 go/internal/mpc/pvband_test.go create mode 100644 go/internal/pvperf/calibration.go create mode 100644 go/internal/pvperf/calibration_test.go create mode 100644 go/internal/pvperf/pvperf.go create mode 100644 go/internal/pvperf/pvperf_test.go create mode 100644 go/internal/pvperf/service.go create mode 100644 go/internal/pvperf/service_test.go create mode 100644 go/internal/state/pvperf.go create mode 100644 go/internal/state/pvperf_test.go create mode 100644 go/internal/strang/strang.go create mode 100644 go/internal/strang/strang_test.go diff --git a/.changeset/strang-source-coverage.md b/.changeset/strang-source-coverage.md new file mode 100644 index 00000000..6fc1a2b0 --- /dev/null +++ b/.changeset/strang-source-coverage.md @@ -0,0 +1,46 @@ +--- +"ftw": minor +--- + +SMHI STRÅNG becomes a first-class irradiance source, every external data source +now declares where in the world it works, and the location picker moves to +MapLibre GL JS. + +- **STRÅNG as an irradiance source.** The client now covers the model's full + parameter set and knows its own domain. A nightly backfill scores measured + production against the DC energy the configured arrays should have produced + under that irradiance, exposed at `GET /api/pv/performance` and drawn as a + dashed "expected (STRÅNG)" overlay on the Produced tile. The resulting + performance ratio feeds back as a calibration factor on the forward forecast, + refused outright when it lands outside a plausible band — a site reading at + 10% or 160% of nameplate is a configuration fault, and silently rescaling the + forecast would hide it. + +- **Cloud cover, derived.** STRÅNG publishes no cloud-cover parameter; it is a + radiation model. It does publish sunshine duration (minutes per hour above the + WMO beam threshold), so cloudiness is recovered as `1 − minutes/60`. That is an + observed quantity rather than an inferred cloud field, but coarser: blind to + thin cirrus, and undefined at night. The API returns an explicit *unknown* + rather than defaulting to *clear*, because those lead to opposite decisions. + +- **Coverage metadata.** New `GET /api/data-sources` reports every forecast, + irradiance and price source with its coverage area, country list, licence and + whether it reaches this specific site; the Weather tab renders it under the + map and flags sources that do not. This makes an existing silence explicit: + STRÅNG is Nordic-only and every price provider is European, so sites elsewhere + were getting empty results with no explanation. Bounds are advisory — a + rotated model grid means a lat/lon box can only be a superset — so `false` is + definitive and `true` means "worth trying". PV performance scoring now declines + to start outside the STRÅNG domain instead of retrying nightly forever. + +- **MapLibre GL JS location picker.** The Weather tab's map is now MapLibre GL + JS 6 (BSD-3) instead of Leaflet, still lazy-loaded only when the tab opens. v6 + is ESM-only and code-split, so it loads via a pinned dynamic import; the + stylesheet keeps its integrity hash, while the JS relies on version pinning + (an integrity hash on the entry point would not cover the shared chunk it + imports anyway). The style is built inline from the same OpenStreetMap raster + tiles as before, so neither the tile source nor the attribution changed. The + numeric latitude/longitude fields remain authoritative, so a CDN or WebGL + failure costs the picker and nothing else. + +Nothing here touches the control tick, dispatch or the optimizer contract. diff --git a/README.md b/README.md index bf287419..03a2eb72 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,7 @@ metadata are the detailed reference. - [Product roadmap](docs/roadmap.md) - [Power sign convention](docs/site-convention.md) - [Safety invariants](docs/safety.md) +- [Geographic coverage of external data](docs/data-coverage.md) - [Operations and recovery](docs/operations.md) - [Full backup and safe restore](docs/backup-and-restore.md) - [Writing a driver](docs/writing-a-driver.md) diff --git a/docs/data-coverage.md b/docs/data-coverage.md new file mode 100644 index 00000000..a38a678a --- /dev/null +++ b/docs/data-coverage.md @@ -0,0 +1,154 @@ +# Geographic coverage of external data sources + +FTW controls hardware anywhere, but it depends on external data for three +things: **spot prices**, **weather/PV forecasts** and **PV performance +scoring**. Those three have very different geographic reach, and the difference +decides how much of FTW is useful at a given site. + +Short version: + +- **Weather and PV forecasting works worldwide.** +- **Price-driven planning works in Europe only.** +- **PV performance scoring works in the Nordic region only.** +- **Roof geometry (planned) is Sweden only.** + +A site outside Europe can still run FTW for monitoring, safety and control — but +the economic optimisation that motivates most of the planner has no price source +to work from. + +`GET /api/data-sources` answers this per site: it returns every source with its +coverage area and, when the site location is known, whether that source reaches +it. The Weather settings tab renders the same data under the map. This file is +the prose; `go/internal/coverage` is the machine-readable source of truth, and +the two are meant to stay in step. + +> **Coverage bounds are advisory.** Each bounded source declares a lat/lon box, +> but STRÅNG's model grid is rotated relative to lat/lon, so its box is a +> *superset* of the real domain — points near a corner pass the box test and +> still return nothing. Treat `covers: false` as definitive and `covers: true` +> as "worth trying". The upstream API is always the final word. + +## Spot prices — Europe only + +Configured under `price.provider`. + +| Provider | Coverage | API key | Notes | +|---|---|---|---| +| `sourceful` | European day-ahead markets | No | Default. Sourceful's cached ENTSO-E API. | +| `elprisetjustnu` | **Sweden only** — zones SE1–SE4 | No | 15-minute PTU since late 2025. | +| `entsoe` | ENTSO-E member markets (most of Europe) | Yes | Direct from the Transparency Platform. | +| `none` | — | — | Disables price fetching entirely. | + +There is **no provider for any market outside Europe**. North America (CAISO, +ERCOT, PJM, ISO-NE, NYISO, MISO, SPP, AESO, IESO), Australia (AEMO/NEM), Japan +(JEPX) and everywhere else are unsupported, and there is no manual or +fixed-tariff provider to stand in for them. + +Two further Europe-centric assumptions live in the price layer: prices are +stored internally in **öre** (1 SEK = 100 öre), and ENTSO-E's EUR/MWh figures +are converted using **ECB** daily FX rates. + +> The Tibber driver (`drivers/tibber.lua`) is telemetry only — it reports meter +> readings, not prices, so it is not a fourth price source. + +## Weather and PV forecasts — worldwide + +Configured under `weather.provider`. All four work at any latitude/longitude. + +| Provider | Coverage | API key | Signal quality | +|---|---|---|---| +| `met_no` | Global | No | Cloud cover only — weakest PV signal. | +| `openweather` | Global | Yes | Cloud cover only. | +| `open_meteo` | Global | No | Shortwave radiation (GHI) — good. | +| `forecast_solar` | Global | No (free tier) | Site-calibrated watts from panel geometry — best. | + +Accuracy varies by region because the underlying numerical weather models do, +but none of these are geographically gated. Outside the Nordics, prefer +`open_meteo` or `forecast_solar`: they carry an irradiance signal, which is what +the orientation-aware plane-of-array model needs. + +## PV performance scoring — Nordic region only + +The scorer (`GET /api/pv/performance`) compares measured production against a +physics baseline built from **SMHI STRÅNG** irradiance. STRÅNG is a mesoscale +analysis product covering the **Nordic region** hourly at ~2.5 km from 1999 to +roughly one day ago. It is free, keyless and CC BY 4.0. + +Outside that domain STRÅNG returns no data, so scoring simply never produces +rows and the dashboard overlay stays hidden. Nothing fails loudly; the feature +is just unavailable. + +Because the same scoring feeds the **forecast calibration factor**, sites +outside the STRÅNG domain also do not get measured calibration of their PV +forecast — they fall back to the uncalibrated physics estimate. + +STRÅNG has **no forward horizon**. It is never used as a forecast provider; see +[architecture.md](architecture.md) for where it sits. + +### What STRÅNG actually publishes + +Probing the live API on 2026-07-31 (SMHI's own apidocs pages currently 404) +returned data for exactly seven parameters and 404 for everything else. Names +were confirmed from their magnitudes on a clear day rather than from docs: + +| Code | Quantity | Unit | Noon value, Stockholm 2026-06-21 | +|---|---|---|---| +| 116 | CIE-weighted UV irradiance | mW/m² | 146.6 | +| 117 | **Global horizontal (GHI)** | W/m² | 810.5 | +| 118 | Direct normal (DNI) | W/m² | 917.2 | +| 119 | **Sunshine duration** | min/h | 60.0 | +| 120 | Photosynthetically active radiation | W/m² | 357.9 | +| 121 | Direct horizontal | W/m² | 723.0 | +| 122 | **Diffuse horizontal (DHI)** | W/m² | 87.5 | + +Two checks confirm the identification: 121 + 122 = 723.0 + 87.5 = 810.5, exactly +parameter 117 (direct + diffuse = global), and 119 caps at exactly 60, i.e. +minutes within the hour. + +**STRÅNG publishes no cloud cover.** It is a radiation model; cloudiness is not +among its outputs. It is however *derivable*: parameter 119 counts the minutes +in each hour during which direct beam irradiance exceeded the WMO sunshine +threshold, so `1 − minutes/60` is the fraction of the hour the sun spent +obscured. That is an observed quantity rather than an inferred cloud field, but +it is coarser than a forecast provider's cloud percentage — it cannot see thin +cirrus that dims without blocking. FTW exposes it via +`strang.IrradianceHour.CloudCover(lat, lon)`, which returns an explicit +"unknown" rather than defaulting to "clear". + +The location argument is not decoration. Sunshine duration is zero at night for +the trivial reason that there is no sun, and zero again near sunrise and sunset +because the beam crosses ten or more air masses and cannot reach the 120 W/m² +threshold even under a spotless sky. Both would read as "100% overcast" if taken +at face value. `CloudCover` therefore declines to answer unless the sun clears +**5° of elevation** at some point in the hour, sampling the hour's start, +midpoint and end so the hour in which the sun crosses that line is still counted. + +Live data from 2026-06-21 at Stockholm shows the distinction: + +| Hour (UTC) | GHI W/m² | Sunshine | Cloud cover | +|---|---|---|---| +| 00:00 | 0.0 | 0 min | *unknown* — sun below horizon | +| 04:00 | 163.2 | 60 min | 0% | +| 12:00 | 810.5 | 60 min | 0% | +| 20:00 | 2.5 | 0 min | *unknown* — sun minutes from setting | + +## Roof geometry — Sweden only (planned) + +The roof-derivation module proposed in +[RFC #717](https://github.com/srcfl/ftw/discussions/717) reads **Lantmäteriet** +building footprints and LiDAR, which exist for **Sweden only** and require a +Geotorget account. Everywhere else, panel tilt/azimuth/kWp stays a manual entry +in the Weather settings tab — which is the fallback by design, not a +degraded mode. + +## What a non-European site loses + +| Capability | Works outside Europe? | +|---|---| +| Device control, safety, dispatch | Yes | +| Telemetry, history, dashboard | Yes | +| Weather + PV forecasting | Yes | +| Self-learning PV twin | Yes | +| Price-driven planning / optimisation | **No** — no price source | +| PV performance scoring + calibration | **No** — outside STRÅNG's domain | +| Automatic roof geometry | **No** — Sweden only | diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 509f7946..a455a193 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -53,6 +53,7 @@ import ( "github.com/srcfl/ftw/go/internal/prices" "github.com/srcfl/ftw/go/internal/proxy" "github.com/srcfl/ftw/go/internal/pvmodel" + "github.com/srcfl/ftw/go/internal/pvperf" "github.com/srcfl/ftw/go/internal/selftune" "github.com/srcfl/ftw/go/internal/selfupdate" "github.com/srcfl/ftw/go/internal/state" @@ -992,6 +993,26 @@ func main() { "lat", forecastSvc.Lat, "lon", forecastSvc.Lon, "rated_pv_w", ratedPVW) } + // ---- Start PV performance scoring (optional) ---- + // Nightly backfill of SMHI STRÅNG historical irradiance + expected-vs-actual + // PV scoring. Nil when the site has no PV geometry to score against. This is + // read-only with respect to control: it only fetches weather data and writes + // the irradiance_history + pv_performance_daily tables. + pvPerfSvc := pvperf.FromConfig(cfg.Weather, ratedPVW, st, + "ftw/"+Version+" github.com/srcfl/ftw") + if pvPerfSvc != nil { + pvPerfSvc.Start(ctx) + defer pvPerfSvc.Stop() + // Close the loop: measured performance calibrates the forward + // forecast. The hook is read at fetch time, so it starts correcting + // as soon as enough days are scored — no restart needed. + if forecastSvc != nil { + forecastSvc.Calibration = pvPerfSvc.CalibrationFactor + } + slog.Info("pv performance scoring started", + "lat", pvPerfSvc.Lat, "lon", pvPerfSvc.Lon, "arrays", len(pvPerfSvc.Arrays)) + } + // ---- Start PV digital twin (optional, requires weather config) ---- // pvSvc is pre-declared above so the reload Applier can update it. if cfg.Weather != nil && cfg.Weather.Provider != "" && cfg.Weather.Provider != "none" { @@ -2081,6 +2102,7 @@ func main() { SnapshotDir: filepath.Join(filepath.Dir(statePath), "snapshots"), Prices: priceSvc, Forecast: forecastSvc, + PVPerf: pvPerfSvc, MPC: mpcSvc, PVModel: pvSvc, LoadModel: loadSvc, diff --git a/go/internal/api/api.go b/go/internal/api/api.go index fa5ed888..a3255909 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -27,6 +27,7 @@ import ( "github.com/srcfl/ftw/go/internal/battery" "github.com/srcfl/ftw/go/internal/calendar" "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/coverage" "github.com/srcfl/ftw/go/internal/control" "github.com/srcfl/ftw/go/internal/driverrepo" "github.com/srcfl/ftw/go/internal/drivers" @@ -40,6 +41,7 @@ import ( "github.com/srcfl/ftw/go/internal/notifications" "github.com/srcfl/ftw/go/internal/prices" "github.com/srcfl/ftw/go/internal/pvmodel" + "github.com/srcfl/ftw/go/internal/pvperf" "github.com/srcfl/ftw/go/internal/scanner" "github.com/srcfl/ftw/go/internal/selftune" "github.com/srcfl/ftw/go/internal/selfupdate" @@ -106,6 +108,10 @@ type Deps struct { Prices *prices.Service Forecast *forecast.Service + // Optional: STRÅNG-based PV performance scoring. Nil when the site has no + // PV geometry to score against (surfaced as {enabled:false}). + PVPerf *pvperf.Service + // Optional: MPC planner. Nil if disabled. MPC *mpc.Service @@ -344,6 +350,8 @@ func (s *Server) routes() { s.handle("POST /api/pv/manual_hold", s.handlePVManualHold) s.handle("DELETE /api/pv/manual_hold", s.handlePVManualHoldClear) s.handle("GET /api/pv/manual_hold", s.handlePVManualHoldGet) + s.handle("GET /api/pv/performance", s.handlePVPerformance) + s.handle("GET /api/data-sources", s.handleDataSources) s.handle("GET /api/version/check", s.handleVersionCheck) s.handle("POST /api/version/channel", s.handleVersionChannel) s.handle("POST /api/version/skip", s.handleVersionSkip) @@ -2018,6 +2026,141 @@ func (s *Server) handleForecast(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"items": rows, "enabled": true}) } +// ---- /api/data-sources ---- +// +// Where each external data source works, and whether it covers this site. +// Response: {latitude, longitude, sources:[{id, kind, label, area, countries, +// worldwide, requires_key, license, note, covers}]}. `covers` is advisory: for +// a bounded source it is a lat/lon box test, and STRÅNG's grid is rotated, so a +// true near a corner still means "worth trying", not "guaranteed". False is +// reliable — that location is definitely not served. +// +// This exists because several sources are regional (STRÅNG is Nordic-only, +// every price provider is European) and nothing previously said so: a site +// outside those areas got an empty result and no explanation. See #726. +func (s *Server) handleDataSources(w http.ResponseWriter, r *http.Request) { + var lat, lon float64 + var haveSite bool + // Weather is an optional config section, so it is nil on a site that has + // never configured one — which is exactly the site most likely to be + // looking at this endpoint. + if s.deps.CfgMu != nil { + s.deps.CfgMu.RLock() + if s.deps.Cfg != nil && s.deps.Cfg.Weather != nil { + lat, lon = s.deps.Cfg.Weather.Latitude, s.deps.Cfg.Weather.Longitude + haveSite = lat != 0 || lon != 0 + } + s.deps.CfgMu.RUnlock() + } + + // An explicit ?lat=&lon= overrides the configured site so the Weather tab + // can preview coverage for a pin the operator is still dragging around, + // before they save it. + if v := r.URL.Query().Get("lat"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + lat, haveSite = f, true + } + } + if v := r.URL.Query().Get("lon"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + lon, haveSite = f, true + } + } + + items := make([]map[string]any, 0, len(coverage.All())) + for _, src := range coverage.All() { + item := map[string]any{ + "id": src.ID, + "kind": string(src.Kind), + "label": src.Label, + "area": src.Area, + "worldwide": src.Worldwide(), + "requires_key": src.RequiresKey, + } + if len(src.Countries) > 0 { + item["countries"] = src.Countries + } + if src.License != "" { + item["license"] = src.License + } + if src.Note != "" { + item["note"] = src.Note + } + // Without a site location there is nothing to test against, so omit + // `covers` entirely rather than defaulting it to a misleading true. + if haveSite { + item["covers"] = src.Covers(lat, lon) + } + items = append(items, item) + } + resp := map[string]any{"sources": items} + if haveSite { + resp["latitude"], resp["longitude"] = lat, lon + } + writeJSON(w, 200, resp) +} + +// ---- /api/pv/performance ---- +// +// STRÅNG-based expected-vs-actual PV performance scoring. Query param days=N +// (default 30, max 365) selects the lookback window. Response: +// {enabled, items:[{day, expected_wh, actual_wh, pr, ...}], performance_ratio, +// attribution}. Returns {enabled:false} when scoring is unavailable (no PV +// geometry configured). +func (s *Server) handlePVPerformance(w http.ResponseWriter, r *http.Request) { + if s.deps.PVPerf == nil { + writeJSON(w, 200, map[string]any{"items": []any{}, "enabled": false}) + return + } + days := 30 + if v := r.URL.Query().Get("days"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + days = n + } + } + if days > 365 { + days = 365 + } + now := time.Now() + loc := now.Location() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + sinceDay := today.AddDate(0, 0, -days).Format("2006-01-02") + untilDay := today.Format("2006-01-02") + items, err := s.deps.PVPerf.Load(sinceDay, untilDay) + if err != nil { + writeJSON(w, 500, map[string]string{"error": err.Error()}) + return + } + // Energy-weighted overall performance ratio across the window — only days + // with a meaningful expected baseline (pr != null) contribute. + var sumExpected, sumActual float64 + for _, it := range items { + if it.PR != nil { + sumExpected += it.ExpectedWh + sumActual += it.ActualWh + } + } + // The calibration is reported over its own fixed window rather than the + // caller's, so a short ?days= request cannot make the site look + // uncalibrated. "applied" is what actually reaches the forward forecast. + cal := s.deps.PVPerf.Calibration() + resp := map[string]any{ + "items": items, + "enabled": true, + "attribution": "Irradiance: SMHI STRÅNG (CC BY 4.0)", + "calibration": map[string]any{ + "factor": cal.Factor, + "sigma_rel": cal.SigmaRel, + "days": cal.Days, + "applied": cal.Valid, + }, + } + if sumExpected > 0 { + resp["performance_ratio"] = sumActual / sumExpected + } + writeJSON(w, 200, resp) +} + // ---- MPC planner ---- func (s *Server) handleMPCPlan(w http.ResponseWriter, r *http.Request) { diff --git a/go/internal/api/api_datasources_test.go b/go/internal/api/api_datasources_test.go new file mode 100644 index 00000000..74fd3dde --- /dev/null +++ b/go/internal/api/api_datasources_test.go @@ -0,0 +1,157 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/srcfl/ftw/go/internal/config" +) + +type dataSource struct { + ID string `json:"id"` + Kind string `json:"kind"` + Label string `json:"label"` + Area string `json:"area"` + Countries []string `json:"countries"` + Worldwide bool `json:"worldwide"` + RequiresKey bool `json:"requires_key"` + Note string `json:"note"` + Covers *bool `json:"covers"` +} + +type dataSourcesResp struct { + Latitude *float64 `json:"latitude"` + Longitude *float64 `json:"longitude"` + Sources []dataSource `json:"sources"` +} + +func getDataSources(t *testing.T, deps *Deps, query string) dataSourcesResp { + t.Helper() + srv := New(deps) + req := httptest.NewRequest(http.MethodGet, "/api/data-sources"+query, nil) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != 200 { + t.Fatalf("status = %d, want 200", rr.Code) + } + var resp dataSourcesResp + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + return resp +} + +func depsAt(lat, lon float64) *Deps { + cfg := &config.Config{Weather: &config.Weather{Latitude: lat, Longitude: lon}} + return &Deps{Cfg: cfg, CfgMu: &sync.RWMutex{}} +} + +func find(t *testing.T, resp dataSourcesResp, id string) dataSource { + t.Helper() + for _, s := range resp.Sources { + if s.ID == id { + return s + } + } + t.Fatalf("source %q missing from response", id) + return dataSource{} +} + +func TestDataSourcesListsEverySource(t *testing.T) { + resp := getDataSources(t, depsAt(59.33, 18.07), "") + for _, id := range []string{ + "met_no", "openweather", "open_meteo", "forecast_solar", + "strang", "sourceful", "elprisetjustnu", "entsoe", + } { + find(t, resp, id) // fails the test if absent + } +} + +// A Nordic site: STRÅNG and the Swedish price feed both apply. +func TestDataSourcesCoversNordicSite(t *testing.T) { + resp := getDataSources(t, depsAt(59.33, 18.07), "") + for _, id := range []string{"strang", "elprisetjustnu", "sourceful", "open_meteo"} { + s := find(t, resp, id) + if s.Covers == nil || !*s.Covers { + t.Errorf("%s: want covers=true for Stockholm", id) + } + } +} + +// The case that motivated this endpoint: outside Europe the forecast still +// works, but irradiance scoring and every price provider do not. +func TestDataSourcesExplainsWhySydneyIsLimited(t *testing.T) { + resp := getDataSources(t, depsAt(-33.87, 151.21), "") + + for _, id := range []string{"met_no", "openweather", "open_meteo", "forecast_solar"} { + s := find(t, resp, id) + if s.Covers == nil || !*s.Covers { + t.Errorf("%s: forecast providers are worldwide, want covers=true", id) + } + } + for _, id := range []string{"strang", "sourceful", "elprisetjustnu", "entsoe"} { + s := find(t, resp, id) + if s.Covers == nil || *s.Covers { + t.Errorf("%s: want covers=false in Sydney", id) + } + if s.Note == "" && s.Area == "" { + t.Errorf("%s: an uncovered source must still explain its area", id) + } + } +} + +// The Weather tab previews a pin before it is saved, so an explicit lat/lon +// must override the configured site. +func TestDataSourcesQueryOverridesConfiguredSite(t *testing.T) { + deps := depsAt(59.33, 18.07) // configured: Stockholm + resp := getDataSources(t, deps, "?lat=-33.87&lon=151.21") + if s := find(t, resp, "strang"); s.Covers == nil || *s.Covers { + t.Error("query lat/lon should override config and report not covered") + } + if resp.Latitude == nil || *resp.Latitude != -33.87 { + t.Errorf("latitude = %v, want the overridden -33.87", resp.Latitude) + } +} + +// With no location configured there is nothing to test against, so `covers` +// must be absent rather than defaulting to a misleading true. +func TestDataSourcesOmitsCoversWithoutASite(t *testing.T) { + resp := getDataSources(t, &Deps{}, "") + if len(resp.Sources) == 0 { + t.Fatal("sources should still be listed without a site") + } + for _, s := range resp.Sources { + if s.Covers != nil { + t.Errorf("%s: covers should be omitted when no site is known", s.ID) + } + } + if resp.Latitude != nil || resp.Longitude != nil { + t.Error("latitude/longitude should be omitted when no site is known") + } +} + +// Metadata is the whole point of the endpoint; assert it actually arrives. +func TestDataSourcesCarriesRegionMetadata(t *testing.T) { + resp := getDataSources(t, depsAt(59.33, 18.07), "") + + strang := find(t, resp, "strang") + if strang.Worldwide { + t.Error("strang must not be reported worldwide") + } + if strang.Area == "" || len(strang.Countries) == 0 { + t.Error("strang should carry an area and country list") + } + if strang.RequiresKey { + t.Error("strang needs no API key") + } + + if ow := find(t, resp, "openweather"); !ow.RequiresKey { + t.Error("openweather requires an API key") + } + if mn := find(t, resp, "met_no"); !mn.Worldwide { + t.Error("met_no is worldwide") + } +} diff --git a/go/internal/api/api_pvperf_test.go b/go/internal/api/api_pvperf_test.go new file mode 100644 index 00000000..17107bb1 --- /dev/null +++ b/go/internal/api/api_pvperf_test.go @@ -0,0 +1,91 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/pvperf" + "github.com/srcfl/ftw/go/internal/state" +) + +func TestPVPerformanceDisabled(t *testing.T) { + srv := New(&Deps{}) // no PVPerf service + req := httptest.NewRequest(http.MethodGet, "/api/pv/performance", nil) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + + if rr.Code != 200 { + t.Fatalf("status = %d, want 200", rr.Code) + } + var resp struct { + Enabled bool `json:"enabled"` + Items []any `json:"items"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.Enabled { + t.Error("enabled should be false when no PVPerf service is wired") + } + if len(resp.Items) != 0 { + t.Errorf("items should be empty, got %d", len(resp.Items)) + } +} + +func TestPVPerformanceEnabledReturnsScores(t *testing.T) { + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + + // Seed two recent scored days (relative to now so the default window covers them). + now := time.Now() + pr := 0.9 + for i := 1; i <= 2; i++ { + day := now.AddDate(0, 0, -i).Format("2006-01-02") + if err := st.SavePVPerformance(state.PVPerformanceDay{ + Day: day, ExpectedWh: 10000, ActualWh: 9000, PR: &pr, + }); err != nil { + t.Fatal(err) + } + } + + srv := New(&Deps{PVPerf: &pvperf.Service{Store: st}}) + req := httptest.NewRequest(http.MethodGet, "/api/pv/performance?days=30", nil) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + + if rr.Code != 200 { + t.Fatalf("status = %d, want 200", rr.Code) + } + var resp struct { + Enabled bool `json:"enabled"` + PerformanceRatio *float64 `json:"performance_ratio"` + Attribution string `json:"attribution"` + Items []struct { + Day string `json:"day"` + ExpectedWh float64 `json:"expected_wh"` + ActualWh float64 `json:"actual_wh"` + } `json:"items"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if !resp.Enabled { + t.Error("enabled should be true") + } + if len(resp.Items) != 2 { + t.Fatalf("want 2 scored days, got %d", len(resp.Items)) + } + if resp.PerformanceRatio == nil || *resp.PerformanceRatio < 0.89 || *resp.PerformanceRatio > 0.91 { + t.Errorf("energy-weighted PR should be ~0.9, got %v", resp.PerformanceRatio) + } + if resp.Attribution == "" { + t.Error("attribution (SMHI STRÅNG CC BY 4.0) should be present") + } +} diff --git a/go/internal/coverage/coverage.go b/go/internal/coverage/coverage.go new file mode 100644 index 00000000..12ba85d9 --- /dev/null +++ b/go/internal/coverage/coverage.go @@ -0,0 +1,188 @@ +// Package coverage records where each external data source FTW talks to +// actually returns usable data. +// +// FTW runs outside the Nordics, but several of its sources are regional: +// STRÅNG models only the Nordic domain, and every price provider is European. +// Nothing in the code said so, so a site in Australia would get an empty price +// curve and an unscored PV history with no explanation. This package is that +// missing explanation, in one place, so the API and the UI can tell an operator +// *before* they select a source that it cannot serve their location. +// +// Bounds here are ADVISORY, and deliberately generous. Coverage is declared as +// a lat/lon box, but STRÅNG's grid is rotated relative to lat/lon, so the box is +// a superset of the real domain: a point near a corner can pass Covers and still +// return no data. Read Covers()==false as "definitely not supported, do not +// bother asking" and Covers()==true as "worth trying" — the upstream API stays +// authoritative. Nothing here is a safety input; it only decides what we show +// and whether we skip a pointless fetch. +package coverage + +// Kind groups sources by what they supply, so the UI can present forecast, +// irradiance and price coverage separately. +type Kind string + +const ( + KindForecast Kind = "forecast" + KindIrradiance Kind = "irradiance" + KindPrice Kind = "price" +) + +// BBox is an inclusive latitude/longitude bounding box in WGS84 degrees. +type BBox struct { + MinLat float64 `json:"min_lat"` + MinLon float64 `json:"min_lon"` + MaxLat float64 `json:"max_lat"` + MaxLon float64 `json:"max_lon"` +} + +// Contains reports whether (lat, lon) falls inside the box. Longitude is not +// wrapped: no source described here spans the antimeridian, and silently +// wrapping would turn a nonsense coordinate into a plausible-looking hit. +func (b BBox) Contains(lat, lon float64) bool { + return lat >= b.MinLat && lat <= b.MaxLat && lon >= b.MinLon && lon <= b.MaxLon +} + +// Source describes one external data source and where it works. +type Source struct { + ID string `json:"id"` + Kind Kind `json:"kind"` + Label string `json:"label"` + // Area is the human-readable coverage, shown in the UI. + Area string `json:"area"` + // Countries lists ISO 3166-1 alpha-2 codes when the source is bounded to a + // known set. Empty means either worldwide or "bounded by BBox, not by + // borders" — check Worldwide() rather than inferring from length. + Countries []string `json:"countries,omitempty"` + // BBox bounds the source geographically. nil means worldwide. + BBox *BBox `json:"bbox,omitempty"` + // RequiresKey is true when the operator must supply their own credential. + RequiresKey bool `json:"requires_key"` + License string `json:"license,omitempty"` + Note string `json:"note,omitempty"` +} + +// Worldwide reports whether the source is unbounded geographically. +func (s Source) Worldwide() bool { return s.BBox == nil } + +// Covers reports whether the source plausibly serves (lat, lon). Worldwide +// sources always do. See the package doc: a true result is advisory. +func (s Source) Covers(lat, lon float64) bool { + if s.BBox == nil { + return true + } + return s.BBox.Contains(lat, lon) +} + +// strangDomain is the STRÅNG model domain, measured against the live API rather +// than taken from documentation (SMHI's apidocs pages 404 as of 2026-07). +// Probing parameter 117 found data at lat 53.5 and 72.5 but not 53.0 or 73.5, +// and at lon 0 and 30 but not -2 or 35. The corner (53.5, -4) returns nothing +// even though it is inside this box, which is the rotated grid showing through — +// hence "advisory superset" in the package doc. +var strangDomain = &BBox{MinLat: 53.0, MinLon: -1.0, MaxLat: 73.0, MaxLon: 33.0} + +// sources is the registry. Keep it ordered by kind then id so the API response +// is stable and diffs stay readable. +var sources = []Source{ + { + ID: "met_no", Kind: KindForecast, Label: "MET Norway", + Area: "Worldwide", + License: "NLOD / CC BY 4.0", + Note: "Cloud cover only — no irradiance, so PV is derived from a cloud-derated clear-sky prior.", + }, + { + ID: "openweather", Kind: KindForecast, Label: "OpenWeather", + Area: "Worldwide", + RequiresKey: true, + Note: "Cloud cover only — same cloud-derated prior as MET Norway.", + }, + { + ID: "open_meteo", Kind: KindForecast, Label: "Open-Meteo", + Area: "Worldwide", + License: "CC BY 4.0", + Note: "Publishes shortwave radiation, so PV is irradiance-derived rather than cloud-derated.", + }, + { + ID: "forecast_solar", Kind: KindForecast, Label: "Forecast.Solar", + Area: "Worldwide", + Note: "Returns site-calibrated watts from the configured array geometry; free tier is rate-limited.", + }, + { + ID: "strang", Kind: KindIrradiance, Label: "SMHI STRÅNG", + Area: "Nordic region", + Countries: []string{"SE", "NO", "FI", "DK", "EE", "LV", "LT"}, + BBox: strangDomain, + License: "CC BY 4.0", + Note: "Historical only (1999 to ~1 day ago). Used for PV performance scoring and forecast calibration, never as a forward forecast.", + }, + { + ID: "sourceful", Kind: KindPrice, Label: "Sourceful (cached ENTSO-E)", + Area: "Europe", + Countries: europeanPriceCountries, + BBox: &BBox{MinLat: 34.0, MinLon: -25.0, MaxLat: 72.0, MaxLon: 45.0}, + Note: "European day-ahead bidding zones. No key required.", + }, + { + ID: "elprisetjustnu", Kind: KindPrice, Label: "Elpriset just nu", + Area: "Sweden", + Countries: []string{"SE"}, + BBox: &BBox{MinLat: 55.0, MinLon: 10.0, MaxLat: 69.5, MaxLon: 24.5}, + Note: "Swedish bidding zones SE1-SE4 only. No key required.", + }, + { + ID: "entsoe", Kind: KindPrice, Label: "ENTSO-E Transparency", + Area: "Europe", + Countries: europeanPriceCountries, + BBox: &BBox{MinLat: 34.0, MinLon: -25.0, MaxLat: 72.0, MaxLon: 45.0}, + RequiresKey: true, + Note: "All ENTSO-E member bidding zones.", + }, +} + +// europeanPriceCountries are the ENTSO-E member states whose day-ahead prices +// the European providers can serve. Shared by sourceful and entsoe because both +// resolve to the same underlying bidding zones. +var europeanPriceCountries = []string{ + "AT", "BE", "BG", "CH", "CZ", "DE", "DK", "EE", "ES", "FI", + "FR", "GR", "HR", "HU", "IE", "IT", "LT", "LU", "LV", "NL", + "NO", "PL", "PT", "RO", "RS", "SE", "SI", "SK", +} + +// All returns every known source. +func All() []Source { + out := make([]Source, len(sources)) + copy(out, sources) + return out +} + +// ByID returns the source with the given id. +func ByID(id string) (Source, bool) { + for _, s := range sources { + if s.ID == id { + return s, true + } + } + return Source{}, false +} + +// ForKind returns every source of one kind, in registry order. +func ForKind(k Kind) []Source { + var out []Source + for _, s := range sources { + if s.Kind == k { + out = append(out, s) + } + } + return out +} + +// Covers reports whether the named source plausibly serves (lat, lon). An +// unknown id returns false: callers ask about a source they intend to use, and +// answering "sure" for a source we know nothing about is the wrong default. +func Covers(id string, lat, lon float64) bool { + s, ok := ByID(id) + if !ok { + return false + } + return s.Covers(lat, lon) +} diff --git a/go/internal/coverage/coverage_test.go b/go/internal/coverage/coverage_test.go new file mode 100644 index 00000000..36de8d82 --- /dev/null +++ b/go/internal/coverage/coverage_test.go @@ -0,0 +1,174 @@ +package coverage + +import "testing" + +// The STRÅNG cases below are the live-API probe results recorded on 2026-07-31 +// (parameter 117, 2026-06-21). They are the reason the box has the bounds it +// does, so if someone widens it these fail and say why. +func TestStrangCoversProbedNordicPoints(t *testing.T) { + in := []struct { + name string + lat, lon float64 + }{ + {"Stockholm", 59.33, 18.07}, + {"Tromsø", 69.65, 18.96}, + {"Helsinki", 60.17, 24.94}, + {"Copenhagen", 55.68, 12.57}, + } + for _, c := range in { + if !Covers("strang", c.lat, c.lon) { + t.Errorf("%s (%.2f,%.2f): want covered, got not covered", c.name, c.lat, c.lon) + } + } +} + +func TestStrangRejectsProbedOutsidePoints(t *testing.T) { + // Every one of these returned no data from the live API. + out := []struct { + name string + lat, lon float64 + }{ + {"Berlin", 52.52, 13.40}, + {"London", 51.51, -0.13}, + {"Paris", 48.86, 2.35}, + {"Reykjavík", 64.15, -21.94}, + {"Sydney", -33.87, 151.21}, + {"New York", 40.71, -74.01}, + } + for _, c := range out { + if Covers("strang", c.lat, c.lon) { + t.Errorf("%s (%.2f,%.2f): want not covered, got covered", c.name, c.lat, c.lon) + } + } +} + +// The declared box is a superset of the rotated grid: these four corners are +// inside the box yet every one returned no data when probed live on 2026-07-31. +// That gap is intentional and documented — Covers()==true means "worth asking", +// not "guaranteed". Pinned so nobody tightens the box into a false promise, or +// starts treating a true result as a guarantee. +func TestStrangBoxIsAdvisorySupersetAtCorners(t *testing.T) { + corners := [][2]float64{ + {53.5, -0.5}, {53.5, 32.0}, {72.5, -0.5}, {72.5, 32.0}, + } + for _, c := range corners { + if !Covers("strang", c[0], c[1]) { + t.Errorf("(%.1f,%.1f): corner should pass the advisory box test", c[0], c[1]) + } + } +} + +func TestForecastProvidersAreWorldwide(t *testing.T) { + for _, id := range []string{"met_no", "openweather", "open_meteo", "forecast_solar"} { + s, ok := ByID(id) + if !ok { + t.Fatalf("%s: not registered", id) + } + if !s.Worldwide() { + t.Errorf("%s: want worldwide", id) + } + // A worldwide source must cover anywhere, including the far south. + if !s.Covers(-33.87, 151.21) { + t.Errorf("%s: worldwide source must cover Sydney", id) + } + } +} + +// The whole point of #726: price data is Europe-only. If someone adds a global +// price provider this test should be updated deliberately, not incidentally. +func TestPriceProvidersAreEuropeOnly(t *testing.T) { + prices := ForKind(KindPrice) + if len(prices) == 0 { + t.Fatal("no price sources registered") + } + for _, s := range prices { + if s.Worldwide() { + t.Errorf("%s: price sources are not worldwide", s.ID) + } + if s.Covers(-33.87, 151.21) { + t.Errorf("%s: must not claim to cover Sydney", s.ID) + } + if s.Covers(40.71, -74.01) { + t.Errorf("%s: must not claim to cover New York", s.ID) + } + } +} + +func TestSwedishPriceProviderIsNarrowerThanEuropean(t *testing.T) { + // Berlin: served by the European providers, not by the Swedish one. + if Covers("elprisetjustnu", 52.52, 13.40) { + t.Error("elprisetjustnu must not claim Berlin") + } + if !Covers("sourceful", 52.52, 13.40) { + t.Error("sourceful should cover Berlin") + } + if !Covers("elprisetjustnu", 59.33, 18.07) { + t.Error("elprisetjustnu should cover Stockholm") + } +} + +// An unknown id must not be treated as universally available. +func TestUnknownSourceIsNotCovered(t *testing.T) { + if Covers("does_not_exist", 59.33, 18.07) { + t.Error("unknown source must report not covered") + } + if _, ok := ByID("does_not_exist"); ok { + t.Error("unknown source must not resolve") + } +} + +func TestBBoxContainsIsInclusive(t *testing.T) { + b := BBox{MinLat: 10, MinLon: 20, MaxLat: 30, MaxLon: 40} + for _, c := range []struct { + lat, lon float64 + want bool + }{ + {10, 20, true}, // min corner + {30, 40, true}, // max corner + {20, 30, true}, // interior + {9.99, 30, false}, // just south + {20, 40.01, false}, // just east + } { + if got := b.Contains(c.lat, c.lon); got != c.want { + t.Errorf("Contains(%v,%v) = %v, want %v", c.lat, c.lon, got, c.want) + } + } +} + +// Longitude is intentionally not wrapped; a nonsense coordinate must stay a +// miss rather than being folded into range. +func TestBBoxDoesNotWrapLongitude(t *testing.T) { + b := BBox{MinLat: -90, MinLon: -180, MaxLat: 90, MaxLon: 180} + if b.Contains(0, 200) { + t.Error("lon 200 must not wrap to -160") + } +} + +func TestRegistryIsInternallyConsistent(t *testing.T) { + seen := map[string]bool{} + for _, s := range All() { + if s.ID == "" || s.Label == "" || s.Area == "" { + t.Errorf("%+v: id, label and area are all required", s) + } + if seen[s.ID] { + t.Errorf("%s: duplicate id", s.ID) + } + seen[s.ID] = true + if s.BBox != nil { + if s.BBox.MinLat > s.BBox.MaxLat || s.BBox.MinLon > s.BBox.MaxLon { + t.Errorf("%s: inverted bbox %+v", s.ID, *s.BBox) + } + } + } +} + +// All() must hand out a copy: a caller mutating the result must not corrupt the +// registry for everyone else in the process. +func TestAllReturnsACopy(t *testing.T) { + got := All() + original := got[0].ID + got[0].ID = "mutated" + if All()[0].ID != original { + t.Fatal("All() exposed the backing array") + } +} diff --git a/go/internal/forecast/forecast.go b/go/internal/forecast/forecast.go index 6222871b..5c52095c 100644 --- a/go/internal/forecast/forecast.go +++ b/go/internal/forecast/forecast.go @@ -255,6 +255,15 @@ type Service struct { // orientation-blind flat rated×(W/m²/1000) estimate. Empty → flat estimate. Arrays []Array + // Calibration optionally supplies a measured site correction factor, + // wired to the STRÅNG performance scorer. The irradiance-derived + // estimates below are deliberately loss-free (no inverter, wiring or + // temperature derate), so a site settles at a stable ratio slightly under + // 1; feeding that measured ratio back closes the gap. The second return + // is false whenever the factor must not be applied, and nil means the + // scorer isn't wired at all — both leave the estimate untouched. + Calibration func() (float64, bool) + stop chan struct{} done chan struct{} } @@ -349,6 +358,15 @@ func (s *Service) fetchAndStore(ctx context.Context) { } if len(rows) == 0 { return } nowMs := time.Now().UnixMilli() + + // Resolved once per fetch so every point in a batch shares one factor. + calibration, calibrated := 1.0, false + if s.Calibration != nil { + if f, ok := s.Calibration(); ok && f > 0 { + calibration, calibrated = f, true + } + } + points := make([]state.ForecastPoint, 0, len(rows)) for _, r := range rows { // Pick the most direct PV signal the provider gave us. Forecast.Solar @@ -356,18 +374,30 @@ func (s *Service) fetchAndStore(ctx context.Context) { // radiation we turn into watts via rated × W/m²/1000; met.no only has // cloud fraction, so we fall through to the naive cloud-derated prior. var pvW float64 + // Only the two irradiance-derived branches are calibrated. They share + // the loss-free "irradiance × nameplate" baseline the performance ratio + // was measured against, so the correction is the same quantity. A + // provider-native figure is already site-calibrated upstream and the + // cloud-derated prior carries its own empirical derate; scaling either + // would double-count. + applyCalibration := false switch { case r.PVWEstimated != nil: pvW = *r.PVWEstimated case r.SolarWm2 != nil && len(s.Arrays) > 0: // Orientation-aware: project GHI onto each configured plane and sum. pvW = poaPVWattsFromGHI(s.Lat, s.Lon, r.HourStart, *r.SolarWm2, s.Arrays) + applyCalibration = true case r.SolarWm2 != nil && s.RatedPVW > 0: // No geometry configured — flat, orientation-blind estimate. pvW = s.RatedPVW * (*r.SolarWm2) / 1000.0 + applyCalibration = true default: pvW = EstimatePVW(s.Lat, s.Lon, r.HourStart, r.CloudCoverPct, s.RatedPVW) } + if applyCalibration && calibrated { + pvW *= calibration + } pvPtr := &pvW points = append(points, state.ForecastPoint{ SlotTsMs: r.HourStart.UnixMilli(), @@ -384,7 +414,8 @@ func (s *Service) fetchAndStore(ctx context.Context) { slog.Warn("forecast save failed", "err", err) return } - slog.Info("forecast fetched", "count", len(points), "provider", s.Provider.Name()) + slog.Info("forecast fetched", "count", len(points), "provider", s.Provider.Name(), + "pv_calibration", calibration, "calibrated", calibrated) } // poaPVWattsFromGHI converts a global-horizontal irradiance (W/m², positive) diff --git a/go/internal/forecast/forecast_test.go b/go/internal/forecast/forecast_test.go index e0cb5526..19231b68 100644 --- a/go/internal/forecast/forecast_test.go +++ b/go/internal/forecast/forecast_test.go @@ -341,3 +341,83 @@ func TestServicePOAPathDiffersFromFlat(t *testing.T) { } t.Logf("POA-per-array estimate %.0fW vs flat %.0fW", got, flat) } + +// ---- STRÅNG calibration hook ---- + +// radiationForecastPVW runs one fetch against a stub shortwave-radiation +// provider and returns the stored PV estimate, so calibration variants can be +// compared against an otherwise identical run. +func radiationForecastPVW(t *testing.T, calibration func() (float64, bool)) float64 { + t.Helper() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "hourly": map[string]any{ + "time": []string{"2026-06-21T11:00"}, + "shortwave_radiation": []float64{700}, + "cloud_cover": []float64{5}, + "temperature_2m": []float64{20}, + }, + } + _ = json.NewEncoder(w).Encode(resp) + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + st, _ := state.Open(filepath.Join(t.TempDir(), "state.db")) + defer st.Close() + + p := NewOpenMeteo() + p.BaseURL = srv.URL + s := &Service{ + Provider: p, Store: st, Lat: 59.3293, Lon: 18.0686, RatedPVW: 10000, + Arrays: []Array{{TiltDeg: 35, AzimuthDeg: 180, KWp: 10}}, + Calibration: calibration, + } + s.fetchAndStore(context.Background()) + + tt := time.Date(2026, 6, 21, 11, 0, 0, 0, time.UTC) + rows, err := st.LoadForecasts(tt.UnixMilli(), tt.Add(time.Hour).UnixMilli()) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].PVWEstimated == nil { + t.Fatalf("expected 1 forecast with PV estimate, got %+v", rows) + } + return *rows[0].PVWEstimated +} + +// The point of scoring: a site measured at 80% of its physics baseline should +// have its forward forecast scaled to match. +func TestServiceAppliesCalibrationToIrradianceEstimate(t *testing.T) { + uncalibrated := radiationForecastPVW(t, nil) + calibrated := radiationForecastPVW(t, func() (float64, bool) { return 0.8, true }) + + want := uncalibrated * 0.8 + if math.Abs(calibrated-want) > 1.0 { + t.Errorf("calibrated estimate = %.1f W, want %.1f W (0.8 × %.1f)", calibrated, want, uncalibrated) + } + t.Logf("uncalibrated %.0fW → calibrated %.0fW", uncalibrated, calibrated) +} + +// An untrusted factor must change nothing: too few days, or a ratio outside the +// plausible band, leaves the physics estimate exactly as it was. +func TestServiceIgnoresUntrustedCalibration(t *testing.T) { + uncalibrated := radiationForecastPVW(t, nil) + rejected := radiationForecastPVW(t, func() (float64, bool) { return 0.05, false }) + + if math.Abs(rejected-uncalibrated) > 1e-9 { + t.Errorf("estimate = %.1f W, want the uncalibrated %.1f W", rejected, uncalibrated) + } +} + +// A zero or negative factor would silently zero out the site's whole forecast, +// so it is refused even when the source claims it is usable. +func TestServiceIgnoresNonPositiveCalibration(t *testing.T) { + uncalibrated := radiationForecastPVW(t, nil) + for _, factor := range []float64{0, -0.5} { + got := radiationForecastPVW(t, func() (float64, bool) { return factor, true }) + if math.Abs(got-uncalibrated) > 1e-9 { + t.Errorf("factor %v: estimate = %.1f W, want the uncalibrated %.1f W", factor, got, uncalibrated) + } + } +} diff --git a/go/internal/mpc/external_optimizer.go b/go/internal/mpc/external_optimizer.go index 1a7078b8..68e2c73b 100644 --- a/go/internal/mpc/external_optimizer.go +++ b/go/internal/mpc/external_optimizer.go @@ -486,9 +486,9 @@ func (o *ExternalOptimizer) buildRequest(slots []Slot, p Params) externalRequest basePV[i] = slot.PVW if slot.PVW < 0 { hasDaylight = true - generation := -slot.PVW - downsidePV[i] = -math.Max(0, generation-spread) - upsidePV[i] = -(generation + spread) + // PVBand owns the site-sign mapping: "low" is more negative + // (more generation, optimistic), "high" is nearer zero. + upsidePV[i], downsidePV[i] = PVBand(slot.PVW, spread) } } if hasDaylight { diff --git a/go/internal/mpc/pvband.go b/go/internal/mpc/pvband.go new file mode 100644 index 00000000..f25c42d8 --- /dev/null +++ b/go/internal/mpc/pvband.go @@ -0,0 +1,37 @@ +package mpc + +import "math" + +// PVBand returns an uncertainty band around a slot's expected PV power. +// +// Everything here is in the site convention: power into the site is positive, +// so generation is NEGATIVE. That inverts the intuitive reading of the return +// values, which is exactly why this lives in one tested function instead of +// being open-coded at each call site: +// +// - low is the numerically smaller (more negative) bound — MORE generation, +// i.e. the OPTIMISTIC case. +// - high is the numerically larger bound (closer to zero) — LESS generation, +// i.e. the PESSIMISTIC case. +// +// The optimizer validates `low <= base <= high <= 0` and rejects the request +// outright if any of that fails, so two clamps are load-bearing: high can never +// cross zero (a spread wider than expected generation degrades to "produces +// nothing", never to positive PV, which would be read as load), and a +// non-positive spread collapses the band onto the base forecast exactly. +// +// Slots with no expected generation return a zero-width band at zero, matching +// how night slots are left untouched. +func PVBand(basePVW, spreadW float64) (low, high float64) { + // `!(x < 0)` rather than `x >= 0` so NaN falls into this branch too. + if !(basePVW < 0) || math.IsInf(basePVW, 0) { + return 0, 0 + } + if !(spreadW > 0) || math.IsInf(spreadW, 0) { + return basePVW, basePVW + } + generation := -basePVW // > 0 + low = -(generation + spreadW) + high = -math.Max(0, generation-spreadW) + return low, high +} diff --git a/go/internal/mpc/pvband_test.go b/go/internal/mpc/pvband_test.go new file mode 100644 index 00000000..7dab3fc4 --- /dev/null +++ b/go/internal/mpc/pvband_test.go @@ -0,0 +1,86 @@ +package mpc + +import ( + "math" + "testing" +) + +// The optimizer rejects any request where the band fails to bracket the base +// forecast or where either bound crosses zero. This is the invariant the whole +// helper exists to guarantee, so it is asserted over a spread of inputs +// including the degenerate ones. +func TestPVBandHoldsSiteSignInvariant(t *testing.T) { + bases := []float64{0, -1, -250, -2000, -9500, math.NaN(), math.Inf(-1)} + spreads := []float64{0, 1, 250, 3000, 100000, -50, math.NaN()} + + for _, base := range bases { + for _, spread := range spreads { + low, high := PVBand(base, spread) + + if math.IsNaN(low) || math.IsNaN(high) { + t.Fatalf("PVBand(%v, %v) produced NaN: low=%v high=%v", base, spread, low, high) + } + if low > 0 || high > 0 { + t.Errorf("PVBand(%v, %v) = (%v, %v); bounds must stay <= 0 (site convention)", + base, spread, low, high) + } + if low > high { + t.Errorf("PVBand(%v, %v) = (%v, %v); low must not exceed high", base, spread, low, high) + } + // The base is only bracketed when it is itself a valid generation + // figure; invalid bases are normalised to a zero-width band. + if base < 0 && !math.IsInf(base, 0) { + if low > base || base > high { + t.Errorf("PVBand(%v, %v) = (%v, %v); band must bracket the base forecast", + base, spread, low, high) + } + } + } + } +} + +// A site with no measured uncertainty must plan against the plain forecast -- +// the band collapses to a point rather than quietly widening. +func TestPVBandZeroSpreadCollapsesOntoBase(t *testing.T) { + const base = -3200.0 + low, high := PVBand(base, 0) + if low != base || high != base { + t.Errorf("PVBand(%v, 0) = (%v, %v), want both == %v", base, low, high, base) + } +} + +// The clamp that keeps the pessimistic bound from crossing into positive +// territory: a spread wider than the expected generation means "might produce +// nothing", never "might consume". +func TestPVBandClampsPessimisticBoundAtZero(t *testing.T) { + low, high := PVBand(-1000, 4000) + if high != 0 { + t.Errorf("high = %v, want 0 (clamped); a positive bound would read as load", high) + } + if low != -5000 { + t.Errorf("low = %v, want -5000 (optimistic bound is unclamped)", low) + } +} + +// Direction check stated in production terms, so a future sign flip fails here +// with an obvious message rather than as an opaque optimizer ProtocolError. +func TestPVBandLowIsOptimisticHighIsPessimistic(t *testing.T) { + const base = -5000.0 + low, high := PVBand(base, 1500) + if generation := -low; generation != 6500 { + t.Errorf("optimistic generation = %v W, want 6500 W", generation) + } + if generation := -high; generation != 3500 { + t.Errorf("pessimistic generation = %v W, want 3500 W", generation) + } +} + +// Night slots carry no expected generation, so they get no band at all. +func TestPVBandNoGenerationYieldsNoBand(t *testing.T) { + for _, base := range []float64{0, 250} { + low, high := PVBand(base, 900) + if low != 0 || high != 0 { + t.Errorf("PVBand(%v, 900) = (%v, %v), want (0, 0)", base, low, high) + } + } +} diff --git a/go/internal/pvperf/calibration.go b/go/internal/pvperf/calibration.go new file mode 100644 index 00000000..fd2bfea6 --- /dev/null +++ b/go/internal/pvperf/calibration.go @@ -0,0 +1,117 @@ +package pvperf + +import ( + "math" + "sort" + "time" + + "github.com/srcfl/ftw/go/internal/state" +) + +// Calibration is the site-specific correction learned by comparing measured +// production against the STRÅNG physics baseline. It is the payoff of scoring: +// once a site has enough closed days, its typical performance ratio *is* the +// derate the forward forecast should apply, and the spread of that ratio is a +// measured uncertainty that beats any hand-tuned constant. +type Calibration struct { + // Factor is the representative performance ratio — multiply a loss-free + // physics PV estimate by this to get an expected real-world figure. + Factor float64 + // SigmaRel is the robust spread of the daily ratio. PR is dimensionless, + // so this is already a relative uncertainty. + SigmaRel float64 + // Days is how many scored days carried a usable ratio. + Days int + // Valid reports whether Factor is trustworthy enough to apply. A factor + // outside the plausible band means the geometry or the meter is wrong, + // not that the panels are dirty — better to leave the forecast alone. + Valid bool +} + +const ( + // minCalibrationDays is the smallest sample that can outvote a single + // snowy or curtailed day. + minCalibrationDays = 7 + + // Plausible bounds for a real site's ratio. Below the floor points at + // broken telemetry or an array that never ran; above the ceiling means + // the declared kWp understates the installation. Both are configuration + // faults that a silent forecast rescale would only hide. + minCalibrationFactor = 0.30 + maxCalibrationFactor = 1.30 + + // maxCalibrationSigma caps the reported spread so a pathological sample + // cannot widen the planner's uncertainty without bound. + maxCalibrationSigma = 1.0 + + // madToSigma converts a median absolute deviation into a standard + // deviation for normally distributed data. + madToSigma = 1.4826 + + // calibrationWindowDays is how far back Service.Calibration looks. + calibrationWindowDays = 30 +) + +// Calibrate summarises scored days into a site calibration. It uses the median +// and a median-absolute-deviation sigma rather than mean/stddev: a single +// snow-covered or curtailed day is a large outlier, and the mean would chase it. +func Calibrate(days []state.PVPerformanceDay) Calibration { + ratios := make([]float64, 0, len(days)) + for _, d := range days { + if d.PR != nil && !math.IsNaN(*d.PR) && !math.IsInf(*d.PR, 0) { + ratios = append(ratios, *d.PR) + } + } + c := Calibration{Days: len(ratios)} + if len(ratios) < minCalibrationDays { + return c + } + c.Factor = median(ratios) + + deviations := make([]float64, len(ratios)) + for i, r := range ratios { + deviations[i] = math.Abs(r - c.Factor) + } + c.SigmaRel = math.Min(maxCalibrationSigma, madToSigma*median(deviations)) + c.Valid = c.Factor >= minCalibrationFactor && c.Factor <= maxCalibrationFactor + return c +} + +// median returns the middle value of xs without mutating the caller's slice. +func median(xs []float64) float64 { + if len(xs) == 0 { + return 0 + } + sorted := append([]float64(nil), xs...) + sort.Float64s(sorted) + n := len(sorted) + if n%2 == 1 { + return sorted[n/2] + } + return (sorted[n/2-1] + sorted[n/2]) / 2 +} + +// Calibration returns the site calibration over the recent scored window. +// A zero value (Valid false) is returned whenever the service is unwired or +// the history is too thin to draw a conclusion. +func (s *Service) Calibration() Calibration { + if s == nil || s.Store == nil { + return Calibration{} + } + now := time.Now() + days, err := s.Store.LoadPVPerformance( + now.AddDate(0, 0, -calibrationWindowDays).Format("2006-01-02"), + now.Format("2006-01-02"), + ) + if err != nil { + return Calibration{} + } + return Calibrate(days) +} + +// CalibrationFactor adapts Calibration to the hook shape the forecast service +// consumes. The second return is false when the factor must not be applied. +func (s *Service) CalibrationFactor() (float64, bool) { + c := s.Calibration() + return c.Factor, c.Valid +} diff --git a/go/internal/pvperf/calibration_test.go b/go/internal/pvperf/calibration_test.go new file mode 100644 index 00000000..76c7155e --- /dev/null +++ b/go/internal/pvperf/calibration_test.go @@ -0,0 +1,153 @@ +package pvperf + +import ( + "fmt" + "math" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/state" +) + +// scoredDays builds a run of days carrying the given performance ratios. +func scoredDays(ratios ...float64) []state.PVPerformanceDay { + days := make([]state.PVPerformanceDay, 0, len(ratios)) + for i, r := range ratios { + ratio := r + days = append(days, state.PVPerformanceDay{ + Day: fmt.Sprintf("2026-07-%02d", i+1), + ExpectedWh: 50000, + ActualWh: 50000 * ratio, + PR: &ratio, + }) + } + return days +} + +// A handful of days is not evidence. Applying a factor from a thin sample would +// let one cloudy week permanently depress the forecast. +func TestCalibrateNeedsEnoughDays(t *testing.T) { + c := Calibrate(scoredDays(0.9, 0.9, 0.9)) + if c.Valid { + t.Error("calibration should not be valid on 3 days") + } + if c.Days != 3 { + t.Errorf("Days = %d, want 3", c.Days) + } +} + +// Days without a usable ratio (polar night, no history) must not be counted as +// evidence, and must not drag the factor toward zero. +func TestCalibrateIgnoresDaysWithoutRatio(t *testing.T) { + days := scoredDays(0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9) + days = append(days, state.PVPerformanceDay{Day: "2026-07-30", ExpectedWh: 10, ActualWh: 0}) + c := Calibrate(days) + if c.Days != 7 { + t.Errorf("Days = %d, want 7 (the ratio-less day is not evidence)", c.Days) + } + if math.Abs(c.Factor-0.9) > 1e-9 { + t.Errorf("Factor = %v, want 0.9", c.Factor) + } +} + +// The reason for median-over-mean: a snow day is a huge outlier, and a site +// that normally runs at 0.90 must still calibrate to ~0.90 despite one. +func TestCalibrateResistsOutlierDays(t *testing.T) { + c := Calibrate(scoredDays(0.90, 0.91, 0.89, 0.90, 0.92, 0.88, 0.90, 0.05)) + if !c.Valid { + t.Fatal("expected a valid calibration") + } + if math.Abs(c.Factor-0.90) > 0.02 { + t.Errorf("Factor = %v, want ~0.90; a single snow day should not move it", c.Factor) + } +} + +// A factor this low means the geometry or the meter is misconfigured. Silently +// rescaling the forecast would bake the fault in instead of surfacing it. +func TestCalibrateRejectsImplausiblyLowFactor(t *testing.T) { + c := Calibrate(scoredDays(0.10, 0.11, 0.09, 0.10, 0.12, 0.08, 0.10, 0.11)) + if c.Valid { + t.Errorf("factor %v should be rejected as implausible", c.Factor) + } + if c.Days != 8 { + t.Errorf("Days = %d, want 8 — the sample is still reported", c.Days) + } +} + +// Likewise for a site consistently beating its own nameplate: that is an +// understated kWp, not a bonus to hand to the planner. +func TestCalibrateRejectsImplausiblyHighFactor(t *testing.T) { + c := Calibrate(scoredDays(1.6, 1.7, 1.55, 1.62, 1.58, 1.7, 1.65, 1.6)) + if c.Valid { + t.Errorf("factor %v should be rejected as implausible", c.Factor) + } +} + +// A perfectly steady site reports no uncertainty, so the planner's band +// collapses onto the forecast rather than inventing spread. +func TestCalibrateSigmaIsZeroForSteadySite(t *testing.T) { + c := Calibrate(scoredDays(0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9)) + if !c.Valid { + t.Fatal("expected a valid calibration") + } + if c.SigmaRel != 0 { + t.Errorf("SigmaRel = %v, want 0 for an unvarying site", c.SigmaRel) + } +} + +// A variable site reports a real spread, which is what makes the measured band +// better than a hand-tuned constant. +func TestCalibrateSigmaGrowsWithSpread(t *testing.T) { + steady := Calibrate(scoredDays(0.90, 0.91, 0.89, 0.90, 0.91, 0.89, 0.90, 0.90)) + variable := Calibrate(scoredDays(0.60, 0.95, 0.70, 0.99, 0.65, 0.92, 0.75, 0.88)) + if !(variable.SigmaRel > steady.SigmaRel) { + t.Errorf("variable SigmaRel %v should exceed steady %v", variable.SigmaRel, steady.SigmaRel) + } +} + +// The forecast hook is wired unconditionally at startup, so it must be safe on +// a service that was never built. +func TestCalibrationFactorNilServiceIsSafe(t *testing.T) { + var s *Service + factor, ok := s.CalibrationFactor() + if ok { + t.Errorf("nil service reported a usable factor (%v)", factor) + } +} + +// End-to-end through the store, since that is how the API and the forecast +// hook actually reach it. +func TestServiceCalibrationReadsPersistedDays(t *testing.T) { + st := openStore(t) + svc := &Service{Store: st} + + if c := svc.Calibration(); c.Valid || c.Days != 0 { + t.Fatalf("empty store should yield no calibration, got %+v", c) + } + + now := time.Now() + for i := 1; i <= 10; i++ { + ratio := 0.88 + day := now.AddDate(0, 0, -i).Format("2006-01-02") + if err := st.SavePVPerformance(state.PVPerformanceDay{ + Day: day, ExpectedWh: 40000, ActualWh: 40000 * ratio, PR: &ratio, + }); err != nil { + t.Fatalf("save %s: %v", day, err) + } + } + c := svc.Calibration() + if !c.Valid { + t.Fatalf("expected a valid calibration, got %+v", c) + } + if math.Abs(c.Factor-0.88) > 1e-9 { + t.Errorf("Factor = %v, want 0.88", c.Factor) + } + if c.Days != 10 { + t.Errorf("Days = %d, want 10", c.Days) + } + + factor, ok := svc.CalibrationFactor() + if !ok || math.Abs(factor-0.88) > 1e-9 { + t.Errorf("CalibrationFactor() = (%v, %v), want (0.88, true)", factor, ok) + } +} diff --git a/go/internal/pvperf/pvperf.go b/go/internal/pvperf/pvperf.go new file mode 100644 index 00000000..e492bd9b --- /dev/null +++ b/go/internal/pvperf/pvperf.go @@ -0,0 +1,88 @@ +// Package pvperf scores measured PV production against a weather-expected +// baseline derived from SMHI STRÅNG irradiance and the site's per-plane +// geometry. +// +// It is read-only analytics with no control-path coupling: given historical +// irradiance and the panel arrays, it computes the DC energy the arrays should +// have produced under that irradiance, then compares to the measured energy to +// yield a performance ratio (PR). A sustained PR below ~1 hints at soiling, +// snow, shading or degradation; the raw PR series is the signal, the diagnosis +// is left to the caller/UI. +// +// The expected baseline deliberately omits system losses (inverter, wiring, +// temperature). The performance ratio absorbs the site's fixed derate, so a +// healthy site simply sits at a stable PR < 1 and anomalies show as departures +// from that baseline. +package pvperf + +import ( + "time" + + "github.com/srcfl/ftw/go/internal/sunpos" +) + +// Array is one panel plane: nameplate DC kWp at a tilt/azimuth (same +// conventions as sunpos — tilt 0=flat..90=wall, azimuth 0=N,90=E,180=S,270=W). +type Array struct { + KWp float64 + TiltDeg float64 + AzimuthDeg float64 +} + +// Irradiance is one hour of horizontal irradiance (W/m²). DHIWm2 is nil when +// the diffuse component is unavailable, in which case an Erbs split is used. +type Irradiance struct { + HourStart time.Time + GHIWm2 float64 + DHIWm2 *float64 +} + +// ExpectedWh returns the DC energy (Wh, positive) the given arrays would +// produce under the supplied hourly irradiance at (lat, lon). Each hour's +// plane-of-array irradiance is projected via sunpos and integrated over one +// hour. When measured diffuse is present it is used directly (more accurate); +// otherwise the diffuse fraction is estimated from the clearness index. +func ExpectedWh(lat, lon float64, arrays []Array, hours []Irradiance) float64 { + var wh float64 + for _, h := range hours { + if h.GHIWm2 <= 0 { + continue + } + sun := sunpos.At(h.HourStart, lat, lon) + for _, a := range arrays { + if a.KWp <= 0 { + continue + } + var poa float64 + if h.DHIWm2 != nil { + poa = sunpos.POAFromComponents(sun, h.GHIWm2, *h.DHIWm2, a.TiltDeg, a.AzimuthDeg) + } else { + poa = sunpos.POAFromGHI(h.HourStart, lat, lon, h.GHIWm2, a.TiltDeg, a.AzimuthDeg) + } + // kWp×1000 W at STC (1000 W/m²) × (POA/1000) × 1 h = Wh this hour. + wh += a.KWp * 1000.0 * (poa / 1000.0) + } + } + return wh +} + +// minExpectedWh is the floor below which a performance ratio is meaningless +// (polar-night / near-dark days) — reported as "not available" instead. +const minExpectedWh = 100.0 + +// PerformanceRatio returns actualWh / expectedWh, clamped to [0, 2]. The second +// return is false when expected production is too small to form a meaningful +// ratio, so callers can render "n/a" rather than a divide-by-tiny artifact. +func PerformanceRatio(expectedWh, actualWh float64) (float64, bool) { + if expectedWh < minExpectedWh { + return 0, false + } + pr := actualWh / expectedWh + if pr < 0 { + pr = 0 + } + if pr > 2 { + pr = 2 + } + return pr, true +} diff --git a/go/internal/pvperf/pvperf_test.go b/go/internal/pvperf/pvperf_test.go new file mode 100644 index 00000000..8408c617 --- /dev/null +++ b/go/internal/pvperf/pvperf_test.go @@ -0,0 +1,83 @@ +package pvperf + +import ( + "math" + "testing" + "time" +) + +// A few clear-ish midday summer hours at Stockholm, GHI + DHI populated. +func summerHours() []Irradiance { + base := time.Date(2024, 6, 21, 8, 0, 0, 0, time.UTC) + ghi := []float64{300, 450, 600, 700, 680, 550, 400, 250} + out := make([]Irradiance, len(ghi)) + for i, g := range ghi { + d := g * 0.25 + out[i] = Irradiance{HourStart: base.Add(time.Duration(i) * time.Hour), GHIWm2: g, DHIWm2: &d} + } + return out +} + +func TestExpectedWhScalesWithKWp(t *testing.T) { + hours := summerHours() + e5 := ExpectedWh(59.33, 18.07, []Array{{KWp: 5, TiltDeg: 35, AzimuthDeg: 180}}, hours) + e10 := ExpectedWh(59.33, 18.07, []Array{{KWp: 10, TiltDeg: 35, AzimuthDeg: 180}}, hours) + if e5 <= 0 { + t.Fatalf("expected positive energy, got %.1f", e5) + } + if math.Abs(e10/e5-2) > 1e-9 { + t.Errorf("10 kWp should be 2× 5 kWp, ratio %.4f", e10/e5) + } +} + +func TestExpectedWhSumsArrays(t *testing.T) { + hours := summerHours() + south := ExpectedWh(59.33, 18.07, []Array{{KWp: 5, TiltDeg: 35, AzimuthDeg: 180}}, hours) + east := ExpectedWh(59.33, 18.07, []Array{{KWp: 5, TiltDeg: 35, AzimuthDeg: 90}}, hours) + both := ExpectedWh(59.33, 18.07, []Array{ + {KWp: 5, TiltDeg: 35, AzimuthDeg: 180}, + {KWp: 5, TiltDeg: 35, AzimuthDeg: 90}, + }, hours) + if math.Abs(both-(south+east)) > 1e-6 { + t.Errorf("multi-array should sum: both=%.2f south+east=%.2f", both, south+east) + } +} + +// Measured diffuse should be honoured: an all-diffuse hour yields less on a +// south-tilted panel than the GHI-only Erbs path (which infers more beam). +func TestExpectedWhUsesMeasuredDiffuse(t *testing.T) { + base := time.Date(2024, 6, 21, 11, 0, 0, 0, time.UTC) + ghiOnly := []Irradiance{{HourStart: base, GHIWm2: 600}} + full := 600.0 + allDiffuse := []Irradiance{{HourStart: base, GHIWm2: 600, DHIWm2: &full}} + arr := []Array{{KWp: 10, TiltDeg: 35, AzimuthDeg: 180}} + + eErbs := ExpectedWh(59.33, 18.07, arr, ghiOnly) + eDiffuse := ExpectedWh(59.33, 18.07, arr, allDiffuse) + if !(eDiffuse < eErbs) { + t.Errorf("all-diffuse (%.1f) should be < Erbs-inferred-beam (%.1f)", eDiffuse, eErbs) + } +} + +func TestExpectedWhZeroWhenDark(t *testing.T) { + night := []Irradiance{{HourStart: time.Date(2024, 12, 21, 23, 0, 0, 0, time.UTC), GHIWm2: 0}} + if e := ExpectedWh(59.33, 18.07, []Array{{KWp: 10, TiltDeg: 35, AzimuthDeg: 180}}, night); e != 0 { + t.Errorf("dark hours should yield 0 Wh, got %.2f", e) + } +} + +func TestPerformanceRatio(t *testing.T) { + if _, ok := PerformanceRatio(50, 40); ok { + t.Error("tiny expected should be n/a") + } + pr, ok := PerformanceRatio(1000, 850) + if !ok || math.Abs(pr-0.85) > 1e-9 { + t.Errorf("pr=%.3f ok=%v, want 0.85 true", pr, ok) + } + if pr, _ := PerformanceRatio(1000, 5000); pr != 2 { + t.Errorf("PR should clamp high to 2, got %.2f", pr) + } + if pr, _ := PerformanceRatio(1000, -10); pr != 0 { + t.Errorf("PR should clamp low to 0, got %.2f", pr) + } +} diff --git a/go/internal/pvperf/service.go b/go/internal/pvperf/service.go new file mode 100644 index 00000000..cc9b0cce --- /dev/null +++ b/go/internal/pvperf/service.go @@ -0,0 +1,221 @@ +package pvperf + +import ( + "context" + "log/slog" + "time" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/coverage" + "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/strang" +) + +// irradianceSource labels rows persisted by this service. +const irradianceSource = "strang" + +// defaultLookbackDays is how far back the backfill scores on each run. Older +// closed days already have an immutable cached score and are skipped. +const defaultLookbackDays = 30 + +// rescoreTailDays is how many of the most recent days are recomputed on every +// run even if already scored, so the STRÅNG ~1-day analysis lag is corrected as +// data lands (a day scored against partial irradiance is refreshed next run). +const rescoreTailDays = 3 + +// Service backfills historical STRÅNG irradiance and scores realised PV +// production against it, once at startup and nightly thereafter. It is +// read-only with respect to control: it only fetches weather data and writes +// the irradiance_history + pv_performance_daily tables. Nil when the site has +// no usable PV geometry (scoring is impossible), which the API surfaces as +// {enabled:false}. +type Service struct { + Store *state.Store + Strang *strang.Client + Lat, Lon float64 + Arrays []Array + LookbackDays int + + stop chan struct{} + done chan struct{} +} + +// FromConfig builds a scoring Service from the weather config, mirroring how +// forecast.FromConfig derives per-plane geometry (explicit pv_arrays, else a +// single synthesized array from the legacy flat fields). Returns nil when no +// geometry is available — without arrays there is nothing to score against. +func FromConfig(cfg *config.Weather, ratedPVW float64, st *state.Store, userAgent string) *Service { + if cfg == nil || st == nil { + return nil + } + var arrays []Array + for _, a := range cfg.PVArrays { + if a.KWp > 0 { + arrays = append(arrays, Array{KWp: a.KWp, TiltDeg: a.TiltDeg, AzimuthDeg: a.AzimuthDeg}) + } + } + if len(arrays) == 0 && ratedPVW > 0 { + arrays = append(arrays, Array{KWp: ratedPVW / 1000.0, TiltDeg: cfg.PVTiltDeg, AzimuthDeg: cfg.PVAzimuthDeg}) + } + if len(arrays) == 0 { + return nil + } + // STRÅNG only models the Nordic domain. Outside it every nightly backfill + // would spend three HTTP requests to be told nothing, forever, so decline + // to start at all. GET /api/data-sources is where an operator finds out + // why — this is a silent no-op by design, not a hidden failure. + if !coverage.Covers("strang", cfg.Latitude, cfg.Longitude) { + slog.Info("pvperf: site is outside the STRÅNG domain, PV performance scoring disabled", + "lat", cfg.Latitude, "lon", cfg.Longitude) + return nil + } + return &Service{ + Store: st, + Strang: strang.NewClient(userAgent), + Lat: cfg.Latitude, + Lon: cfg.Longitude, + Arrays: arrays, + LookbackDays: defaultLookbackDays, + stop: make(chan struct{}), + done: make(chan struct{}), + } +} + +// Start runs an initial backfill shortly after boot, then nightly. +func (s *Service) Start(ctx context.Context) { + go s.loop(ctx) +} + +// Stop terminates the backfill loop and waits for it to drain. +func (s *Service) Stop() { + close(s.stop) + <-s.done +} + +func (s *Service) loop(ctx context.Context) { + defer close(s.done) + // Delay the first run so boot isn't competing with a network fetch, and + // so telemetry has a moment to settle before we read history. + first := time.NewTimer(3 * time.Minute) + defer first.Stop() + tick := time.NewTicker(24 * time.Hour) + defer tick.Stop() + for { + select { + case <-s.stop: + return + case <-ctx.Done(): + return + case <-first.C: + s.runBackfill(ctx) + case <-tick.C: + s.runBackfill(ctx) + } + } +} + +// runBackfill fetches the lookback window from STRÅNG, persists the irradiance, +// and scores each closed day that is missing or within the rescore tail. +func (s *Service) runBackfill(ctx context.Context) { + fetchCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + now := time.Now() + loc := now.Location() + todayMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + windowStart := todayMidnight.AddDate(0, 0, -s.LookbackDays) + + // STRÅNG takes calendar dates (UTC). Pad by a day each side so local-day + // boundaries are fully covered regardless of the UTC offset. + hours, err := s.Strang.FetchWindow(fetchCtx, s.Lat, s.Lon, + windowStart.UTC().AddDate(0, 0, -1), todayMidnight.UTC()) + if err != nil { + slog.Warn("pvperf: STRÅNG fetch failed", "err", err) + return + } + if len(hours) == 0 { + slog.Info("pvperf: STRÅNG returned no data for window", "lat", s.Lat, "lon", s.Lon) + return + } + + fetchedAtMs := now.UnixMilli() + rows := make([]state.IrradianceRow, 0, len(hours)) + for _, h := range hours { + rows = append(rows, state.IrradianceRow{ + SlotTsMs: h.HourStart.UnixMilli(), + GHIWm2: h.GHIWm2, + DHIWm2: h.DHIWm2, + Source: irradianceSource, + FetchedAtMs: fetchedAtMs, + }) + } + if err := s.Store.SaveIrradiance(rows); err != nil { + slog.Warn("pvperf: save irradiance failed", "err", err) + return + } + + scored := 0 + // Score closed days only (strictly before today's midnight). + for i := s.LookbackDays; i >= 1; i-- { + dayStart := todayMidnight.AddDate(0, 0, -i) + dayEnd := dayStart.AddDate(0, 0, 1) + day := dayStart.Format("2006-01-02") + + // Skip days already scored, except the recent tail we always refresh. + if i > rescoreTailDays { + if _, ok, _ := s.Store.LoadPVPerformanceDay(day); ok { + continue + } + } + if s.scoreDay(day, dayStart, dayEnd, hours, fetchedAtMs) { + scored++ + } + } + slog.Info("pvperf: backfill complete", "irradiance_rows", len(rows), "days_scored", scored) +} + +// scoreDay computes and persists one day's performance score. Returns false +// (and persists nothing) when there is no measured PV history for the day. +func (s *Service) scoreDay(day string, dayStart, dayEnd time.Time, hours []strang.IrradianceHour, fetchedAtMs int64) bool { + startMs, endMs := dayStart.UnixMilli(), dayEnd.UnixMilli() + + dayHours := make([]Irradiance, 0, 24) + for _, h := range hours { + ms := h.HourStart.UnixMilli() + if ms < startMs || ms >= endMs { + continue + } + dayHours = append(dayHours, Irradiance{HourStart: h.HourStart, GHIWm2: h.GHIWm2, DHIWm2: h.DHIWm2}) + } + + de, err := s.Store.DailyEnergy(startMs, endMs-1) + if err != nil { + slog.Warn("pvperf: read actual energy failed", "day", day, "err", err) + return false + } + if de.Intervals == 0 { + // No measured history for this day — nothing to score against. + return false + } + + expectedWh := ExpectedWh(s.Lat, s.Lon, s.Arrays, dayHours) + rec := state.PVPerformanceDay{ + Day: day, + ExpectedWh: expectedWh, + ActualWh: de.PVWh, + StrangDataDateMs: &fetchedAtMs, + } + if pr, ok := PerformanceRatio(expectedWh, de.PVWh); ok { + rec.PR = &pr + } + if err := s.Store.SavePVPerformance(rec); err != nil { + slog.Warn("pvperf: save score failed", "day", day, "err", err) + return false + } + return true +} + +// Load returns scored days in [sinceDay, untilDay] (inclusive YYYY-MM-DD). +func (s *Service) Load(sinceDay, untilDay string) ([]state.PVPerformanceDay, error) { + return s.Store.LoadPVPerformance(sinceDay, untilDay) +} diff --git a/go/internal/pvperf/service_test.go b/go/internal/pvperf/service_test.go new file mode 100644 index 00000000..e14628ce --- /dev/null +++ b/go/internal/pvperf/service_test.go @@ -0,0 +1,132 @@ +package pvperf + +import ( + "math" + "path/filepath" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/strang" +) + +func openStore(t *testing.T) *state.Store { + t.Helper() + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + return st +} + +func TestFromConfigGating(t *testing.T) { + st := openStore(t) + + if FromConfig(nil, 5000, st, "ua") != nil { + t.Error("nil weather config should yield nil service") + } + if FromConfig(&config.Weather{Latitude: 59, Longitude: 18}, 0, st, "ua") != nil { + t.Error("no arrays and no rated PV should yield nil service") + } + + // Explicit arrays win. + svc := FromConfig(&config.Weather{ + Latitude: 59, Longitude: 18, + PVArrays: []config.PVArray{{KWp: 5, TiltDeg: 35, AzimuthDeg: 180}, {KWp: 3, TiltDeg: 20, AzimuthDeg: 90}}, + }, 0, st, "ua") + if svc == nil || len(svc.Arrays) != 2 { + t.Fatalf("expected 2 arrays, got %+v", svc) + } + if svc.Arrays[0].KWp != 5 || svc.Arrays[1].AzimuthDeg != 90 { + t.Errorf("array geometry mismatch: %+v", svc.Arrays) + } + + // Flat fallback synthesizes one array from rated + legacy tilt/azimuth. + flat := FromConfig(&config.Weather{ + Latitude: 59, Longitude: 18, PVTiltDeg: 30, PVAzimuthDeg: 180, + }, 8000, st, "ua") + if flat == nil || len(flat.Arrays) != 1 { + t.Fatalf("flat fallback should synthesize one array, got %+v", flat) + } + if flat.Arrays[0].KWp != 8 || flat.Arrays[0].TiltDeg != 30 { + t.Errorf("synthesized array mismatch: %+v", flat.Arrays[0]) + } +} + +// A bell-ish clear day of hourly GHI centered on solar noon. +func syntheticDay(dayStart time.Time) []strang.IrradianceHour { + out := []strang.IrradianceHour{} + for hr := 4; hr <= 20; hr++ { + // crude parabola peaking ~700 W/m² at hour 12 + g := 700.0 - 12.0*float64((hr-12)*(hr-12)) + if g < 0 { + g = 0 + } + out = append(out, strang.IrradianceHour{ + HourStart: dayStart.Add(time.Duration(hr) * time.Hour), + GHIWm2: g, + }) + } + return out +} + +func TestScoreDayPersistsExpectedVsActual(t *testing.T) { + st := openStore(t) + svc := &Service{ + Store: st, + Lat: 59.33, + Lon: 18.07, + Arrays: []Array{{KWp: 10, TiltDeg: 35, AzimuthDeg: 180}}, + } + + dayStart := time.Date(2024, 6, 21, 0, 0, 0, 0, time.UTC) + dayEnd := dayStart.AddDate(0, 0, 1) + day := dayStart.Format("2006-01-02") + + // Seed measured PV history: -2000 W constant across 8h → 16000 Wh produced + // (PV is stored site-signed negative; DailyEnergy integrates SUM(-pv_w·Δt)). + if err := st.RecordHistory(state.HistoryPoint{TsMs: dayStart.Add(8 * time.Hour).UnixMilli(), PVW: -2000}); err != nil { + t.Fatal(err) + } + if err := st.RecordHistory(state.HistoryPoint{TsMs: dayStart.Add(16 * time.Hour).UnixMilli(), PVW: -2000}); err != nil { + t.Fatal(err) + } + + hours := syntheticDay(dayStart) + if !svc.scoreDay(day, dayStart, dayEnd, hours, 12345) { + t.Fatal("scoreDay should succeed with history present") + } + + got, ok, err := st.LoadPVPerformanceDay(day) + if err != nil || !ok { + t.Fatalf("score not persisted: ok=%v err=%v", ok, err) + } + if math.Abs(got.ActualWh-16000) > 1 { + t.Errorf("actual Wh: want ~16000, got %.1f", got.ActualWh) + } + if got.ExpectedWh <= 0 { + t.Errorf("expected Wh should be positive, got %.1f", got.ExpectedWh) + } + if got.PR == nil { + t.Error("PR should be set when expected is above the floor") + } + if got.StrangDataDateMs == nil || *got.StrangDataDateMs != 12345 { + t.Errorf("provenance not stamped: %+v", got.StrangDataDateMs) + } +} + +func TestScoreDaySkipsWhenNoHistory(t *testing.T) { + st := openStore(t) + svc := &Service{Store: st, Lat: 59.33, Lon: 18.07, Arrays: []Array{{KWp: 10, TiltDeg: 35, AzimuthDeg: 180}}} + + dayStart := time.Date(2024, 6, 21, 0, 0, 0, 0, time.UTC) + day := dayStart.Format("2006-01-02") + if svc.scoreDay(day, dayStart, dayStart.AddDate(0, 0, 1), syntheticDay(dayStart), 1) { + t.Error("scoreDay should return false with no measured history") + } + if _, ok, _ := st.LoadPVPerformanceDay(day); ok { + t.Error("nothing should be persisted when there's no history") + } +} diff --git a/go/internal/state/pvperf.go b/go/internal/state/pvperf.go new file mode 100644 index 00000000..59476f1e --- /dev/null +++ b/go/internal/state/pvperf.go @@ -0,0 +1,138 @@ +package state + +import ( + "database/sql" + "time" +) + +// ---- Irradiance history (cache.db) ---- + +// IrradianceRow is one hour of historical horizontal irradiance (W/m²). +// DHIWm2 is nil when the source did not provide a diffuse component. +type IrradianceRow struct { + SlotTsMs int64 `json:"slot_ts_ms"` + GHIWm2 float64 `json:"ghi_wm2"` + DHIWm2 *float64 `json:"dhi_wm2,omitempty"` + Source string `json:"source"` + FetchedAtMs int64 `json:"fetched_at_ms"` +} + +// SaveIrradiance upserts a batch of historical-irradiance rows (keyed by +// slot_ts_ms). Re-fetching a window overwrites the existing rows. +func (s *Store) SaveIrradiance(rows []IrradianceRow) error { + if len(rows) == 0 { + return nil + } + tx, err := s.cache.Begin() + if err != nil { + return err + } + defer tx.Rollback() + stmt, err := tx.Prepare(`INSERT INTO irradiance_history + (slot_ts_ms, ghi_wm2, dhi_wm2, source, fetched_at_ms) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (slot_ts_ms) DO UPDATE SET + ghi_wm2 = excluded.ghi_wm2, + dhi_wm2 = excluded.dhi_wm2, + source = excluded.source, + fetched_at_ms = excluded.fetched_at_ms`) + if err != nil { + return err + } + defer stmt.Close() + for _, r := range rows { + if _, err := stmt.Exec(r.SlotTsMs, r.GHIWm2, r.DHIWm2, r.Source, r.FetchedAtMs); err != nil { + return err + } + } + return tx.Commit() +} + +// LoadIrradiance returns irradiance rows in [sinceMs, untilMs], ascending. +func (s *Store) LoadIrradiance(sinceMs, untilMs int64) ([]IrradianceRow, error) { + rows, err := s.cache.Query(`SELECT slot_ts_ms, ghi_wm2, dhi_wm2, source, fetched_at_ms + FROM irradiance_history + WHERE slot_ts_ms BETWEEN ? AND ? + ORDER BY slot_ts_ms ASC`, sinceMs, untilMs) + if err != nil { + return nil, err + } + defer rows.Close() + out := []IrradianceRow{} + for rows.Next() { + var r IrradianceRow + if err := rows.Scan(&r.SlotTsMs, &r.GHIWm2, &r.DHIWm2, &r.Source, &r.FetchedAtMs); err != nil { + return out, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// ---- PV performance daily (state.db) ---- + +// PVPerformanceDay is one day's PV performance score: expected DC energy under +// measured irradiance versus the site's actual generation, plus their ratio. +// PR is nil when expected production was below a meaningful floor (n/a). +type PVPerformanceDay struct { + Day string `json:"day"` // YYYY-MM-DD, local date + ExpectedWh float64 `json:"expected_wh"` + ActualWh float64 `json:"actual_wh"` + PR *float64 `json:"pr,omitempty"` + StrangDataDateMs *int64 `json:"strang_data_date_ms,omitempty"` + ComputedAtMs int64 `json:"computed_at_ms"` +} + +// SavePVPerformance upserts one day's PV performance score (keyed by day). +func (s *Store) SavePVPerformance(p PVPerformanceDay) error { + const q = ` + INSERT INTO pv_performance_daily( + day, expected_wh, actual_wh, pr, strang_data_date_ms, computed_at_ms + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(day) DO UPDATE SET + expected_wh = excluded.expected_wh, + actual_wh = excluded.actual_wh, + pr = excluded.pr, + strang_data_date_ms = excluded.strang_data_date_ms, + computed_at_ms = excluded.computed_at_ms + ` + _, err := s.db.Exec(q, p.Day, p.ExpectedWh, p.ActualWh, p.PR, p.StrangDataDateMs, time.Now().UnixMilli()) + return err +} + +// LoadPVPerformanceDay returns one day's score, or ok=false on a cache miss. +func (s *Store) LoadPVPerformanceDay(day string) (PVPerformanceDay, bool, error) { + const q = `SELECT day, expected_wh, actual_wh, pr, strang_data_date_ms, computed_at_ms + FROM pv_performance_daily WHERE day = ?` + var p PVPerformanceDay + err := s.db.QueryRow(q, day).Scan(&p.Day, &p.ExpectedWh, &p.ActualWh, &p.PR, &p.StrangDataDateMs, &p.ComputedAtMs) + if err == sql.ErrNoRows { + return PVPerformanceDay{}, false, nil + } + if err != nil { + return PVPerformanceDay{}, false, err + } + return p, true, nil +} + +// LoadPVPerformance returns scored days in [sinceDay, untilDay] (inclusive, +// YYYY-MM-DD string compare), ascending by day. +func (s *Store) LoadPVPerformance(sinceDay, untilDay string) ([]PVPerformanceDay, error) { + rows, err := s.db.Query(`SELECT day, expected_wh, actual_wh, pr, strang_data_date_ms, computed_at_ms + FROM pv_performance_daily + WHERE day BETWEEN ? AND ? + ORDER BY day ASC`, sinceDay, untilDay) + if err != nil { + return nil, err + } + defer rows.Close() + out := []PVPerformanceDay{} + for rows.Next() { + var p PVPerformanceDay + if err := rows.Scan(&p.Day, &p.ExpectedWh, &p.ActualWh, &p.PR, &p.StrangDataDateMs, &p.ComputedAtMs); err != nil { + return out, err + } + out = append(out, p) + } + return out, rows.Err() +} diff --git a/go/internal/state/pvperf_test.go b/go/internal/state/pvperf_test.go new file mode 100644 index 00000000..ef1becad --- /dev/null +++ b/go/internal/state/pvperf_test.go @@ -0,0 +1,119 @@ +package state + +import ( + "testing" +) + +func f64(v float64) *float64 { return &v } +func i64(v int64) *int64 { return &v } + +func TestSaveIrradianceRoundtrip(t *testing.T) { + s := freshStore(t) + rows := []IrradianceRow{ + {SlotTsMs: 1000, GHIWm2: 300, DHIWm2: f64(80), Source: "strang", FetchedAtMs: 5000}, + {SlotTsMs: 2000, GHIWm2: 450, DHIWm2: nil, Source: "strang", FetchedAtMs: 5000}, + } + if err := s.SaveIrradiance(rows); err != nil { + t.Fatal(err) + } + got, err := s.LoadIrradiance(0, 10000) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("want 2 rows, got %d", len(got)) + } + if got[0].SlotTsMs != 1000 || got[0].GHIWm2 != 300 || got[0].DHIWm2 == nil || *got[0].DHIWm2 != 80 { + t.Errorf("row0 mismatch: %+v", got[0]) + } + if got[1].DHIWm2 != nil { + t.Errorf("row1 diffuse should be nil, got %v", *got[1].DHIWm2) + } + + // Upsert overwrites, no duplicate. + if err := s.SaveIrradiance([]IrradianceRow{{SlotTsMs: 1000, GHIWm2: 999, Source: "strang", FetchedAtMs: 6000}}); err != nil { + t.Fatal(err) + } + got, _ = s.LoadIrradiance(0, 10000) + if len(got) != 2 { + t.Fatalf("upsert should not add a row, got %d", len(got)) + } + if got[0].GHIWm2 != 999 { + t.Errorf("upsert should overwrite ghi, got %.0f", got[0].GHIWm2) + } +} + +func TestLoadIrradianceRangeFilters(t *testing.T) { + s := freshStore(t) + _ = s.SaveIrradiance([]IrradianceRow{ + {SlotTsMs: 100, GHIWm2: 1, Source: "strang", FetchedAtMs: 1}, + {SlotTsMs: 200, GHIWm2: 2, Source: "strang", FetchedAtMs: 1}, + {SlotTsMs: 300, GHIWm2: 3, Source: "strang", FetchedAtMs: 1}, + }) + got, err := s.LoadIrradiance(150, 250) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].SlotTsMs != 200 { + t.Fatalf("range filter failed: %+v", got) + } +} + +func TestPVPerformanceRoundtrip(t *testing.T) { + s := freshStore(t) + day := "2026-06-21" + p := PVPerformanceDay{ + Day: day, + ExpectedWh: 12000, + ActualWh: 10800, + PR: f64(0.9), + StrangDataDateMs: i64(1719000000000), + } + if err := s.SavePVPerformance(p); err != nil { + t.Fatal(err) + } + got, ok, err := s.LoadPVPerformanceDay(day) + if err != nil || !ok { + t.Fatalf("load ok=%v err=%v", ok, err) + } + if got.ExpectedWh != 12000 || got.ActualWh != 10800 || got.PR == nil || *got.PR != 0.9 { + t.Errorf("mismatch: %+v", got) + } + if got.StrangDataDateMs == nil || *got.StrangDataDateMs != 1719000000000 { + t.Errorf("provenance mismatch: %+v", got.StrangDataDateMs) + } + if got.ComputedAtMs == 0 { + t.Error("computed_at_ms should be stamped") + } + + // Upsert with n/a PR (nil) overwrites. + p.PR = nil + p.ExpectedWh = 50 + if err := s.SavePVPerformance(p); err != nil { + t.Fatal(err) + } + got, _, _ = s.LoadPVPerformanceDay(day) + if got.PR != nil { + t.Errorf("PR should be nil after upsert, got %v", *got.PR) + } + if got.ExpectedWh != 50 { + t.Errorf("expected_wh should overwrite, got %.0f", got.ExpectedWh) + } +} + +func TestLoadPVPerformanceMissAndRange(t *testing.T) { + s := freshStore(t) + if _, ok, err := s.LoadPVPerformanceDay("2000-01-01"); ok || err != nil { + t.Fatalf("miss should be ok=false err=nil, got ok=%v err=%v", ok, err) + } + for _, d := range []string{"2026-06-19", "2026-06-20", "2026-06-21"} { + _ = s.SavePVPerformance(PVPerformanceDay{Day: d, ExpectedWh: 1000, ActualWh: 900, PR: f64(0.9)}) + } + got, err := s.LoadPVPerformance("2026-06-20", "2026-06-21") + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0].Day != "2026-06-20" || got[1].Day != "2026-06-21" { + t.Fatalf("range/order wrong: %+v", got) + } +} diff --git a/go/internal/state/store.go b/go/internal/state/store.go index 1aea5115..e0d98613 100644 --- a/go/internal/state/store.go +++ b/go/internal/state/store.go @@ -1030,6 +1030,22 @@ func (s *Store) migrate() error { ts_ms INTEGER NOT NULL, PRIMARY KEY(asset_id, flow, cursor_kind) ) WITHOUT ROWID, STRICT`, + // Persistent per-day PV performance score: the DC energy the + // configured arrays should have produced under measured STRÅNG + // irradiance (expected_wh) versus what the site actually generated + // (actual_wh, from history), and their ratio. Precious like + // energy_daily — closed days are immutable, so a computed score is + // cached here forever and never recomputed. pr is null when expected + // production was below a meaningful floor (polar-night / near-dark). + // strang_data_date_ms records the STRÅNG fetch time for provenance. + `CREATE TABLE IF NOT EXISTS pv_performance_daily ( + day TEXT PRIMARY KEY, + expected_wh REAL NOT NULL, + actual_wh REAL NOT NULL, + pr REAL, + strang_data_date_ms INTEGER, + computed_at_ms INTEGER NOT NULL + ) STRICT`, } for _, stmt := range stmts { if _, err := s.db.Exec(stmt); err != nil { @@ -1068,6 +1084,18 @@ func (s *Store) migrate() error { source TEXT NOT NULL, fetched_at_ms INTEGER NOT NULL )`, + // Historical solar irradiance — one row per hour. Backfilled from + // SMHI STRÅNG (an analysis product, ~1-day lag) to score realised PV + // performance against a weather-expected baseline. Disposable and + // re-fetchable, so it lives in cache.db alongside forecasts. dhi_wm2 + // (diffuse) is null when the source doesn't provide it. + `CREATE TABLE IF NOT EXISTS irradiance_history ( + slot_ts_ms INTEGER PRIMARY KEY, + ghi_wm2 REAL NOT NULL, + dhi_wm2 REAL, + source TEXT NOT NULL, + fetched_at_ms INTEGER NOT NULL + )`, } for _, stmt := range cacheStmts { if _, err := s.cache.Exec(stmt); err != nil { diff --git a/go/internal/strang/strang.go b/go/internal/strang/strang.go new file mode 100644 index 00000000..579d5136 --- /dev/null +++ b/go/internal/strang/strang.go @@ -0,0 +1,232 @@ +// Package strang fetches historical solar irradiance from SMHI's STRÅNG +// mesoscale model (https://strang.smhi.se/). +// +// STRÅNG is an analysis/reanalysis product: it covers the Nordic region hourly +// at ~2.5 km resolution from 1999 to ~1 day ago. It has NO forward horizon, so +// it is used here for historical PV-performance scoring and model calibration, +// never as a forward forecast provider (those stay in the forecast package). +// +// Data is free and licensed CC BY 4.0 — attribution to SMHI required. No API +// key. The public point time-series endpoint is: +// +// {base}/geotype/point/lon/{lon}/lat/{lat}/parameter/{p}/data.json?from=&to=&interval=hourly +package strang + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sort" + "time" + + "github.com/srcfl/ftw/go/internal/coverage" + "github.com/srcfl/ftw/go/internal/sunpos" +) + +// ErrOutsideDomain is returned when a request falls outside STRÅNG's Nordic +// model domain. Callers should treat it as "this site can never be scored by +// STRÅNG" and stop asking, rather than as a transient failure to retry. +var ErrOutsideDomain = errors.New("outside STRÅNG domain") + +// STRÅNG parameter codes (category "strang1g", version 1). +// +// These are the complete set — probing 100..130 against the live API on +// 2026-07-31 returned 200 for exactly 116..122 and 404 for everything else. +// Names were confirmed by their magnitudes on a clear day at Stockholm rather +// than from documentation, since SMHI's apidocs pages currently 404: parameter +// 119 caps at exactly 60 (minutes in an hour), 118 exceeds 117 the way direct +// *normal* irradiance exceeds global, and at solar noon 121 + 122 = 723.0 + +// 87.5 = 810.5, which is exactly 117 — the direct-plus-diffuse identity that +// tells us 121 is direct *horizontal* and not something else. +// +// Note what is absent: STRÅNG models radiation only and publishes no cloud +// cover. See CloudCover for how cloudiness is recovered from parameter 119. +const ( + ParamCIEUV = 116 // CIE-weighted UV irradiance, mW/m² + ParamGlobalIrradiance = 117 // Global (horizontal) irradiance, W/m² — GHI + ParamDirectNormal = 118 // Direct normal irradiance, W/m² — DNI + ParamSunshineDuration = 119 // Sunshine duration within the hour, minutes 0..60 + ParamPAR = 120 // Photosynthetically active radiation, W/m² + ParamDirectHorizontal = 121 // Direct horizontal irradiance, W/m² + ParamDiffuseIrradiance = 122 // Diffuse (horizontal) irradiance, W/m² — DHI +) + +// minutesPerHour is the full-sun value of ParamSunshineDuration. +const minutesPerHour = 60.0 + +// DefaultBaseURL is SMHI's open-data meteorological-analysis host for STRÅNG. +const DefaultBaseURL = "https://opendata-download-metanalys.smhi.se/api/category/strang1g/version/1" + +// IrradianceHour is one hour of horizontal irradiance at a point. DHIWm2 is nil +// when the diffuse component is unavailable (e.g. windows before 2017-04-18, or +// a transient diffuse-parameter error) — callers then estimate the split. +// SunshineMin is nil on the same terms and carries parameter 119. +type IrradianceHour struct { + HourStart time.Time + GHIWm2 float64 + DHIWm2 *float64 + SunshineMin *float64 +} + +// CloudCover derives a cloud-cover fraction (0 = clear, 1 = overcast) for the +// hour at (lat, lon), and reports whether it could be derived at all. +// +// STRÅNG publishes no cloud-cover parameter, but parameter 119 is sunshine +// duration: the number of minutes in the hour during which direct beam +// irradiance exceeded the WMO sunshine threshold (120 W/m²). One minus that +// fraction is the share of the hour the sun spent obscured, which is what +// "cloud cover" means for a solar model's purposes. +// +// This is an *observed* quantity, not an inference from a cloud field, so it is +// better grounded than a forecast provider's cloud percentage. It is coarser in +// one direction: it cannot see thin cirrus that dims without blocking. +// +// The location is required because sunshine duration is zero at night for the +// trivial reason that there is no sun — reading that as "100% overcast" would be +// confidently wrong every single night. When the sun is below the horizon for +// the whole hour this returns not-ok, and callers must treat that as unknown +// rather than as clear or as overcast. +func (h IrradianceHour) CloudCover(lat, lon float64) (float64, bool) { + if h.SunshineMin == nil { + return 0, false + } + if !h.daylight(lat, lon) { + return 0, false + } + m := *h.SunshineMin + if m < 0 { + return 0, false + } + if m > minutesPerHour { + m = minutesPerHour + } + return 1 - m/minutesPerHour, true +} + +// minSunElevationDeg is how high the sun must get during the hour before a +// sunshine-duration reading says anything about cloud. +// +// The WMO sunshine threshold is 120 W/m² of direct beam. Near the horizon the +// beam crosses roughly ten or more air masses and cannot reach that threshold +// even under a spotless sky, so a zero reading there is a statement about +// geometry, not about cloud. Five degrees is where the beam can plausibly clear +// the threshold; below it we decline to answer rather than report a twilight +// hour as fully overcast. +const minSunElevationDeg = 5.0 + +// daylight reports whether the sun climbs above minSunElevationDeg at any point +// in the hour. Sampling start, middle and end catches the sunrise and sunset +// hours, where the midpoint alone would misclassify half the hour. +func (h IrradianceHour) daylight(lat, lon float64) bool { + for _, off := range []time.Duration{0, 30 * time.Minute, 59 * time.Minute} { + if sunpos.At(h.HourStart.Add(off), lat, lon).ZenithDeg < 90-minSunElevationDeg { + return true + } + } + return false +} + +// Client is a thin STRÅNG point-series HTTP client. +type Client struct { + HTTP *http.Client + BaseURL string + UserAgent string +} + +// NewClient returns a Client with sane defaults. A descriptive User-Agent is +// required by SMHI's fair-use policy, mirroring the forecast providers. +func NewClient(userAgent string) *Client { + if userAgent == "" { + userAgent = "FTW github.com/srcfl/ftw" + } + return &Client{ + HTTP: &http.Client{Timeout: 30 * time.Second}, + BaseURL: DefaultBaseURL, + UserAgent: userAgent, + } +} + +// FetchWindow returns hourly irradiance for [start, end] (dates, UTC) at +// (lat, lon). Global irradiance is required; diffuse is best-effort — a diffuse +// error (common for pre-2017 windows) leaves DHIWm2 nil rather than failing the +// whole window. Rows are returned ascending by hour. +func (c *Client) FetchWindow(ctx context.Context, lat, lon float64, start, end time.Time) ([]IrradianceHour, error) { + if !coverage.Covers("strang", lat, lon) { + return nil, fmt.Errorf("strang: %w: (%.4f, %.4f) is outside the Nordic model domain", ErrOutsideDomain, lat, lon) + } + ghi, err := c.fetchParam(ctx, lat, lon, ParamGlobalIrradiance, start, end) + if err != nil { + return nil, fmt.Errorf("strang: global irradiance: %w", err) + } + // Best-effort extras: never fail the window because a secondary parameter + // errored. Both leave their field nil and callers fall back — the diffuse + // split is estimated, and cloud cover simply reports unknown. + dhi, _ := c.fetchParam(ctx, lat, lon, ParamDiffuseIrradiance, start, end) + sun, _ := c.fetchParam(ctx, lat, lon, ParamSunshineDuration, start, end) + + hours := make([]int64, 0, len(ghi)) + for ms := range ghi { + hours = append(hours, ms) + } + sort.Slice(hours, func(i, j int) bool { return hours[i] < hours[j] }) + + out := make([]IrradianceHour, 0, len(hours)) + for _, ms := range hours { + h := IrradianceHour{HourStart: time.UnixMilli(ms).UTC(), GHIWm2: ghi[ms]} + if d, ok := dhi[ms]; ok { + dv := d + h.DHIWm2 = &dv + } + if s, ok := sun[ms]; ok { + sv := s + h.SunshineMin = &sv + } + out = append(out, h) + } + return out, nil +} + +// fetchParam returns hour-start-ms → value for one STRÅNG parameter. +func (c *Client) fetchParam(ctx context.Context, lat, lon float64, param int, start, end time.Time) (map[int64]float64, error) { + url := fmt.Sprintf("%s/geotype/point/lon/%.4f/lat/%.4f/parameter/%d/data.json?from=%s&to=%s&interval=hourly", + c.BaseURL, lon, lat, param, + start.UTC().Format("2006-01-02"), end.UTC().Format("2006-01-02")) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", c.UserAgent) + req.Header.Set("Accept", "application/json") + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(body)) + } + // STRÅNG data.json is an array of {date_time, value}. + var doc []struct { + DateTime string `json:"date_time"` + Value *float64 `json:"value"` + } + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + out := make(map[int64]float64, len(doc)) + for _, d := range doc { + if d.Value == nil { + continue + } + t, err := time.Parse(time.RFC3339, d.DateTime) + if err != nil { + continue + } + out[t.UTC().Truncate(time.Hour).UnixMilli()] = *d.Value + } + return out, nil +} diff --git a/go/internal/strang/strang_test.go b/go/internal/strang/strang_test.go new file mode 100644 index 00000000..1596fbff --- /dev/null +++ b/go/internal/strang/strang_test.go @@ -0,0 +1,306 @@ +package strang + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/sunpos" +) + +func TestFetchWindowMergesGHIAndDHI(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var arr []map[string]any + switch { + case strings.Contains(r.URL.Path, "/parameter/117/"): + arr = []map[string]any{ + {"date_time": "2024-06-01T10:00:00Z", "value": 500.0}, + {"date_time": "2024-06-01T11:00:00Z", "value": 650.0}, + } + case strings.Contains(r.URL.Path, "/parameter/122/"): + arr = []map[string]any{ + {"date_time": "2024-06-01T10:00:00Z", "value": 120.0}, + {"date_time": "2024-06-01T11:00:00Z", "value": 150.0}, + } + } + _ = json.NewEncoder(w).Encode(arr) + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + c := NewClient("test") + c.BaseURL = srv.URL + hours, err := c.FetchWindow(context.Background(), 59.33, 18.07, + time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC), + time.Date(2024, 6, 2, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + if len(hours) != 2 { + t.Fatalf("got %d hours, want 2", len(hours)) + } + if hours[0].GHIWm2 != 500 || hours[1].GHIWm2 != 650 { + t.Errorf("GHI mismatch: %+v", hours) + } + if hours[0].DHIWm2 == nil || *hours[0].DHIWm2 != 120 { + t.Errorf("DHI[0] mismatch: %+v", hours[0]) + } + if !hours[0].HourStart.Before(hours[1].HourStart) { + t.Error("hours should be ascending") + } +} + +// Diffuse (122) unavailable — common for pre-2017 windows — must not fail the +// window; GHI still returns with nil DHI. +func TestFetchWindowDiffuseErrorTolerated(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/parameter/122/") { + w.WriteHeader(500) + return + } + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"date_time": "2016-06-01T10:00:00Z", "value": 480.0}, + }) + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + c := NewClient("test") + c.BaseURL = srv.URL + hours, err := c.FetchWindow(context.Background(), 59, 18, + time.Date(2016, 6, 1, 0, 0, 0, 0, time.UTC), + time.Date(2016, 6, 2, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("diffuse error should not fail window: %v", err) + } + if len(hours) != 1 || hours[0].DHIWm2 != nil { + t.Errorf("expected 1 hour with nil DHI, got %+v", hours) + } +} + +// Global (117) error must fail the window — GHI is required. +func TestFetchWindowGlobalErrorFails(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + })) + defer srv.Close() + + c := NewClient("t") + c.BaseURL = srv.URL + _, err := c.FetchWindow(context.Background(), 59, 18, + time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)) + if err == nil { + t.Error("global irradiance error should fail the window") + } +} + +// Null values in the series are skipped, not decoded as 0. +func TestFetchWindowSkipsNullValues(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/parameter/122/") { + _ = json.NewEncoder(w).Encode([]map[string]any{}) + return + } + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"date_time": "2024-06-01T10:00:00Z", "value": 500.0}, + {"date_time": "2024-06-01T11:00:00Z", "value": nil}, + }) + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + c := NewClient("t") + c.BaseURL = srv.URL + hours, err := c.FetchWindow(context.Background(), 59, 18, + time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC), + time.Date(2024, 6, 2, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + if len(hours) != 1 || hours[0].GHIWm2 != 500 { + t.Errorf("null value should be skipped, got %+v", hours) + } +} + +// --- cloud cover derived from sunshine duration (parameter 119) --- + +func minutesPtr(v float64) *float64 { return &v } + +const ( + sthlmLat = 59.33 + sthlmLon = 18.07 +) + +// Midsummer noon and midnight at Stockholm: unambiguously day and night. +var ( + noonUTC = time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) + midnightUTC = time.Date(2026, 12, 21, 23, 0, 0, 0, time.UTC) +) + +func TestCloudCoverFromSunshineDuration(t *testing.T) { + cases := []struct { + name string + minutes *float64 + want float64 + wantOK bool + }{ + {"full hour of sun is clear sky", minutesPtr(60), 0, true}, + {"no sun at all is overcast", minutesPtr(0), 1, true}, + {"half an hour is half cover", minutesPtr(30), 0.5, true}, + {"quarter hour is three quarters cover", minutesPtr(15), 0.75, true}, + {"missing parameter is unknown, not clear", nil, 0, false}, + {"negative is rejected as unknown", minutesPtr(-1), 0, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + h := IrradianceHour{HourStart: noonUTC, SunshineMin: c.minutes} + got, ok := h.CloudCover(sthlmLat, sthlmLon) + if ok != c.wantOK { + t.Fatalf("ok = %v, want %v", ok, c.wantOK) + } + if ok && got != c.want { + t.Errorf("cover = %v, want %v", got, c.want) + } + }) + } +} + +// A value above 60 would push cover negative and read as "brighter than clear", +// which is meaningless. Clamp instead. +func TestCloudCoverClampsAboveFullHour(t *testing.T) { + h := IrradianceHour{HourStart: noonUTC, SunshineMin: minutesPtr(75)} + got, ok := h.CloudCover(sthlmLat, sthlmLon) + if !ok { + t.Fatal("want derivable") + } + if got != 0 { + t.Errorf("cover = %v, want 0 (clamped)", got) + } +} + +// The distinction that matters at a call site: unknown must never be mistaken +// for clear, because they lead to opposite decisions. +func TestCloudCoverUnknownIsDistinguishableFromClear(t *testing.T) { + unknown, okU := IrradianceHour{HourStart: noonUTC}.CloudCover(sthlmLat, sthlmLon) + clear, okC := IrradianceHour{HourStart: noonUTC, SunshineMin: minutesPtr(60)}.CloudCover(sthlmLat, sthlmLon) + if okU { + t.Error("absent sunshine must report not-ok") + } + if !okC { + t.Error("full sun must report ok") + } + if unknown != clear { + t.Log("values differ, but callers must branch on the boolean, not the value") + } +} + +// Outside the Nordic domain STRÅNG can never return data, so the client must +// refuse locally rather than spend three HTTP requests learning that. +func TestFetchWindowRefusesOutsideDomain(t *testing.T) { + c := NewClient("test") + c.BaseURL = "http://127.0.0.1:1" // must never be dialled + _, err := c.FetchWindow(context.Background(), -33.87, 151.21, + time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC), + time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC)) + if err == nil { + t.Fatal("want an error for Sydney") + } + if !errors.Is(err, ErrOutsideDomain) { + t.Errorf("err = %v, want ErrOutsideDomain", err) + } +} + +func TestFetchWindowAcceptsInsideDomain(t *testing.T) { + // Stockholm is in-domain, so this must get past the guard and fail on the + // unreachable transport instead. + c := NewClient("test") + c.BaseURL = "http://127.0.0.1:1" + _, err := c.FetchWindow(context.Background(), 59.33, 18.07, + time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC), + time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC)) + if errors.Is(err, ErrOutsideDomain) { + t.Fatal("Stockholm must not be rejected as outside the domain") + } +} + +// Sunshine duration is zero at night because there is no sun, not because it is +// overcast. Reporting 100% cover would be confidently wrong every single night, +// which is exactly what the live API returned before this guard existed. +func TestCloudCoverIsUnknownAtNight(t *testing.T) { + h := IrradianceHour{HourStart: midnightUTC, SunshineMin: minutesPtr(0)} + if _, ok := h.CloudCover(sthlmLat, sthlmLon); ok { + t.Error("midwinter midnight must report unknown, not 100% cloud") + } +} + +// The hour the sun climbs through the threshold must be answerable, and it is +// only answerable because all three sample points are checked. Midsummer at +// Stockholm, 02:00Z: elevation runs 1.59 deg at :00 and 4.34 deg at :30 — both +// below the 5 deg cutoff — reaching 7.25 deg by :59. Sampling the start or the +// midpoint alone would discard a genuinely observed half-hour of sunshine. +func TestCloudCoverCountsHourWhereSunCrossesThreshold(t *testing.T) { + start := time.Date(2026, 6, 21, 2, 0, 0, 0, time.UTC) + if sunpos.At(start, sthlmLat, sthlmLon).ZenithDeg < 90-minSunElevationDeg { + t.Fatal("premise broken: the sun should start this hour below the cutoff") + } + h := IrradianceHour{HourStart: start, SunshineMin: minutesPtr(30)} + got, ok := h.CloudCover(sthlmLat, sthlmLon) + if !ok { + t.Fatal("the hour the sun crosses the cutoff should be derivable") + } + if got != 0.5 { + t.Errorf("cover = %v, want 0.5", got) + } +} + +// Polar night: the sun never rises, so no hour of the day is derivable. +func TestCloudCoverUnknownThroughPolarNight(t *testing.T) { + const tromsoLat, tromsoLon = 69.65, 18.96 + for hour := 0; hour < 24; hour++ { + h := IrradianceHour{ + HourStart: time.Date(2026, 12, 21, hour, 0, 0, 0, time.UTC), + SunshineMin: minutesPtr(0), + } + if _, ok := h.CloudCover(tromsoLat, tromsoLon); ok { + t.Errorf("hour %02d: polar night must report unknown", hour) + } + } +} + +// Near sunrise/sunset the beam crosses too much atmosphere to clear the WMO +// threshold even under a clear sky, so a zero reading there describes geometry +// rather than cloud. Verified against the live API: 2026-06-21 20:00Z at +// Stockholm has GHI 2.5 W/m2 and 0 minutes of sunshine — the sun is minutes +// from setting, and calling that "100% overcast" would be wrong. +func TestCloudCoverDeclinesNearTheHorizon(t *testing.T) { + h := IrradianceHour{ + HourStart: time.Date(2026, 6, 21, 20, 0, 0, 0, time.UTC), + SunshineMin: minutesPtr(0), + } + if _, ok := h.CloudCover(sthlmLat, sthlmLon); ok { + t.Error("a sun about to set must report unknown, not fully overcast") + } +} + +// ...but a genuinely low-yet-usable sun must still be answerable, otherwise the +// guard would silently discard most of a Nordic winter. +func TestCloudCoverStillAnswersWhenSunIsUsablyUp(t *testing.T) { + // 2026-06-21 04:00Z at Stockholm: live GHI 163.2 W/m2, 60 min sunshine. + h := IrradianceHour{ + HourStart: time.Date(2026, 6, 21, 4, 0, 0, 0, time.UTC), + SunshineMin: minutesPtr(60), + } + got, ok := h.CloudCover(sthlmLat, sthlmLon) + if !ok { + t.Fatal("a usable morning sun must be derivable") + } + if got != 0 { + t.Errorf("cover = %v, want 0 (full sunshine)", got) + } +} diff --git a/web/components/ftw-bar-chart.js b/web/components/ftw-bar-chart.js index d7465a6b..6f0144d8 100644 --- a/web/components/ftw-bar-chart.js +++ b/web/components/ftw-bar-chart.js @@ -188,6 +188,26 @@ class FtwBarChart extends FtwElement { opacity: 0.85; pointer-events: none; } + /* Optional dashed overlay line (e.g. STRÅNG-expected PV vs the + produced bars). An SVG stretched to fill .bar-area, plotted in a + 0..100 viewBox so it needs no pixel math against the CSS grid; + non-scaling-stroke keeps the dash crisp despite the stretch. */ + .overlay-line { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + overflow: visible; + } + .overlay-line polyline { + fill: none; + stroke: var(--ftw-overlay-color, var(--amber, #f59e0b)); + stroke-width: 1.5; + stroke-dasharray: 4 3; + vector-effect: non-scaling-stroke; + opacity: 0.9; + } `; static get observedAttributes() { @@ -197,6 +217,7 @@ class FtwBarChart extends FtwElement { constructor() { super(); this._data = []; + this._overlay = null; } attributeChangedCallback() { this.update(); } @@ -209,6 +230,16 @@ class FtwBarChart extends FtwElement { } get data() { return this._data; } + // Optional dashed overlay line, index-aligned with .data. Shape: + // { values: (number|null)[], color?: string } + // values[i] is plotted above column i on the SAME axis as the bars + // (nulls / non-finite entries break the line). Set null to remove. + set overlay(o) { + this._overlay = o && Array.isArray(o.values) ? o : null; + this.update(); + } + get overlay() { return this._overlay; } + render() { const accent = this.getAttribute("accent"); const height = this.getAttribute("chart-height"); @@ -262,6 +293,17 @@ class FtwBarChart extends FtwElement { } const avg = count > 0 ? sum / count : 0; + // Fold overlay values into the axis max so the expected line and the + // produced bars share one scale (an expected level above the tallest + // bar must still fit in-frame). + const overlayVals = this._overlay ? this._overlay.values : null; + if (overlayVals) { + for (const ov of overlayVals) { + const n = Number(ov); + if (isFinite(n) && n > max) max = n; + } + } + const colsSvg = this._data.map((d) => { const v = Number(d.value) || 0; // 2% floor keeps tiny-but-nonzero values visible; gate on v>0 so @@ -300,10 +342,34 @@ class FtwBarChart extends FtwElement { `title="average ${display}">`; } + // Dashed overlay line (expected series). Plotted in a 0..100 viewBox + // stretched to fill .bar-area: x centers each column, y is the value + // as a percentage of the shared max (inverted — SVG y grows downward). + let overlayLine = ""; + if (overlayVals && max > 0) { + const n = this._data.length; + const pts = []; + for (let i = 0; i < n; i++) { + const val = Number(overlayVals[i]); + if (!isFinite(val)) continue; + const x = n > 1 ? (i + 0.5) / n * 100 : 50; + const y = Math.max(0, Math.min(100, 100 - (val / max) * 100)); + pts.push(`${x.toFixed(2)},${y.toFixed(2)}`); + } + if (pts.length >= 2) { + const color = this._overlay.color; + if (color) this.style.setProperty("--ftw-overlay-color", color); + overlayLine = + ``; + } + } + return `
-
${colsSvg}${avgOverlay}
+
${colsSvg}${avgOverlay}${overlayLine}
${lblsSvg}
diff --git a/web/components/ftw-history-card.js b/web/components/ftw-history-card.js index ed659092..1e1393c0 100644 --- a/web/components/ftw-history-card.js +++ b/web/components/ftw-history-card.js @@ -28,7 +28,7 @@ import { FtwElement, ftwDebugDelay } from "./ftw-element.js"; import { apiFetch } from "./api-fetch.js"; -import "./ftw-bar-chart.js"; +import "./ftw-bar-chart.js?v=strang1"; const FIELD_BY_METRIC = { import: "import_wh", @@ -82,6 +82,36 @@ function fetchDailyEnergy(days) { return promise; } +// STRÅNG-based PV performance (expected-vs-actual). Only the "Produced" +// (metric="pv") tile fetches this, to overlay the weather-expected line. +// Tolerant: a disabled/absent service or any error resolves to +// { enabled:false } so the bars still render without an overlay. +const pvPerfFetchCache = new Map(); // days -> { at, data?, promise? } + +function fetchPVPerformance(days) { + const now = Date.now(); + const cached = pvPerfFetchCache.get(days); + if (cached && cached.data && now - cached.at < DAILY_CACHE_TTL_MS) { + return Promise.resolve(cached.data); + } + if (cached && cached.promise && now - cached.at < DAILY_CACHE_TTL_MS) { + return cached.promise; + } + const promise = apiFetch("/api/pv/performance?days=" + days) + .then((r) => (r.ok ? r.json() : { enabled: false })) + .then((resp) => { + const data = resp || { enabled: false }; + pvPerfFetchCache.set(days, { at: Date.now(), data }); + return data; + }) + .catch(() => { + pvPerfFetchCache.delete(days); + return { enabled: false }; + }); + pvPerfFetchCache.set(days, { at: now, promise }); + return promise; +} + class FtwHistoryCard extends FtwElement { static styles = ` :host { display: block; } @@ -205,6 +235,27 @@ class FtwHistoryCard extends FtwElement { margin-left: 6px; letter-spacing: 0; } + /* STRÅNG expected-vs-actual caption under the Produced chart. Hidden + until the pv tile has a performance overlay to describe. The dashed + swatch mirrors the overlay line so the legend reads at a glance. */ + .strang-note { + margin-top: 6px; + font-size: 0.72rem; + color: var(--fg-muted); + font-family: var(--mono); + display: flex; + align-items: center; + gap: 6px; + } + .strang-note[hidden] { display: none; } + .strang-note .swatch { + display: inline-block; + width: 16px; + height: 0; + border-top: 2px dashed #fcd34d; + flex: 0 0 auto; + } + .strang-note .pr { color: var(--fg-label); font-weight: 600; } @media (max-width: 900px) { .card-inner { padding: var(--card-pad-tight, 12px 14px); } } @@ -302,6 +353,7 @@ class FtwHistoryCard extends FtwElement {
— kWh
+ `; } @@ -309,6 +361,7 @@ class FtwHistoryCard extends FtwElement { afterRender() { this._chart = this.shadowRoot.querySelector('[data-role="chart"]'); this._totalEl = this.shadowRoot.querySelector('[data-role="total"]'); + this._strangEl = this.shadowRoot.querySelector('[data-role="strang"]'); this._toggleEl = this.shadowRoot.querySelector('.toggle'); if (this._chart) this._chart.setAttribute("accent", this._accent()); if (this._toggleEl) { @@ -387,6 +440,12 @@ class FtwHistoryCard extends FtwElement { this._totalEl.textContent = "— kWh"; } this._chart.data = data; + // Produced tile: overlay the STRÅNG expected line. Fetched + // separately so a disabled/slow scoring service never holds up + // the bars. Aligned to the same day buckets by ISO date. + if (metric === "pv") { + this._loadOverlay(days, buckets.map((b) => b.day), seq); + } }; // `?delay=N` — hold in the skeleton state for N ms after the // fetch resolves, for inspecting the loading→loaded transition. @@ -402,6 +461,58 @@ class FtwHistoryCard extends FtwElement { this._totalEl.textContent = "failed to load"; }); } + + // Fetch STRÅNG performance scores and overlay the expected-production + // line onto the Produced bars, aligned to `dayKeys` (ISO dates, same + // order as the bars). Hides the overlay + caption when scoring is + // unavailable. Guarded by `seq` so a stale response can't paint over a + // newer Week/Month selection. + _loadOverlay(days, dayKeys, seq) { + fetchPVPerformance(days) + .then((perf) => { + if (seq !== this._reqSeq || !this._chart) return; + if (!perf || perf.enabled === false || !Array.isArray(perf.items) || !perf.items.length) { + this._chart.overlay = null; + if (this._strangEl) this._strangEl.setAttribute("hidden", ""); + return; + } + const expByDay = new Map(); + for (const it of perf.items) { + if (it && it.day != null) expByDay.set(it.day, Number(it.expected_wh) || 0); + } + // kWh, index-aligned to the bars; null where no score exists so + // the dashed line breaks rather than dropping to zero. + const values = dayKeys.map((d) => { + const wh = expByDay.get(d); + return wh == null ? null : wh / 1000; + }); + const hasAny = values.some((v) => v != null); + this._chart.overlay = hasAny ? { values, color: "#fcd34d" } : null; + + if (this._strangEl) { + const pr = typeof perf.performance_ratio === "number" ? perf.performance_ratio : null; + const prTxt = pr != null ? `${Math.round(pr * 100)}% of expected` : ""; + // Only announce the calibration once it is actually being applied to + // the forward forecast — reporting a factor we ignore would mislead. + const cal = perf.calibration; + const calTxt = + cal && cal.applied && typeof cal.factor === "number" + ? `calibrating forecast ×${cal.factor.toFixed(2)}` + : ""; + this._strangEl.innerHTML = + ` expected (STRÅNG)` + + (prTxt ? " · " + prTxt : "") + + (calTxt ? " · " + calTxt : ""); + if (hasAny) this._strangEl.removeAttribute("hidden"); + else this._strangEl.setAttribute("hidden", ""); + } + }) + .catch(() => { + if (seq !== this._reqSeq || !this._chart) return; + this._chart.overlay = null; + if (this._strangEl) this._strangEl.setAttribute("hidden", ""); + }); + } } function fmtKwh(wh) { diff --git a/web/components/index.js b/web/components/index.js index ee2f11fe..9a6a895b 100644 --- a/web/components/index.js +++ b/web/components/index.js @@ -16,8 +16,8 @@ import "./ftw-battery-control.js?v=apifetch1"; import "./ftw-pv-control.js?v=apifetch1"; import "./ftw-price-chart.js?v=apiread3"; import "./ftw-energy-cake.js"; -import "./ftw-bar-chart.js"; -import "./ftw-history-card.js?v=apiread2"; +import "./ftw-bar-chart.js?v=strang1"; +import "./ftw-history-card.js?v=strang2"; import "./ftw-savings-card.js?v=apiread1"; import "./ftw-update-check.js?v=apifetch1"; import "./ftw-notif-status.js?v=apifetch1"; diff --git a/web/index.html b/web/index.html index fdc42703..f641cbbe 100644 --- a/web/index.html +++ b/web/index.html @@ -5,10 +5,10 @@ FTW - +