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/.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 08a55efb..5c52095c 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,25 @@ 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 + + // 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{} @@ -286,10 +302,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{}), } @@ -331,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 @@ -338,14 +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(), @@ -362,7 +414,27 @@ 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) +// 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]. diff --git a/go/internal/forecast/forecast_test.go b/go/internal/forecast/forecast_test.go index 73450fa0..19231b68 100644 --- a/go/internal/forecast/forecast_test.go +++ b/go/internal/forecast/forecast_test.go @@ -247,3 +247,177 @@ 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) +} + +// ---- 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/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) + } +} 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 - +