From 4617efa085ac5eb4b2df5e19b4cfcf4bcc835584 Mon Sep 17 00:00:00 2001
From: Claude Opus 5
Date: Fri, 31 Jul 2026 10:34:03 +0200
Subject: [PATCH 01/27] feat(weather): add STRANG as an irradiance source and
declare source coverage
STRANG becomes a real irradiance source rather than an unreferenced client,
every external data source now says where in the world it works, and the
location picker moves off Leaflet.
STRANG's parameter set was mapped against the live API because SMHI's apidocs
pages 404: exactly 116-122 exist. Identification was confirmed by physics
rather than by guessing - at solar noon 121 + 122 = 723.0 + 87.5 = 810.5,
exactly parameter 117, and 119 caps at 60, i.e. minutes within the hour.
STRANG publishes no cloud cover; it is a radiation model. Cloudiness is
instead derived from sunshine duration as 1 - minutes/60, which is observed
rather than inferred but coarser: blind to thin cirrus, undefined at night.
CloudCover() therefore returns an explicit unknown instead of defaulting to
clear, because those two lead to opposite decisions.
The new coverage registry makes an existing silence explicit. STRANG is
Nordic-only and every price provider is European, so sites elsewhere were
getting empty results with no explanation (#726). GET /api/data-sources now
reports area, countries, licence and whether each source reaches this site,
and the Weather tab renders it under the map. Bounds are advisory: STRANG's
grid is rotated, so a lat/lon box can only ever be a superset - all four
in-box corners were probed and returned no data. False is definitive, true
means worth trying. Scoring now declines to start outside the domain instead
of retrying nightly forever.
Stacked on #718, which carries the plane-of-array wiring this builds on.
Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
---
.changeset/strang-source-coverage.md | 46 ++++
README.md | 1 +
docs/data-coverage.md | 154 ++++++++++++
go/cmd/ftw/main.go | 22 ++
go/internal/api/api.go | 143 +++++++++++
go/internal/api/api_datasources_test.go | 157 ++++++++++++
go/internal/api/api_pvperf_test.go | 91 +++++++
go/internal/coverage/coverage.go | 188 +++++++++++++++
go/internal/coverage/coverage_test.go | 174 ++++++++++++++
go/internal/forecast/forecast.go | 39 ++-
go/internal/forecast/forecast_test.go | 156 ++++++++++--
go/internal/mpc/external_optimizer.go | 5 +-
go/internal/mpc/pvband.go | 37 +++
go/internal/mpc/pvband_test.go | 86 +++++++
go/internal/pvperf/calibration.go | 117 +++++++++
go/internal/pvperf/calibration_test.go | 153 ++++++++++++
go/internal/pvperf/pvperf.go | 89 +++++++
go/internal/pvperf/pvperf_test.go | 83 +++++++
go/internal/pvperf/service.go | 227 ++++++++++++++++++
go/internal/pvperf/service_test.go | 156 ++++++++++++
go/internal/state/pvperf.go | 138 +++++++++++
go/internal/state/pvperf_test.go | 119 +++++++++
go/internal/state/store.go | 28 +++
go/internal/strang/strang.go | 232 ++++++++++++++++++
go/internal/strang/strang_test.go | 306 ++++++++++++++++++++++++
go/internal/units/consistency_test.go | 12 +
web/components/ftw-bar-chart.js | 68 +++++-
web/components/ftw-history-card.js | 113 ++++++++-
web/components/index.js | 4 +-
web/index.html | 10 +-
web/settings/tabs/weather.js | 175 +++++++++++---
31 files changed, 3260 insertions(+), 69 deletions(-)
create mode 100644 .changeset/strang-source-coverage.md
create mode 100644 docs/data-coverage.md
create mode 100644 go/internal/api/api_datasources_test.go
create mode 100644 go/internal/api/api_pvperf_test.go
create mode 100644 go/internal/coverage/coverage.go
create mode 100644 go/internal/coverage/coverage_test.go
create mode 100644 go/internal/mpc/pvband.go
create mode 100644 go/internal/mpc/pvband_test.go
create mode 100644 go/internal/pvperf/calibration.go
create mode 100644 go/internal/pvperf/calibration_test.go
create mode 100644 go/internal/pvperf/pvperf.go
create mode 100644 go/internal/pvperf/pvperf_test.go
create mode 100644 go/internal/pvperf/service.go
create mode 100644 go/internal/pvperf/service_test.go
create mode 100644 go/internal/state/pvperf.go
create mode 100644 go/internal/state/pvperf_test.go
create mode 100644 go/internal/strang/strang.go
create mode 100644 go/internal/strang/strang_test.go
diff --git a/.changeset/strang-source-coverage.md b/.changeset/strang-source-coverage.md
new file mode 100644
index 000000000..6fc1a2b08
--- /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 8a045ac62..176ddc882 100644
--- a/README.md
+++ b/README.md
@@ -219,6 +219,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 000000000..a38a678ad
--- /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 c8d35f144..219e7b19c 100644
--- a/go/cmd/ftw/main.go
+++ b/go/cmd/ftw/main.go
@@ -64,6 +64,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"
@@ -1204,6 +1205,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" {
@@ -2568,6 +2589,7 @@ func main() {
SnapshotDir: filepath.Join(filepath.Dir(statePath), "snapshots"),
Prices: priceSvc,
Forecast: forecastSvc,
+ PVPerf: pvPerfSvc,
MPC: mpcSvc,
PlannerPrefs: plannerPrefs,
PVModel: pvSvc,
diff --git a/go/internal/api/api.go b/go/internal/api/api.go
index 40bfec1b3..ebc21e36a 100644
--- a/go/internal/api/api.go
+++ b/go/internal/api/api.go
@@ -36,6 +36,7 @@ import (
"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/configreload"
"github.com/srcfl/ftw/go/internal/control"
+ "github.com/srcfl/ftw/go/internal/coverage"
"github.com/srcfl/ftw/go/internal/driverrepo"
"github.com/srcfl/ftw/go/internal/drivers"
"github.com/srcfl/ftw/go/internal/evcloud"
@@ -50,6 +51,7 @@ import (
"github.com/srcfl/ftw/go/internal/ocpp"
"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"
@@ -139,6 +141,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 or a buildMPC gate skipped it.
MPC *mpc.Service
@@ -495,6 +501,8 @@ func (s *Server) routes() {
s.handle("GET /api/prices", Read, s.handlePrices)
s.handle("GET /api/prices/zones", Read, s.handlePriceZones)
s.handle("GET /api/forecast", Read, s.handleForecast)
+ s.handle("GET /api/pv/performance", Read, s.handlePVPerformance)
+ s.handle("GET /api/data-sources", Read, s.handleDataSources)
s.handle("GET /api/mpc/plan", Read, s.handleMPCPlan)
s.handle("POST /api/mpc/replan", Configure, s.handleMPCReplan)
s.handle("GET /api/mpc/diagnose", Read, s.handleMPCDiagnose)
@@ -2420,6 +2428,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) mpcDisabledPayload() map[string]any {
diff --git a/go/internal/api/api_datasources_test.go b/go/internal/api/api_datasources_test.go
new file mode 100644
index 000000000..74fd3ddea
--- /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 000000000..17107bb1e
--- /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 000000000..12ba85d90
--- /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 000000000..36de8d82a
--- /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 acefbc773..733a8632d 100644
--- a/go/internal/forecast/forecast.go
+++ b/go/internal/forecast/forecast.go
@@ -265,12 +265,21 @@ type Service struct {
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
+ // Arrays holds per-plane geometry (tilt/azimuth/rated_w) 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{}
}
@@ -367,6 +376,15 @@ func (s *Service) fetchAndStore(ctx context.Context) {
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 {
// A negative irradiance is not physical; retain the row with a
@@ -386,19 +404,35 @@ 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 solarWm2 != nil:
+ // Irradiance-derived: orientation-aware when per-plane geometry
+ // exists, flat rated×(W/m²/1000) otherwise. Either way it is the
+ // loss-free baseline the performance ratio was measured against.
var ok bool
pvW, ok = pvWFromGHI(s.Lat, s.Lon, r.HourStart, *solarWm2, s.RatedPVW, s.Arrays)
if !ok {
slog.Warn("forecast row skipped", "reason", "non-finite irradiance", "provider", s.Provider.Name(), "slot", r.HourStart)
continue
}
+ applyCalibration = true
default:
pvW = EstimatePVW(s.Lat, s.Lon, r.HourStart, r.CloudCoverPct, s.RatedPVW)
}
+ // Calibrate before the finiteness guard so the stored value is the one
+ // that was checked, not the one before scaling.
+ if applyCalibration && calibrated {
+ pvW *= calibration
+ }
if math.IsNaN(pvW) || math.IsInf(pvW, 0) {
slog.Warn("forecast row skipped", "reason", "non-finite PV estimate", "provider", s.Provider.Name(), "slot", r.HourStart)
continue
@@ -430,7 +464,8 @@ func (s *Service) fetchAndStore(ctx context.Context) {
slog.Warn("forecast save failed", "err", err)
return
}
- slog.Info("forecast fetched", "count", len(points), "provider", s.Provider.Name())
+ slog.Info("forecast fetched", "count", len(points), "provider", s.Provider.Name(),
+ "pv_calibration", calibration, "calibrated", calibrated)
}
func arrayFromConfig(a config.PVArray) (Array, bool) {
diff --git a/go/internal/forecast/forecast_test.go b/go/internal/forecast/forecast_test.go
index 9c6567c7f..7f0f4ffb6 100644
--- a/go/internal/forecast/forecast_test.go
+++ b/go/internal/forecast/forecast_test.go
@@ -111,7 +111,9 @@ func TestEstimatePVWCloudReduction(t *testing.T) {
func TestEstimatePVWNilCloudIsMid(t *testing.T) {
tt := time.Date(2026, 6, 21, 11, 0, 0, 0, time.UTC)
pv := EstimatePVW(59.3293, 18.0686, tt, nil, 10000)
- if pv == 0 { t.Error("nil cloud should default to mid-range, not zero") }
+ if pv == 0 {
+ t.Error("nil cloud should default to mid-range, not zero")
+ }
}
// ---- met.no HTTP ----
@@ -130,7 +132,7 @@ func TestMetNoFetchParses(t *testing.T) {
"instant": map[string]any{
"details": map[string]any{
"cloud_area_fraction": 75.0,
- "air_temperature": 8.5,
+ "air_temperature": 8.5,
},
},
},
@@ -141,7 +143,7 @@ func TestMetNoFetchParses(t *testing.T) {
"instant": map[string]any{
"details": map[string]any{
"cloud_area_fraction": 20.0,
- "air_temperature": 7.2,
+ "air_temperature": 7.2,
},
},
},
@@ -156,8 +158,12 @@ func TestMetNoFetchParses(t *testing.T) {
p := NewMetNo("test-ua")
p.BaseURL = srv.URL
rows, err := p.Fetch(context.Background(), 59.3, 18.1)
- if err != nil { t.Fatal(err) }
- if len(rows) != 2 { t.Fatalf("got %d rows, want 2", len(rows)) }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rows) != 2 {
+ t.Fatalf("got %d rows, want 2", len(rows))
+ }
if rows[0].CloudCoverPct == nil || *rows[0].CloudCoverPct != 75 {
t.Errorf("cloud cover: %+v", rows[0].CloudCoverPct)
}
@@ -174,7 +180,9 @@ func TestMetNoErrorsOn500(t *testing.T) {
p := NewMetNo("test")
p.BaseURL = srv.URL
_, err := p.Fetch(context.Background(), 59, 18)
- if err == nil { t.Error("expected error on 500") }
+ if err == nil {
+ t.Error("expected error on 500")
+ }
}
// ---- OpenWeather HTTP ----
@@ -193,16 +201,26 @@ func TestOpenWeatherFetchParses(t *testing.T) {
p := NewOpenWeather("test-key")
p.BaseURL = srv.URL
rows, err := p.Fetch(context.Background(), 59, 18)
- if err != nil { t.Fatal(err) }
- if len(rows) != 2 { t.Fatalf("got %d", len(rows)) }
- if *rows[0].CloudCoverPct != 40 { t.Errorf("cloud: %f", *rows[0].CloudCoverPct) }
- if *rows[1].TempC != 10.5 { t.Errorf("temp: %f", *rows[1].TempC) }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rows) != 2 {
+ t.Fatalf("got %d", len(rows))
+ }
+ if *rows[0].CloudCoverPct != 40 {
+ t.Errorf("cloud: %f", *rows[0].CloudCoverPct)
+ }
+ if *rows[1].TempC != 10.5 {
+ t.Errorf("temp: %f", *rows[1].TempC)
+ }
}
func TestOpenWeatherRequiresKey(t *testing.T) {
p := NewOpenWeather("")
_, err := p.Fetch(context.Background(), 59, 18)
- if err == nil { t.Error("expected API key error") }
+ if err == nil {
+ t.Error("expected API key error")
+ }
}
// ---- Service integration ----
@@ -241,8 +259,12 @@ func TestServiceFetchesAndStoresWithPVEstimate(t *testing.T) {
// Load back
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 { t.Fatalf("got %d forecasts", len(rows)) }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rows) != 1 {
+ t.Fatalf("got %d forecasts", len(rows))
+ }
// Stockholm summer clear-ish sky at noon with 10kW array should give ~4-8 kW estimate
if rows[0].PVWEstimated == nil || *rows[0].PVWEstimated < 1000 {
t.Errorf("PV estimate should be substantial for clear summer, got %+v", rows[0].PVWEstimated)
@@ -253,18 +275,30 @@ func TestServiceFetchesAndStoresWithPVEstimate(t *testing.T) {
// ---- FromConfig ----
func TestFromConfigNilWhenDisabled(t *testing.T) {
- if FromConfig(nil, 10000, nil, "") != nil { t.Error("nil cfg → nil svc") }
- if FromConfig(&config.Weather{Provider: "none"}, 10000, nil, "") != nil { t.Error("none → nil svc") }
- if FromConfig(&config.Weather{Provider: ""}, 10000, nil, "") != nil { t.Error("empty → nil svc") }
+ if FromConfig(nil, 10000, nil, "") != nil {
+ t.Error("nil cfg → nil svc")
+ }
+ if FromConfig(&config.Weather{Provider: "none"}, 10000, nil, "") != nil {
+ t.Error("none → nil svc")
+ }
+ if FromConfig(&config.Weather{Provider: ""}, 10000, nil, "") != nil {
+ t.Error("empty → nil svc")
+ }
}
func TestFromConfigBuildsMetNo(t *testing.T) {
st, _ := state.Open(filepath.Join(t.TempDir(), "t.db"))
defer st.Close()
s := FromConfig(&config.Weather{Provider: "met_no", Latitude: 59, Longitude: 18}, 10000, st, "ua")
- if s == nil { t.Fatal("expected service") }
- if s.Lat != 59 { t.Errorf("lat: %f", s.Lat) }
- if s.RatedPVW != 10000 { t.Errorf("rated: %f", s.RatedPVW) }
+ if s == nil {
+ t.Fatal("expected service")
+ }
+ 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) {
@@ -279,7 +313,9 @@ func TestFromConfigPopulatesArrays(t *testing.T) {
},
}
s := FromConfig(cfg, 10000, st, "ua")
- if s == nil { t.Fatal("expected service") }
+ 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))
}
@@ -621,3 +657,83 @@ func TestLoadClampsStoredMegawattForecast(t *testing.T) {
t.Fatalf("clamp should sit on the nameplate ceiling, got %.1f W", got)
}
}
+
+// ---- 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, RatedW: 10000}},
+ 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 189416b28..7c9fd651f 100644
--- a/go/internal/mpc/external_optimizer.go
+++ b/go/internal/mpc/external_optimizer.go
@@ -496,8 +496,9 @@ func (o *ExternalOptimizer) buildRequest(slots []Slot, p Params) externalRequest
if p.PVRelativeUncertainty > 0 {
spread = relSpread * generation
}
- 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 000000000..f25c42d83
--- /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 000000000..7dab3fc4e
--- /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 000000000..c78689ebb
--- /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 rated watts understate 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 000000000..76c7155ee
--- /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 000000000..d0e9d0354
--- /dev/null
+++ b/go/internal/pvperf/pvperf.go
@@ -0,0 +1,89 @@
+// 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"
+ "github.com/srcfl/ftw/go/internal/units"
+)
+
+// Array is one panel plane: nameplate DC watts 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 {
+ RatedW 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.RatedW <= 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)
+ }
+ // Watts at STC × (POA / 1000 W/m²) × 1 h = Wh this hour.
+ wh += units.PVFromIrradiance(a.RatedW, poa)
+ }
+ }
+ 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 000000000..2b767acc7
--- /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 TestExpectedWhScalesWithRatedW(t *testing.T) {
+ hours := summerHours()
+ e5 := ExpectedWh(59.33, 18.07, []Array{{RatedW: 5000, TiltDeg: 35, AzimuthDeg: 180}}, hours)
+ e10 := ExpectedWh(59.33, 18.07, []Array{{RatedW: 10000, 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 kW should be 2× 5 kW, ratio %.4f", e10/e5)
+ }
+}
+
+func TestExpectedWhSumsArrays(t *testing.T) {
+ hours := summerHours()
+ south := ExpectedWh(59.33, 18.07, []Array{{RatedW: 5000, TiltDeg: 35, AzimuthDeg: 180}}, hours)
+ east := ExpectedWh(59.33, 18.07, []Array{{RatedW: 5000, TiltDeg: 35, AzimuthDeg: 90}}, hours)
+ both := ExpectedWh(59.33, 18.07, []Array{
+ {RatedW: 5000, TiltDeg: 35, AzimuthDeg: 180},
+ {RatedW: 5000, 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{{RatedW: 10000, 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{{RatedW: 10000, 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 000000000..23353d59f
--- /dev/null
+++ b/go/internal/pvperf/service.go
@@ -0,0 +1,227 @@
+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 {
+ // CompleteGeometry is the same gate forecast.arrayFromConfig uses: a
+ // plane missing its tilt or azimuth is skipped rather than scored as
+ // 0° flat, so expected-vs-actual is never measured against a plane
+ // the operator never described.
+ tiltDeg, azimuthDeg, ratedW, ok := a.CompleteGeometry()
+ if !ok {
+ continue
+ }
+ arrays = append(arrays, Array{RatedW: ratedW, TiltDeg: tiltDeg, AzimuthDeg: azimuthDeg})
+ }
+ if len(arrays) == 0 && ratedPVW > 0 {
+ arrays = append(arrays, Array{RatedW: ratedPVW, 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 000000000..ff7cb2636
--- /dev/null
+++ b/go/internal/pvperf/service_test.go
@@ -0,0 +1,156 @@
+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"
+)
+
+// f64 addresses a literal: config.PVArray keeps tilt and azimuth as pointers so
+// an omitted field cannot pass for a valid 0°.
+func f64(v float64) *float64 { return &v }
+
+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{
+ {RatedW: 5000, TiltDeg: f64(35), AzimuthDeg: f64(180)},
+ {RatedW: 3000, TiltDeg: f64(20), AzimuthDeg: f64(90)},
+ },
+ }, 0, st, "ua")
+ if svc == nil || len(svc.Arrays) != 2 {
+ t.Fatalf("expected 2 arrays, got %+v", svc)
+ }
+ if svc.Arrays[0].RatedW != 5000 || svc.Arrays[1].AzimuthDeg != 90 {
+ t.Errorf("array geometry mismatch: %+v", svc.Arrays)
+ }
+
+ // An array whose tilt was never filled in is not a 0° flat roof; scoring
+ // against it would invent a plane the operator never described, so the
+ // incomplete entry is skipped and only the usable one survives.
+ partial := FromConfig(&config.Weather{
+ Latitude: 59, Longitude: 18,
+ PVArrays: []config.PVArray{
+ {RatedW: 5000, AzimuthDeg: f64(180)},
+ {RatedW: 3000, TiltDeg: f64(20), AzimuthDeg: f64(90)},
+ },
+ }, 0, st, "ua")
+ if partial == nil || len(partial.Arrays) != 1 {
+ t.Fatalf("incomplete geometry should be skipped, got %+v", partial)
+ }
+ if partial.Arrays[0].RatedW != 3000 {
+ t.Errorf("wrong array survived: %+v", partial.Arrays[0])
+ }
+
+ // 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].RatedW != 8000 || 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{{RatedW: 10000, 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{{RatedW: 10000, 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 000000000..59476f1e5
--- /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 000000000..ef1becad6
--- /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 ef67150c5..c46b59947 100644
--- a/go/internal/state/store.go
+++ b/go/internal/state/store.go
@@ -991,6 +991,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 {
@@ -1039,6 +1055,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 000000000..579d5136c
--- /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 000000000..1596fbff6
--- /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/units/consistency_test.go b/go/internal/units/consistency_test.go
index 54ce42874..053c1e6b5 100644
--- a/go/internal/units/consistency_test.go
+++ b/go/internal/units/consistency_test.go
@@ -10,6 +10,7 @@ import (
"github.com/srcfl/ftw/go/internal/forecast"
"github.com/srcfl/ftw/go/internal/loadpoint"
"github.com/srcfl/ftw/go/internal/mpc"
+ "github.com/srcfl/ftw/go/internal/pvperf"
"github.com/srcfl/ftw/go/internal/telemetry"
"github.com/srcfl/ftw/go/internal/units"
"github.com/srcfl/ftw/go/internal/v2x"
@@ -43,6 +44,16 @@ func TestForecastArrayHasNoKWp(t *testing.T) {
}
}
+func TestPVPerfArrayHasNoKWp(t *testing.T) {
+ typ := reflect.TypeOf(pvperf.Array{})
+ if _, ok := typ.FieldByName("KWp"); ok {
+ t.Fatal("pvperf.Array must not have KWp; store RatedW")
+ }
+ if _, ok := typ.FieldByName("RatedW"); !ok {
+ t.Fatal("pvperf.Array must store RatedW (watts)")
+ }
+}
+
func TestMPCParamsSoCIsFraction(t *testing.T) {
typ := reflect.TypeOf(mpc.Params{})
for _, banned := range []string{"SoCMinPct", "SoCMaxPct", "InitialSoCPct"} {
@@ -243,6 +254,7 @@ func TestCoreBannedSoCPercentFieldNames(t *testing.T) {
reflect.TypeOf(mpc.Plan{}),
reflect.TypeOf(mpc.SlotDirective{}),
reflect.TypeOf(forecast.Array{}),
+ reflect.TypeOf(pvperf.Array{}),
}
banned := []string{"CurrentSoCPct", "TargetSoCPct", "PluginSoCPct", "VehicleSoCPct", "SoCPct", "SoCMinPct", "SoCMaxPct", "SoCTargetPct", "LivePVSurplusSoCCapPct", "LoadpointSoCTargetPct", "KWp"}
for _, typ := range types {
diff --git a/web/components/ftw-bar-chart.js b/web/components/ftw-bar-chart.js
index d7465a6ba..6f0144d8e 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 ed659092e..1e1393c0c 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 {
' +
- '';
+ '' +
+ roofFieldset(ctx);
},
after: function (ctx) {
initWeatherMap(ctx);
@@ -343,6 +571,11 @@
renderPVArrays(ctx);
refreshArraysSummary(ctx.config);
});
+ roofState = { features: [], selectedId: null };
+ var findBtn = document.getElementById("roof-find");
+ if (findBtn) findBtn.addEventListener("click", function () { findBuildings(ctx); });
+ var deriveBtn = document.getElementById("roof-derive");
+ if (deriveBtn) deriveBtn.addEventListener("click", function () { deriveRoof(ctx); });
},
};
From 2585d57ce3004be9b660368ea948be6ce00bba0f Mon Sep 17 00:00:00 2001
From: Claude Opus 5
Date: Sat, 1 Aug 2026 10:54:28 +0200
Subject: [PATCH 10/27] feat(roofmodel): read Lantmateriet in the formats it
publishes
Both roof-geometry products are STAC APIs behind one Geotorget account, and
they differ only in what their items point at: Byggnad Nedladdning, vektor
delivers GeoPackage, Laserdata Nedladdning, Skog delivers LAZ organised as
COPC. Assets are now selected by declared media type rather than by guessing
at asset key names, so a catalogue that calls its asset "punktmoln" instead of
"data" keeps working, and a thumbnail is never handed back as a point cloud.
Building footprints are read straight out of the GeoPackage with the standard
library. A GeoPackage is a SQLite database holding geometry as WKB, both
published formats with fixed layouts, so this costs about a hundred lines
against a GDAL dependency that will not install on a Pi without a compiler.
Previously only inline STAC geometry was handled and the asset-backed case --
the normal one -- returned nothing at all.
Because COPC indexes points into an octree, picking a building now also makes
the download small: only the octree nodes covering that footprint are
range-requested, instead of a 2.5 km tile running to hundreds of megabytes.
Plain .laz assets, hosts that ignore Range, and builds of laspy without COPC
support all fall back to reading the tile whole -- slower, same answer -- and
the model records which path ran as source.fetch, since that also decides what
returns_in_radius is counted over.
Also corrects the coplanar-absorption measurement quoted in buildings.py and
docs/roof-geometry.md. A fresh run does not reproduce the 93%/two-fragment
figures: one pass over a house and a garage 40 m apart consumes all 576 of the
house's south-face returns and all 256 of the garage's as one surface, and
gives identical output at every separation from 3 m to 40 m -- which is the
signature of a global infinite-plane fit rather than a proximity effect.
Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
---
.changeset/roofmodel-stac-formats.md | 26 +++
docs/roof-geometry.md | 48 +++--
go/internal/roofmodel/roofmodel.go | 5 +
go/internal/roofmodel/roofmodel_test.go | 24 +++
roofmodel/ftw_roofmodel/buildings.py | 70 +++++--
roofmodel/ftw_roofmodel/geopackage.py | 228 +++++++++++++++++++++++
roofmodel/ftw_roofmodel/geotorget.py | 104 ++++++++++-
roofmodel/ftw_roofmodel/pipeline.py | 79 +++++---
roofmodel/ftw_roofmodel/pointcloud.py | 226 +++++++++++++++++++++++
roofmodel/tests/test_assets.py | 212 +++++++++++++++++++++
roofmodel/tests/test_geopackage.py | 201 ++++++++++++++++++++
roofmodel/tests/test_source_formats.py | 235 ++++++++++++++++++++++++
12 files changed, 1402 insertions(+), 56 deletions(-)
create mode 100644 .changeset/roofmodel-stac-formats.md
create mode 100644 roofmodel/ftw_roofmodel/geopackage.py
create mode 100644 roofmodel/ftw_roofmodel/pointcloud.py
create mode 100644 roofmodel/tests/test_assets.py
create mode 100644 roofmodel/tests/test_geopackage.py
create mode 100644 roofmodel/tests/test_source_formats.py
diff --git a/.changeset/roofmodel-stac-formats.md b/.changeset/roofmodel-stac-formats.md
new file mode 100644
index 000000000..874e48c02
--- /dev/null
+++ b/.changeset/roofmodel-stac-formats.md
@@ -0,0 +1,26 @@
+---
+"ftw": minor
+---
+
+Read Lantmäteriet's data in the formats it is actually published in.
+
+Both roof-geometry products are STAC APIs behind one Geotorget account, and they
+differ only in what their items point at: *Byggnad Nedladdning, vektor* delivers
+**GeoPackage**, *Laserdata Nedladdning, Skog* delivers **LAZ organised as COPC**
+(Cloud Optimized Point Cloud). Assets are now chosen by their declared media
+type instead of by guessing at asset names, so a catalogue that calls its asset
+`punktmoln` rather than `data` still works, and a thumbnail is never mistaken
+for a point cloud.
+
+Building footprints are read straight out of the GeoPackage with the standard
+library — a GeoPackage is a SQLite database holding geometry as WKB, both
+published formats with fixed layouts, so this needs no GDAL and installs on a Pi
+unchanged. Previously only inline STAC geometry was handled, which meant the
+normal asset-backed case returned nothing at all.
+
+Because COPC indexes its points into an octree, picking a building now also
+makes the download small: FTW range-requests only the octree nodes covering that
+footprint instead of pulling a 2.5 km tile that runs to hundreds of megabytes.
+Plain `.laz` assets, servers that ignore `Range`, and builds of laspy without
+COPC support all fall back to reading the tile whole — slower, same answer. The
+derived model records which path ran as `source.fetch`.
diff --git a/docs/roof-geometry.md b/docs/roof-geometry.md
index ad10ac9cc..f6d90d10a 100644
--- a/docs/roof-geometry.md
+++ b/docs/roof-geometry.md
@@ -14,10 +14,15 @@ A free [Geotorget](https://geotorget.lantmateriet.se) account with access
ordered to two products. Both are open data under CC BY 4.0; the account exists
so Lantmäteriet can see who is downloading, not to charge you.
-| Product | What FTW uses it for |
-|---|---|
-| [Byggnad Nedladdning, vektor](https://geotorget.lantmateriet.se/geodataprodukter/byggnad-nedladdning-vektor-api) | Building footprints, so you can point at your house |
-| [Laserdata Nedladdning, Skog](https://geotorget.lantmateriet.se/geodataprodukter/laserdata-nedladdning-skog-api) | The laser scan the roof planes are fitted to |
+| Product | Delivered as | What FTW uses it for |
+|---|---|---|
+| [Byggnad Nedladdning, vektor](https://geotorget.lantmateriet.se/geodataprodukter/byggnad-nedladdning-vektor-api) | STAC → **GeoPackage** | Building footprints, so you can point at your house |
+| [Laserdata Nedladdning, Skog](https://geotorget.lantmateriet.se/geodataprodukter/laserdata-nedladdning-skog-api) | STAC → **LAZ as COPC** | The laser scan the roof planes are fitted to |
+
+Both are STAC APIs behind the same account, so one set of credentials covers
+both and FTW searches them the same way. They differ only in what the items
+point at, and FTW picks the right asset by its declared media type rather than
+by its name — a catalogue that renames `data` to `punktmoln` keeps working.
Ordering access is not instant — Lantmäteriet approves it — so do it before you
plan to use this.
@@ -30,7 +35,22 @@ pip install -e roofmodel[geo]
The `geo` extra pulls the LAZ reader. Without it everything except reading the
point cloud works, which is enough to run the tests but not enough to derive a
-real roof.
+real roof. GeoPackage needs nothing extra: it is a SQLite file, and FTW reads it
+with the standard library.
+
+### Why picking a building also makes it fast
+
+COPC — Cloud Optimized Point Cloud — is LAZ with the points ordered into an
+octree and an index at a known offset, so a reader can ask for a region and
+fetch only the parts that cover it. Once you have picked a building, FTW asks
+for the bounding box of *that footprint* instead of the tile: a Laserdata Skog
+tile covers 2.5 km and runs to hundreds of megabytes, and a house is a few tens
+of metres across.
+
+This is best-effort. A plain (non-COPC) `.laz` asset, a server that ignores
+`Range` requests, or a laspy without COPC support all fall back to reading the
+tile whole — slower, same answer. The result records which path ran as
+`source.fetch`: `copc-window` or `whole-tile`.
## Using it
@@ -69,15 +89,18 @@ knows whether there are panels on that face at all.
Without a footprint the module segments everything within its radius: your
neighbour's roof, the garage, the trees. Worse, the plane fitting is *global* —
a fitted plane is infinite, so a roof at azimuth 180° is described by `z = f(y)`
-with no `x` term at all and extends right across the tile. A second building
-sharing your ridge orientation falls inside its inlier band however far away it
-is, and the two lose points to each other.
+with no `x` term at all and does not stop at your wall. A second building
+sharing your pitch and ridge orientation is not on a *similar* plane; it is on
+the same one.
-Measured on a synthetic pair: a detached garage recovered 93% of its true area
-and split into two fragments when it lay on the house's plane, against 100% and
-a single clean face once the cloud was clipped to its own footprint.
+Measured on a synthetic pair, a single fitting pass over a house and a garage
+40 m apart swallowed **all** of both south faces — 576 returns from one and 256
+from the other — as one surface. The result was identical at every separation
+from 3 m to 40 m, which is what tells you it is a global effect and not a
+proximity one. Only the clustering step afterwards told the two buildings apart.
-Picking a building is what makes the answer yours.
+Clipping to your footprint first gives exactly the same face you would get by
+scanning your building alone. Picking a building is what makes the answer yours.
## Optional: shading
@@ -104,6 +127,7 @@ is clear" are different claims.
| "No buildings found here" | The marker is not on a building, or you are outside Sweden |
| "only N LiDAR returns fall on building" | The building is newer than the scan, or you picked the wrong footprint |
| "No roof faces worth mounting panels on" | Everything found was north-facing or under 8 m² |
+| "the building tile could not be read" | The GeoPackage asset was not a GeoPackage — usually a changed download URL |
Failures never change your configuration.
diff --git a/go/internal/roofmodel/roofmodel.go b/go/internal/roofmodel/roofmodel.go
index fe7453fac..087b6504a 100644
--- a/go/internal/roofmodel/roofmodel.go
+++ b/go/internal/roofmodel/roofmodel.go
@@ -101,6 +101,11 @@ type Model struct {
Collection string `json:"collection"`
ItemCount int `json:"item_count"`
DatasetDatetime string `json:"dataset_datetime"`
+ // Fetch is "copc-window" when only the picked building's neighbourhood
+ // was pulled from the LiDAR tile, or "whole-tile" when the whole thing
+ // came across. It also qualifies Building.ReturnsInRadius below, whose
+ // denominator is the fetched window in the first case.
+ Fetch string `json:"fetch"`
} `json:"source"`
// CapturedAtMs is when Lantmäteriet flew the LiDAR. Null while their STAC
// datetime backfill is incomplete, which is a missing provenance date and
diff --git a/go/internal/roofmodel/roofmodel_test.go b/go/internal/roofmodel/roofmodel_test.go
index 5bb09e6d2..97398a798 100644
--- a/go/internal/roofmodel/roofmodel_test.go
+++ b/go/internal/roofmodel/roofmodel_test.go
@@ -228,6 +228,30 @@ func TestDeriveParsesAModel(t *testing.T) {
}
}
+// Whether the module streamed a window out of the COPC tile or pulled the whole
+// thing changes what ReturnsInRadius counts, so the answer has to reach core
+// rather than being inferred from the numbers.
+func TestDeriveCarriesHowTheLidarWasFetched(t *testing.T) {
+ doc := `{"schema_version":1,"planes_found":2,` +
+ `"source":{"provider":"lantmateriet","collection":"laserdata-nedladdning-skog",` +
+ `"item_count":1,"fetch":"copc-window"},` +
+ `"building":{"building_id":"b-1","area_m2":144,"returns_used":220,"returns_in_radius":260},` +
+ `"arrays":[]}`
+ cmd := stubModule(t, "stdout", doc)
+ s := svc(t, &config.RoofModel{Enabled: true, Command: cmd, GeotorgetUsername: "u", GeotorgetToken: "t"})
+
+ m, err := s.Derive(context.Background(), stockholmLat, stockholmLon, "b-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if m.Source.Fetch != "copc-window" {
+ t.Errorf("fetch = %q, want copc-window", m.Source.Fetch)
+ }
+ if m.Building == nil || m.Building.ReturnsUsed != 220 {
+ t.Fatalf("building = %+v", m.Building)
+ }
+}
+
// The site and the operator's credentials have to survive the process boundary,
// or the module derives a roof somewhere else entirely.
func TestDerivePassesTheSiteAndCredentials(t *testing.T) {
diff --git a/roofmodel/ftw_roofmodel/buildings.py b/roofmodel/ftw_roofmodel/buildings.py
index a0bcc00ca..595a64f15 100644
--- a/roofmodel/ftw_roofmodel/buildings.py
+++ b/roofmodel/ftw_roofmodel/buildings.py
@@ -4,14 +4,21 @@
around a coordinate returns the neighbours' roofs, the garage and whatever trees
stand in the garden, and the segmenter has no way to know which returns belong
to the operator's own house. Worse, RANSAC fits *infinite* planes over the whole
-tile: an azimuth-180 roof plane is z = f(y) with no x term, so a second building
-sharing the ridge orientation falls inside its inlier band however far away it
-is, and loses points to it. Measured on a synthetic pair, a detached garage
-recovered 93% of its true area and split into two fragments when it lay on the
-house's plane, against 100% and one face when it did not.
+tile: an azimuth-180 roof plane is z = f(y) with no x term, so it does not stop
+at the wall, and a second building sharing the pitch and ridge orientation lies
+on *the same* plane rather than a similar one.
+
+Measured on a synthetic pair, one RANSAC pass over a house and a garage 40 m
+apart consumed all 576 of the house's south-face returns and all 256 of the
+garage's as a single surface -- and produced identical output at every
+separation from 3 m to 40 m, which is the signature of a global fit rather than
+a neighbourhood effect. Only the DBSCAN pass afterwards told the two buildings
+apart, leaving a clustering parameter as the sole thing standing between a
+garage and its neighbour's roof.
Clipping to a chosen footprint removes that whole class of error: the returns
-that reach the segmenter are the ones standing on the operator's building.
+that reach the segmenter are the ones standing on the operator's building, and
+the derived face is then identical to segmenting that building in isolation.
Coordinate frames
-----------------
@@ -29,7 +36,15 @@
from typing import Any, Iterable
from . import sweref
-from .geotorget import COLLECTION_BUILDINGS, GeotorgetClient, GeotorgetError, StacItem
+from .geopackage import GeoPackageError, read_features
+from .geotorget import (
+ COLLECTION_BUILDINGS,
+ MEDIA_GEOJSON,
+ MEDIA_GEOPACKAGE,
+ GeotorgetClient,
+ GeotorgetError,
+ StacItem,
+)
# How far around the site to look for candidate buildings. Wide enough to reach
# a house set back from its coordinate, narrow enough not to return a village.
@@ -169,16 +184,49 @@ def _to_sweref(ring: list[tuple[float, float]]) -> list[tuple[float, float]]:
return [sweref.wgs84_to_sweref99tm(lat, lon) for lon, lat in ring]
-def _features_from_item(item: StacItem) -> list[dict[str, Any]]:
+def _features_from_item(item: StacItem, client: GeotorgetClient | None = None) -> list[dict[str, Any]]:
"""Every building-like feature an item carries.
- A STAC item may itself be the building, or it may be a tile whose asset
- holds them. Inline geometry is preferred: it costs no download.
+ A STAC item may *be* the building -- geometry inline, no download -- or it
+ may be a tile whose asset holds thousands of them. Lantmaeteriet publishes
+ *Byggnad Nedladdning, vektor* as **GeoPackage**, so the asset path is the
+ normal one and the inline path is the exception.
"""
geom = (item.raw or {}).get("geometry")
if geom:
return [{"geometry": geom, "properties": (item.raw or {}).get("properties") or {},
"id": item.item_id}]
+ if client is None:
+ return []
+ asset = item.pick(MEDIA_GEOPACKAGE, MEDIA_GEOJSON)
+ if asset is None or not asset.href:
+ return []
+ media = asset.effective_media_type
+ payload = client.download(asset.href)
+ if media == MEDIA_GEOJSON:
+ return _features_from_geojson(payload)
+ try:
+ return read_features(payload)
+ except GeoPackageError as exc:
+ raise BuildingLookupError(
+ f"the building tile for this site could not be read: {exc}"
+ ) from exc
+
+
+def _features_from_geojson(payload: bytes) -> list[dict[str, Any]]:
+ """A GeoJSON FeatureCollection asset, for catalogues that publish one."""
+ import json
+
+ try:
+ doc = json.loads(payload.decode("utf-8"))
+ except (UnicodeDecodeError, ValueError) as exc:
+ raise BuildingLookupError(
+ f"the building tile was announced as GeoJSON but did not parse: {exc}"
+ ) from exc
+ if isinstance(doc, dict) and doc.get("type") == "FeatureCollection":
+ return list(doc.get("features") or [])
+ if isinstance(doc, dict) and doc.get("type") == "Feature":
+ return [doc]
return []
@@ -228,7 +276,7 @@ def search_buildings(
items = client.search(COLLECTION_BUILDINGS, bbox, limit=limit)
features: list[dict[str, Any]] = []
for item in items:
- features.extend(_features_from_item(item))
+ features.extend(_features_from_item(item, client))
if not features:
raise BuildingLookupError(
"no building footprints were returned for this site. The Geotorget "
diff --git a/roofmodel/ftw_roofmodel/geopackage.py b/roofmodel/ftw_roofmodel/geopackage.py
new file mode 100644
index 000000000..9a7ad342c
--- /dev/null
+++ b/roofmodel/ftw_roofmodel/geopackage.py
@@ -0,0 +1,228 @@
+"""Read polygon features out of a GeoPackage, using only the standard library.
+
+Lantmaeteriet publishes *Byggnad Nedladdning, vektor* as GeoPackage, so a STAC
+item for a building tile carries a `.gpkg` asset rather than inline GeoJSON.
+
+A GeoPackage is a SQLite database with an agreed set of metadata tables, and
+geometry stored as a small binary header followed by standard WKB. Both are
+published specifications with fixed layouts, so decoding them here costs about
+a hundred lines of `sqlite3` and `struct` -- against a GDAL/fiona/geopandas
+dependency that would not install on a Pi without a compiler and pulls in a
+second projection stack we already decided not to carry (see sweref.py, which
+implements SWEREF 99 TM directly for the same reason).
+
+Only what a roof model needs is read: polygon and multipolygon rings, in the
+file's own coordinate reference system. Curves, triangulated surfaces and the
+extended (`GPB` "extended geometry") binary types are rejected explicitly rather
+than mis-parsed, because a wrong ring silently clips the wrong LiDAR.
+
+References
+----------
+GeoPackage Encoding Standard (OGC 12-128r19), clause 2.1.3 "BLOB Format".
+OpenGIS Simple Features (OGC 06-103r4), clause 8.2 "Well-known Binary".
+"""
+
+from __future__ import annotations
+
+import os
+import sqlite3
+import struct
+import tempfile
+from typing import Any, Iterator
+
+# "GP" -- the two magic bytes every GeoPackage geometry blob starts with.
+GPKG_MAGIC = b"GP"
+
+# Envelope sizes in doubles, indexed by the header's envelope indicator.
+# 0 = absent, 1 = xy, 2 = xyz, 3 = xym, 4 = xyzm. 5-7 are reserved.
+_ENVELOPE_DOUBLES = {0: 0, 1: 4, 2: 6, 3: 6, 4: 8}
+
+# WKB geometry type codes we can use. The ISO variants add 1000 for Z, 2000 for
+# M and 3000 for ZM, so the base code is recovered with % 1000.
+_WKB_POLYGON = 3
+_WKB_MULTIPOLYGON = 6
+
+# The EWKB flag bits PostGIS adds to the type word. GeoPackage forbids them, but
+# files written by other tools do turn up, and reading a flagged type as a
+# geometry code would silently produce nonsense.
+_EWKB_Z = 0x80000000
+_EWKB_M = 0x40000000
+_EWKB_SRID = 0x20000000
+
+
+class GeoPackageError(ValueError):
+ """The file is not a GeoPackage, or holds geometry we will not guess at."""
+
+
+def _unpack(fmt: str, data: bytes, offset: int) -> tuple[Any, ...]:
+ size = struct.calcsize(fmt)
+ if offset + size > len(data):
+ raise GeoPackageError("geometry blob ended mid-value")
+ return struct.unpack_from(fmt, data, offset)
+
+
+def _dimensions(type_word: int) -> tuple[int, int]:
+ """(base geometry code, coordinates per point) for a WKB type word."""
+ has_z = bool(type_word & _EWKB_Z)
+ has_m = bool(type_word & _EWKB_M)
+ code = type_word & ~(_EWKB_Z | _EWKB_M | _EWKB_SRID)
+ # ISO style: 1000/2000/3000 offsets carry the same information.
+ if code >= 3000:
+ code, has_z, has_m = code - 3000, True, True
+ elif code >= 2000:
+ code, has_m = code - 2000, True
+ elif code >= 1000:
+ code, has_z = code - 1000, True
+ return code, 2 + int(has_z) + int(has_m)
+
+
+def _read_ring(data: bytes, offset: int, endian: str, coords: int) -> tuple[list[tuple[float, float]], int]:
+ (count,) = _unpack(endian + "I", data, offset)
+ offset += 4
+ stride = 8 * coords
+ ring: list[tuple[float, float]] = []
+ for _ in range(count):
+ x, y = _unpack(endian + "dd", data, offset)
+ ring.append((x, y))
+ offset += stride
+ return ring, offset
+
+
+def _read_polygon(data: bytes, offset: int, endian: str, coords: int) -> tuple[list[list[tuple[float, float]]], int]:
+ (n_rings,) = _unpack(endian + "I", data, offset)
+ offset += 4
+ rings = []
+ for _ in range(n_rings):
+ ring, offset = _read_ring(data, offset, endian, coords)
+ rings.append(ring)
+ return rings, offset
+
+
+def _read_geometry(data: bytes, offset: int) -> tuple[list[list[list[tuple[float, float]]]], int]:
+ """Read one WKB geometry, returning it as a list of polygons."""
+ (byte_order,) = _unpack("B", data, offset)
+ endian = "<" if byte_order == 1 else ">"
+ (type_word,) = _unpack(endian + "I", data, offset + 1)
+ offset += 5
+ if type_word & _EWKB_SRID:
+ offset += 4 # embedded SRID, which we take from the header instead
+ code, coords = _dimensions(type_word)
+ if code == _WKB_POLYGON:
+ rings, offset = _read_polygon(data, offset, endian, coords)
+ return [rings], offset
+ if code == _WKB_MULTIPOLYGON:
+ (n,) = _unpack(endian + "I", data, offset)
+ offset += 4
+ polys = []
+ for _ in range(n):
+ # Each part carries its own byte order and type word.
+ part, offset = _read_geometry(data, offset)
+ polys.extend(part)
+ return polys, offset
+ raise GeoPackageError(
+ f"WKB geometry type {code} is not a polygon; a building footprint must be "
+ "a Polygon or MultiPolygon"
+ )
+
+
+def parse_geometry_blob(blob: bytes) -> dict[str, Any] | None:
+ """Decode a GeoPackage geometry BLOB into a GeoJSON-shaped geometry.
+
+ Returns None for the empty geometry, which GeoPackage represents with a flag
+ rather than an absent row.
+ """
+ if len(blob) < 8 or blob[:2] != GPKG_MAGIC:
+ raise GeoPackageError("not a GeoPackage geometry blob (bad magic)")
+ flags = blob[3]
+ if flags & 0x20:
+ raise GeoPackageError(
+ "extended (ExtendedGeoPackageBinary) geometry is not supported"
+ )
+ envelope_indicator = (flags >> 1) & 0x07
+ if envelope_indicator not in _ENVELOPE_DOUBLES:
+ raise GeoPackageError(f"reserved envelope indicator {envelope_indicator}")
+ if flags & 0x10: # empty geometry
+ return None
+ offset = 8 + 8 * _ENVELOPE_DOUBLES[envelope_indicator]
+ polygons, _ = _read_geometry(blob, offset)
+ if not polygons:
+ return None
+ if len(polygons) == 1:
+ return {"type": "Polygon", "coordinates": [[list(p) for p in r] for r in polygons[0]]}
+ return {
+ "type": "MultiPolygon",
+ "coordinates": [[[list(p) for p in r] for r in poly] for poly in polygons],
+ }
+
+
+def _feature_tables(conn: sqlite3.Connection) -> list[tuple[str, str]]:
+ """(table, geometry column) for every feature table in the file."""
+ try:
+ rows = conn.execute(
+ "SELECT c.table_name, g.column_name FROM gpkg_contents c "
+ "JOIN gpkg_geometry_columns g ON g.table_name = c.table_name "
+ "WHERE c.data_type = 'features'"
+ ).fetchall()
+ except sqlite3.DatabaseError as exc:
+ raise GeoPackageError(f"not a readable GeoPackage: {exc}") from exc
+ return [(str(t), str(c)) for t, c in rows]
+
+
+def read_features(data: bytes, *, limit: int = 5000) -> list[dict[str, Any]]:
+ """Every polygon feature in a GeoPackage, as GeoJSON-shaped dicts.
+
+ Attributes travel alongside the geometry so the picker can label a building
+ with whatever the source calls it.
+ """
+ return list(iter_features(data, limit=limit))
+
+
+def iter_features(data: bytes, *, limit: int = 5000) -> Iterator[dict[str, Any]]:
+ if not data.startswith(b"SQLite format 3\x00"):
+ raise GeoPackageError(
+ "asset is not a GeoPackage (missing the SQLite file header)"
+ )
+ # sqlite3 opens paths, not buffers, and a GeoPackage is random-access by
+ # design, so the bytes land in a temp file for the life of the read.
+ fd, path = tempfile.mkstemp(suffix=".gpkg")
+ try:
+ with os.fdopen(fd, "wb") as fh:
+ fh.write(data)
+ conn = sqlite3.connect(path)
+ try:
+ conn.row_factory = sqlite3.Row
+ yielded = 0
+ for table, geom_col in _feature_tables(conn):
+ cursor = conn.execute(f'SELECT * FROM "{table}"')
+ for row in cursor:
+ if yielded >= limit:
+ return
+ blob = row[geom_col]
+ if not isinstance(blob, (bytes, bytearray)):
+ continue
+ try:
+ geometry = parse_geometry_blob(bytes(blob))
+ except GeoPackageError:
+ # One unreadable row must not lose the other buildings
+ # in the tile.
+ continue
+ if geometry is None:
+ continue
+ props = {
+ k: row[k]
+ for k in row.keys()
+ if k != geom_col and isinstance(row[k], (str, int, float))
+ }
+ yield {
+ "id": str(props.get("objektidentitet") or f"{table}-{yielded}"),
+ "geometry": geometry,
+ "properties": props,
+ }
+ yielded += 1
+ finally:
+ conn.close()
+ finally:
+ try:
+ os.unlink(path)
+ except OSError: # pragma: no cover - Windows may hold the handle briefly
+ pass
diff --git a/roofmodel/ftw_roofmodel/geotorget.py b/roofmodel/ftw_roofmodel/geotorget.py
index 6bdbbe2e7..72553ea0a 100644
--- a/roofmodel/ftw_roofmodel/geotorget.py
+++ b/roofmodel/ftw_roofmodel/geotorget.py
@@ -24,10 +24,31 @@
DEFAULT_BASE_URL = "https://api.lantmateriet.se"
-# Collection ids as published in Lantmaeteriet's STAC catalogue.
+# Collection ids as published in Lantmaeteriet's STAC catalogue. Both products
+# are STAC APIs over the same base URL and the same credentials; they differ
+# only in what their items point at, which is what the media types below say.
COLLECTION_BUILDINGS = "byggnad-nedladdning-vektor"
COLLECTION_LIDAR = "laserdata-nedladdning-skog"
+# Media types, so an asset is chosen by *what it is* rather than by hoping the
+# publisher named the key "data". Byggnad-vektor delivers GeoPackage; Laserdata
+# Skog delivers LAZ organised as COPC (Cloud Optimized Point Cloud).
+MEDIA_GEOPACKAGE = "application/geopackage+sqlite3"
+MEDIA_COPC = "application/vnd.laszip+copc"
+MEDIA_LAZ = "application/vnd.laszip"
+MEDIA_LAS = "application/vnd.las"
+MEDIA_GEOJSON = "application/geo+json"
+
+# Longest suffix first: a COPC file is also a .laz, and reading it as a plain
+# one would download the whole tile instead of the part we asked for.
+_EXTENSION_MEDIA: tuple[tuple[str, str], ...] = (
+ (".copc.laz", MEDIA_COPC),
+ (".gpkg", MEDIA_GEOPACKAGE),
+ (".geojson", MEDIA_GEOJSON),
+ (".laz", MEDIA_LAZ),
+ (".las", MEDIA_LAS),
+)
+
class GeotorgetError(RuntimeError):
"""Any failure talking to Geotorget."""
@@ -51,22 +72,85 @@ def validate(self) -> None:
)
+def media_type_for(href: str) -> str | None:
+ """Media type implied by a URL's extension, or None if it says nothing."""
+ path = href.split("?", 1)[0].split("#", 1)[0].lower()
+ for suffix, media in _EXTENSION_MEDIA:
+ if path.endswith(suffix):
+ return media
+ return None
+
+
+@dataclasses.dataclass(frozen=True)
+class Asset:
+ """One STAC asset: where it is, and what it is."""
+
+ href: str
+ media_type: str | None = None
+ roles: tuple[str, ...] = ()
+ title: str = ""
+
+ @property
+ def effective_media_type(self) -> str | None:
+ """Declared media type, or the one the extension implies.
+
+ Catalogues are inconsistent about `type`, and an asset with no declared
+ type is common enough that refusing to guess would mean refusing most
+ real items. The extension is only consulted when nothing was declared.
+ """
+ return self.media_type or media_type_for(self.href)
+
+
@dataclasses.dataclass
class StacItem:
"""One STAC item, reduced to what the pipeline needs."""
item_id: str
collection: str
- assets: dict[str, str]
+ assets: dict[str, Asset]
captured_at: dt.datetime | None
raw: dict[str, Any] = dataclasses.field(default_factory=dict, repr=False)
+ def __post_init__(self) -> None:
+ # A bare href is accepted wherever an Asset is, so callers and tests can
+ # write {"data": "http://.../tile.copc.laz"} without losing the typing
+ # that selection depends on -- the extension supplies it.
+ self.assets = {
+ name: value if isinstance(value, Asset) else Asset(href=str(value))
+ for name, value in (self.assets or {}).items()
+ }
+
+ def pick(self, *media_types: str) -> Asset | None:
+ """Best asset for a wanted media type, most preferred type first.
+
+ Where nothing declares a usable type the search widens: an asset with
+ the `data` role, then a lone asset, since an item carrying exactly one
+ asset is unambiguous however it is labelled.
+
+ Both fallbacks consider only assets of *unknown* type. Guessing in the
+ absence of information is reasonable; guessing against it is not, and an
+ item whose single asset is a thumbnail must not be handed back as a
+ point cloud.
+ """
+ for wanted in media_types:
+ for asset in self.assets.values():
+ if asset.effective_media_type == wanted:
+ return asset
+ untyped = [a for a in self.assets.values() if a.effective_media_type is None]
+ for asset in untyped:
+ if "data" in asset.roles:
+ return asset
+ if len(untyped) == 1:
+ return untyped[0]
+ return None
+
def asset_url(self, *preferred: str) -> str | None:
"""First matching asset href, trying each preferred key in order."""
for key in preferred:
if key in self.assets:
- return self.assets[key]
- return next(iter(self.assets.values()), None)
+ return self.assets[key].href
+ first = next(iter(self.assets.values()), None)
+ return first.href if first else None
def _parse_datetime(value: str | None) -> dt.datetime | None:
@@ -87,7 +171,12 @@ def _parse_datetime(value: str | None) -> dt.datetime | None:
def _item_from_feature(feature: dict[str, Any]) -> StacItem:
props = feature.get("properties") or {}
assets = {
- name: asset.get("href", "")
+ name: Asset(
+ href=asset.get("href", ""),
+ media_type=asset.get("type") or None,
+ roles=tuple(asset.get("roles") or ()),
+ title=str(asset.get("title") or ""),
+ )
for name, asset in (feature.get("assets") or {}).items()
if asset.get("href")
}
@@ -135,6 +224,11 @@ def __init__(
session.auth = (credentials.username, credentials.password)
self._session = session
+ @property
+ def session(self) -> Any:
+ """The authenticated session, for readers that stream their own ranges."""
+ return self._session
+
def search(
self,
collection: str,
diff --git a/roofmodel/ftw_roofmodel/pipeline.py b/roofmodel/ftw_roofmodel/pipeline.py
index ae0a6e00c..85f78b48a 100644
--- a/roofmodel/ftw_roofmodel/pipeline.py
+++ b/roofmodel/ftw_roofmodel/pipeline.py
@@ -17,8 +17,13 @@
import datetime as dt
from typing import Any
-from . import sweref
-from .buildings import Building, clip_to_footprint, search_buildings
+from . import geotorget, pointcloud, sweref
+from .buildings import (
+ DEFAULT_EAVES_BUFFER_M,
+ Building,
+ clip_to_footprint,
+ search_buildings,
+)
from .geotorget import (
COLLECTION_LIDAR,
Credentials,
@@ -128,27 +133,47 @@ def planes_to_arrays(
def load_points(data: bytes) -> Any:
- """Decode a LAZ/LAS payload into an (N, 3) array of SWEREF 99 TM metres.
+ """Decode a whole LAZ/LAS payload into (N, 3) SWEREF 99 TM metres.
- laspy is imported here rather than at module scope so that everything above
- -- projection, segmentation, array derivation -- is importable and testable
- without the geospatial stack installed.
+ Re-exported from pointcloud so that laspy stays lazily imported: everything
+ above -- projection, segmentation, array derivation -- is importable and
+ testable without the geospatial stack installed.
"""
- import io
-
- try:
- import laspy
- except ImportError as exc: # pragma: no cover - depends on the install
- raise RoofModelError(
- "laspy is required to read Lantmaeteriet LiDAR. Install the module's "
- "extras: pip install -e roofmodel[geo]"
- ) from exc
-
- import numpy as np
-
- with laspy.open(io.BytesIO(data)) as reader:
- las = reader.read()
- return np.column_stack([np.asarray(las.x), np.asarray(las.y), np.asarray(las.z)])
+ return pointcloud.load_points(data)
+
+
+def _read_lidar(
+ client: GeotorgetClient,
+ items: list[StacItem],
+ chosen: Building | None,
+) -> tuple[Any, str]:
+ """Points for the first readable LiDAR asset, and how they were fetched.
+
+ Laserdata Skog is LAZ organised as COPC, so when the operator has already
+ picked a building there is no reason to move the rest of a 2.5 km tile
+ across the network: the footprint's bounding box is exactly the query COPC
+ is built to answer. Everything about that is best-effort -- a plain LAZ
+ asset, a host that ignores `Range`, or a laspy without COPC support all
+ fall back to reading the tile whole.
+ """
+ for item in items:
+ asset = item.pick(
+ geotorget.MEDIA_COPC, geotorget.MEDIA_LAZ, geotorget.MEDIA_LAS
+ )
+ if asset is None or not asset.href:
+ continue
+ if chosen is not None and asset.effective_media_type == geotorget.MEDIA_COPC:
+ session = getattr(client, "session", None)
+ if session is not None:
+ bounds = pointcloud.bounds_of(chosen.ring_sweref, DEFAULT_EAVES_BUFFER_M)
+ try:
+ return pointcloud.read_copc_window(session, asset.href, bounds), "copc-window"
+ except pointcloud.PointCloudError:
+ # A slow success beats a failure; the operator gets their
+ # roof either way, and `fetch` records which path ran.
+ pass
+ return load_points(client.download(asset.href)), "whole-tile"
+ raise RoofModelError("LiDAR tiles carried no readable point data")
def derive(
@@ -198,13 +223,7 @@ def derive(
"Lantmaeteriet data is Sweden only"
)
- points = None
- for item in lidar_items:
- url = item.asset_url("data", "laz", "copc")
- if not url:
- continue
- points = load_points(client.download(url))
- break
+ points, fetch = _read_lidar(client, lidar_items, chosen)
if points is None or len(points) == 0:
raise RoofModelError("LiDAR tiles carried no readable point data")
@@ -233,6 +252,10 @@ def derive(
"collection": COLLECTION_LIDAR,
"item_count": len(lidar_items),
"dataset_datetime": captured.isoformat() if captured else None,
+ # "copc-window" means only the footprint's neighbourhood was moved
+ # across the network, which also makes returns_in_radius below a
+ # count over that window rather than over the whole search radius.
+ "fetch": fetch,
},
"building": {
"building_id": chosen.building_id,
diff --git a/roofmodel/ftw_roofmodel/pointcloud.py b/roofmodel/ftw_roofmodel/pointcloud.py
new file mode 100644
index 000000000..622c9efc4
--- /dev/null
+++ b/roofmodel/ftw_roofmodel/pointcloud.py
@@ -0,0 +1,226 @@
+"""Read Lantmaeteriet LiDAR, fetching only the part of the tile we need.
+
+*Laserdata Nedladdning, Skog* is delivered as LAZ organised as **COPC** (Cloud
+Optimized Point Cloud): the points are ordered into an octree and the node index
+lives in a VLR at a known offset, so a reader that can issue HTTP range requests
+can pull the handful of octree nodes covering one building instead of the whole
+2.5 km tile.
+
+That is worth real money on a Pi. A Laserdata Skog tile is hundreds of megabytes;
+a detached house is a few tens of metres across. Since the operator has already
+told us *which building* they mean, the bounding box of that footprint is exactly
+the query COPC exists to answer.
+
+The fallbacks are deliberate and ordered, because none of the preconditions are
+guaranteed:
+
+ 1. COPC asset + a bounding box + a server that honours `Range` -> spatial query.
+ 2. Anything else -> download the asset and read it whole.
+
+A server that ignores `Range` returns 200 with the entire body, which would
+otherwise be mistaken for a successful partial read, so that case is detected on
+the status code rather than assumed away.
+"""
+
+from __future__ import annotations
+
+import io
+from typing import Any
+
+__all__ = [
+ "PointCloudError",
+ "HttpRangeFile",
+ "bounds_of",
+ "load_points",
+ "read_copc_window",
+]
+
+# Read this much per range request. COPC chunks are small, and a request per
+# chunk would spend more time in round trips than in transfer.
+DEFAULT_CHUNK_BYTES = 1 << 20
+
+
+class PointCloudError(RuntimeError):
+ """The LiDAR asset could not be read."""
+
+
+class HttpRangeFile(io.RawIOBase):
+ """A seekable read-only file over HTTP `Range` requests.
+
+ laspy's COPC reader needs `seek`/`read` and nothing else, so this is the
+ whole adapter: it turns an HTTP URL into something that behaves like an open
+ file without ever holding the tile in memory.
+ """
+
+ def __init__(self, session: Any, url: str, *, timeout: float = 60.0,
+ chunk_bytes: int = DEFAULT_CHUNK_BYTES) -> None:
+ self._session = session
+ self._url = url
+ self._timeout = timeout
+ self._chunk = max(1, chunk_bytes)
+ self._pos = 0
+ self._size: int | None = None
+ # One cached chunk. COPC reads are clustered -- header, then index, then
+ # the nodes -- so a single block absorbs most of the repeat traffic.
+ self._cache: tuple[int, bytes] | None = None
+ self.requests = 0
+ self.bytes_fetched = 0
+
+ # -- io.RawIOBase ----------------------------------------------------
+ def readable(self) -> bool:
+ return True
+
+ def seekable(self) -> bool:
+ return True
+
+ def tell(self) -> int:
+ return self._pos
+
+ def seek(self, offset: int, whence: int = io.SEEK_SET) -> int:
+ if whence == io.SEEK_SET:
+ self._pos = offset
+ elif whence == io.SEEK_CUR:
+ self._pos += offset
+ elif whence == io.SEEK_END:
+ self._pos = self.size + offset
+ else: # pragma: no cover - io module only defines the three
+ raise ValueError(f"invalid whence {whence}")
+ self._pos = max(0, self._pos)
+ return self._pos
+
+ def read(self, size: int = -1) -> bytes:
+ if size is None or size < 0:
+ size = max(0, self.size - self._pos)
+ if size == 0:
+ return b""
+ data = self._read_at(self._pos, size)
+ self._pos += len(data)
+ return data
+
+ def readall(self) -> bytes:
+ return self.read(-1)
+
+ def readinto(self, buffer) -> int: # type: ignore[override]
+ data = self.read(len(buffer))
+ buffer[: len(data)] = data
+ return len(data)
+
+ # -- range plumbing --------------------------------------------------
+ @property
+ def size(self) -> int:
+ if self._size is None:
+ self._size = self._head_size()
+ return self._size
+
+ def _head_size(self) -> int:
+ try:
+ resp = self._session.head(self._url, timeout=self._timeout)
+ except Exception as exc:
+ raise PointCloudError(f"could not stat {self._url}: {exc}") from exc
+ length = (getattr(resp, "headers", None) or {}).get("Content-Length")
+ if getattr(resp, "status_code", 0) != 200 or not length:
+ raise PointCloudError(
+ "the LiDAR host did not report a size, so it cannot be read in ranges"
+ )
+ return int(length)
+
+ def _read_at(self, offset: int, size: int) -> bytes:
+ cached = self._from_cache(offset, size)
+ if cached is not None:
+ return cached
+ want = max(size, self._chunk)
+ end = offset + want - 1
+ try:
+ resp = self._session.get(
+ self._url,
+ headers={"Range": f"bytes={offset}-{end}"},
+ timeout=self._timeout,
+ )
+ except Exception as exc:
+ raise PointCloudError(f"range request failed: {exc}") from exc
+ status = getattr(resp, "status_code", 0)
+ if status == 200:
+ # The server ignored Range and sent everything. Honest failure: the
+ # caller falls back to a whole-tile read rather than silently
+ # paying for the full download on every seek.
+ raise PointCloudError("the LiDAR host does not support range requests")
+ if status != 206:
+ raise PointCloudError(f"range request returned HTTP {status}")
+ body = resp.content
+ self.requests += 1
+ self.bytes_fetched += len(body)
+ self._cache = (offset, body)
+ return body[:size]
+
+ def _from_cache(self, offset: int, size: int) -> bytes | None:
+ if self._cache is None:
+ return None
+ start, body = self._cache
+ if offset < start or offset + size > start + len(body):
+ return None
+ rel = offset - start
+ return body[rel : rel + size]
+
+
+def bounds_of(ring: list[tuple[float, float]], pad_m: float = 2.0) -> tuple[float, float, float, float]:
+ xs = [p[0] for p in ring]
+ ys = [p[1] for p in ring]
+ return (min(xs) - pad_m, min(ys) - pad_m, max(xs) + pad_m, max(ys) + pad_m)
+
+
+def _points_from_las(las: Any) -> Any:
+ import numpy as np
+
+ return np.column_stack([np.asarray(las.x), np.asarray(las.y), np.asarray(las.z)])
+
+
+def load_points(data: bytes) -> Any:
+ """Decode a whole LAZ/LAS payload into (N, 3) SWEREF 99 TM metres."""
+ laspy = _import_laspy()
+ with laspy.open(io.BytesIO(data)) as reader:
+ las = reader.read()
+ return _points_from_las(las)
+
+
+def _import_laspy():
+ try:
+ import laspy
+ except ImportError as exc: # pragma: no cover - depends on the install
+ raise PointCloudError(
+ "laspy is required to read Lantmaeteriet LiDAR. Install the module's "
+ "extras: pip install -e roofmodel[geo]"
+ ) from exc
+ return laspy
+
+
+def read_copc_window(
+ session: Any,
+ url: str,
+ bounds: tuple[float, float, float, float],
+ *,
+ timeout: float = 60.0,
+) -> Any:
+ """Points inside `bounds` from a COPC file, over HTTP range requests.
+
+ Raises PointCloudError if the file or the host cannot support it, so the
+ caller can fall back to a whole-tile read.
+ """
+ laspy = _import_laspy()
+ try:
+ from laspy.copc import Bounds, CopcReader
+ except ImportError as exc:
+ raise PointCloudError(
+ "this laspy build has no COPC support; install laspy[lazrs] 2.5 or newer"
+ ) from exc
+
+ min_x, min_y, max_x, max_y = bounds
+ handle = HttpRangeFile(session, url, timeout=timeout)
+ try:
+ with CopcReader.open(handle) as reader:
+ query = Bounds(mins=[min_x, min_y], maxs=[max_x, max_y])
+ points = reader.query(query)
+ except PointCloudError:
+ raise
+ except Exception as exc:
+ raise PointCloudError(f"COPC read failed: {exc}") from exc
+ return _points_from_las(points)
diff --git a/roofmodel/tests/test_assets.py b/roofmodel/tests/test_assets.py
new file mode 100644
index 000000000..107a5baef
--- /dev/null
+++ b/roofmodel/tests/test_assets.py
@@ -0,0 +1,212 @@
+"""Choosing STAC assets by what they are, and reading them in ranges.
+
+Both Lantmaeteriet products are STAC APIs; they differ in what their items point
+at. Byggnad-vektor delivers GeoPackage, Laserdata Skog delivers LAZ organised as
+COPC. Selecting on media type rather than on an asset key is what keeps that
+difference from becoming a pile of special cases.
+"""
+
+from __future__ import annotations
+
+import io
+
+import pytest
+
+from ftw_roofmodel.geotorget import (
+ MEDIA_COPC,
+ MEDIA_GEOJSON,
+ MEDIA_GEOPACKAGE,
+ MEDIA_LAZ,
+ Asset,
+ StacItem,
+ _item_from_feature,
+ media_type_for,
+)
+from ftw_roofmodel.pointcloud import HttpRangeFile, PointCloudError, bounds_of
+
+
+def item(assets):
+ return StacItem("i", "c", assets, None, raw={})
+
+
+def test_a_bare_href_still_gets_a_type_from_its_extension():
+ """Tests and simple catalogues pass strings; selection must still work."""
+ it = item({"data": "https://x/tile.copc.laz"})
+ assert isinstance(it.assets["data"], Asset)
+ assert it.pick(MEDIA_COPC).href == "https://x/tile.copc.laz"
+
+
+def test_copc_is_recognised_before_plain_laz():
+ """A COPC file is also a .laz; reading it as one costs the whole tile."""
+ assert media_type_for("https://x/y/tile.copc.laz") == MEDIA_COPC
+ assert media_type_for("https://x/y/tile.laz") == MEDIA_LAZ
+
+
+def test_query_strings_do_not_hide_the_extension():
+ """Signed download URLs carry tokens after a '?'."""
+ assert media_type_for("https://x/tile.gpkg?token=abc&x=1") == MEDIA_GEOPACKAGE
+
+
+def test_a_declared_type_beats_the_extension():
+ """The catalogue knows better than the filename."""
+ a = Asset(href="https://x/download", media_type=MEDIA_GEOPACKAGE)
+ assert a.effective_media_type == MEDIA_GEOPACKAGE
+
+
+def test_preference_order_is_honoured():
+ it = item({
+ "laz": Asset("https://x/t.laz", MEDIA_LAZ),
+ "copc": Asset("https://x/t.copc.laz", MEDIA_COPC),
+ })
+ assert it.pick(MEDIA_COPC, MEDIA_LAZ).effective_media_type == MEDIA_COPC
+ assert it.pick(MEDIA_LAZ, MEDIA_COPC).effective_media_type == MEDIA_LAZ
+
+
+def test_falls_back_to_the_data_role_when_the_type_is_unknown():
+ it = item({
+ "thumbnail": Asset("https://x/preview.png", "image/png", roles=("thumbnail",)),
+ "mystery": Asset("https://x/blob", None, roles=("data",)),
+ })
+ assert it.pick(MEDIA_COPC).href == "https://x/blob"
+
+
+def test_a_lone_asset_is_unambiguous_whatever_it_is_called():
+ assert item({"whatever": Asset("https://x/blob")}).pick(MEDIA_COPC).href == "https://x/blob"
+
+
+def test_several_unlabelled_assets_are_refused_rather_than_guessed():
+ it = item({"a": Asset("https://x/a"), "b": Asset("https://x/b")})
+ assert it.pick(MEDIA_COPC) is None
+
+
+def test_stac_assets_keep_their_type_and_roles():
+ feature = {
+ "id": "tile-1",
+ "collection": "laserdata-nedladdning-skog",
+ "assets": {
+ "data": {
+ "href": "https://x/t.copc.laz",
+ "type": MEDIA_COPC,
+ "roles": ["data"],
+ "title": "Punktmoln",
+ }
+ },
+ "properties": {},
+ }
+ parsed = _item_from_feature(feature)
+ asset = parsed.pick(MEDIA_COPC)
+ assert asset.media_type == MEDIA_COPC
+ assert asset.roles == ("data",)
+ assert asset.title == "Punktmoln"
+
+
+def test_geojson_and_geopackage_are_both_selectable():
+ it = item({"gj": Asset("https://x/b.geojson"), "gp": Asset("https://x/b.gpkg")})
+ assert it.pick(MEDIA_GEOPACKAGE, MEDIA_GEOJSON).href == "https://x/b.gpkg"
+ assert it.pick(MEDIA_GEOJSON, MEDIA_GEOPACKAGE).href == "https://x/b.geojson"
+
+
+# --- range reads ------------------------------------------------------------
+
+
+class FakeResponse:
+ def __init__(self, status, content=b"", headers=None):
+ self.status_code = status
+ self.content = content
+ self.headers = headers or {}
+
+
+class RangeServer:
+ """Serves a byte string over Range, and counts what was actually moved."""
+
+ def __init__(self, body: bytes, *, supports_range: bool = True):
+ self.body = body
+ self.supports_range = supports_range
+ self.requests: list[str] = []
+
+ def head(self, url, timeout=None):
+ return FakeResponse(200, headers={"Content-Length": str(len(self.body))})
+
+ def get(self, url, headers=None, timeout=None):
+ rng = (headers or {}).get("Range")
+ if not self.supports_range or not rng:
+ self.requests.append("full")
+ return FakeResponse(200, self.body)
+ self.requests.append(rng)
+ spec = rng.split("=", 1)[1]
+ start, end = spec.split("-")
+ lo = int(start)
+ hi = min(int(end), len(self.body) - 1)
+ return FakeResponse(206, self.body[lo : hi + 1])
+
+
+BODY = bytes(range(256)) * 40 # 10 240 bytes, every offset distinguishable
+
+
+def test_reads_a_window_without_moving_the_whole_file():
+ server = RangeServer(BODY)
+ fh = HttpRangeFile(server, "https://x/t.copc.laz", chunk_bytes=512)
+ fh.seek(1000)
+ assert fh.read(16) == BODY[1000:1016]
+ assert fh.bytes_fetched == 512, "one chunk, not the whole file"
+ assert len(server.requests) == 1
+
+
+def test_seek_and_tell_track_the_position():
+ fh = HttpRangeFile(RangeServer(BODY), "https://x/t", chunk_bytes=64)
+ assert fh.seek(100) == 100 and fh.tell() == 100
+ fh.read(10)
+ assert fh.tell() == 110
+ assert fh.seek(-10, io.SEEK_END) == len(BODY) - 10
+ assert fh.seek(5, io.SEEK_CUR) == len(BODY) - 5
+
+
+def test_a_second_read_inside_the_chunk_costs_no_request():
+ server = RangeServer(BODY)
+ fh = HttpRangeFile(server, "https://x/t", chunk_bytes=1024)
+ fh.seek(0)
+ fh.read(8)
+ before = len(server.requests)
+ fh.seek(64)
+ assert fh.read(8) == BODY[64:72]
+ assert len(server.requests) == before, "the chunk was already held"
+
+
+def test_reading_across_the_chunk_boundary_fetches_again():
+ server = RangeServer(BODY)
+ fh = HttpRangeFile(server, "https://x/t", chunk_bytes=128)
+ fh.seek(0)
+ assert fh.read(8) == BODY[0:8]
+ fh.seek(4096)
+ assert fh.read(8) == BODY[4096:4104]
+ assert len(server.requests) == 2
+
+
+def test_a_host_that_ignores_range_is_detected_not_trusted():
+ """A 200 means the whole body arrived; treating it as partial corrupts."""
+ fh = HttpRangeFile(RangeServer(BODY, supports_range=False), "https://x/t")
+ fh.seek(10)
+ with pytest.raises(PointCloudError, match="range requests"):
+ fh.read(4)
+
+
+def test_a_host_that_will_not_report_a_size_is_refused():
+ class NoLength:
+ def head(self, url, timeout=None):
+ return FakeResponse(200, headers={})
+
+ with pytest.raises(PointCloudError, match="size"):
+ HttpRangeFile(NoLength(), "https://x/t").size
+
+
+def test_readinto_fills_the_buffer():
+ fh = HttpRangeFile(RangeServer(BODY), "https://x/t", chunk_bytes=256)
+ fh.seek(32)
+ buf = bytearray(16)
+ assert fh.readinto(buf) == 16
+ assert bytes(buf) == BODY[32:48]
+
+
+def test_bounds_pad_the_footprint_so_the_eaves_survive():
+ ring = [(100.0, 200.0), (110.0, 200.0), (110.0, 220.0), (100.0, 220.0)]
+ assert bounds_of(ring, 1.0) == (99.0, 199.0, 111.0, 221.0)
diff --git a/roofmodel/tests/test_geopackage.py b/roofmodel/tests/test_geopackage.py
new file mode 100644
index 000000000..54c49e424
--- /dev/null
+++ b/roofmodel/tests/test_geopackage.py
@@ -0,0 +1,201 @@
+"""Decoding GeoPackage, the format Lantmaeteriet ships building vectors in.
+
+The fixtures are built byte by byte from the published layouts rather than by
+round-tripping the decoder, so a decoder that agrees with itself but not with
+the standard still fails here.
+"""
+
+from __future__ import annotations
+
+import os
+import sqlite3
+import struct
+import tempfile
+
+import pytest
+
+from ftw_roofmodel.geopackage import (
+ GeoPackageError,
+ parse_geometry_blob,
+ read_features,
+)
+
+SWEREF = 3006
+
+
+def wkb_polygon(rings, *, little=True, z=False):
+ """Standard WKB polygon, per OGC 06-103r4 clause 8.2."""
+ e = "<" if little else ">"
+ code = 1003 if z else 3
+ out = struct.pack("B", 1 if little else 0) + struct.pack(e + "I", code)
+ out += struct.pack(e + "I", len(rings))
+ for ring in rings:
+ out += struct.pack(e + "I", len(ring))
+ for point in ring:
+ out += struct.pack(e + "dd", point[0], point[1])
+ if z:
+ out += struct.pack(e + "d", point[2] if len(point) > 2 else 0.0)
+ return out
+
+
+def wkb_multipolygon(polygons, *, little=True):
+ e = "<" if little else ">"
+ out = struct.pack("B", 1 if little else 0) + struct.pack(e + "I", 6)
+ out += struct.pack(e + "I", len(polygons))
+ for rings in polygons:
+ out += wkb_polygon(rings, little=little)
+ return out
+
+
+def gpkg_blob(wkb, *, envelope=None, srs_id=SWEREF, little=True, empty=False):
+ """GeoPackage geometry BLOB, per OGC 12-128r19 clause 2.1.3.
+
+ Header is magic(2) + version(1) + flags(1) + srs_id(4), then the envelope,
+ then the WKB.
+ """
+ indicator = 0 if envelope is None else 1
+ flags = (1 if little else 0) | (indicator << 1) | (0x10 if empty else 0)
+ e = "<" if little else ">"
+ header = b"GP" + bytes([0, flags]) + struct.pack(e + "i", srs_id)
+ assert len(header) == 8, "the GeoPackage header is 8 bytes before the envelope"
+ body = b""
+ if envelope is not None:
+ body = struct.pack(e + "dddd", *envelope)
+ assert len(body) == 32, "an xy envelope is four doubles"
+ return header + body + wkb
+
+
+SQUARE = [[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 0.0)]]
+
+
+def test_reads_a_plain_polygon():
+ geom = parse_geometry_blob(gpkg_blob(wkb_polygon(SQUARE)))
+ assert geom["type"] == "Polygon"
+ assert geom["coordinates"][0][0] == [0.0, 0.0]
+ assert geom["coordinates"][0][2] == [10.0, 10.0]
+ assert len(geom["coordinates"][0]) == 5
+
+
+def test_skips_the_envelope_when_one_is_present():
+ """The envelope sits between the header and the WKB and must be stepped over."""
+ with_env = parse_geometry_blob(
+ gpkg_blob(wkb_polygon(SQUARE), envelope=(0.0, 10.0, 0.0, 10.0))
+ )
+ without = parse_geometry_blob(gpkg_blob(wkb_polygon(SQUARE)))
+ assert with_env == without
+
+
+def test_reads_big_endian_geometry():
+ assert parse_geometry_blob(
+ gpkg_blob(wkb_polygon(SQUARE, little=False), little=False)
+ ) == parse_geometry_blob(gpkg_blob(wkb_polygon(SQUARE)))
+
+
+def test_reads_3d_polygons_by_stepping_the_z():
+ """Building footprints carry heights; the z must not shift the ring."""
+ ring = [[(0.0, 0.0, 12.5), (10.0, 0.0, 12.5), (10.0, 10.0, 12.5), (0.0, 0.0, 12.5)]]
+ geom = parse_geometry_blob(gpkg_blob(wkb_polygon(ring, z=True)))
+ assert geom["coordinates"][0] == [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 0.0]]
+
+
+def test_reads_a_multipolygon_as_several_rings():
+ other = [[(20.0, 20.0), (30.0, 20.0), (30.0, 30.0), (20.0, 20.0)]]
+ geom = parse_geometry_blob(gpkg_blob(wkb_multipolygon([SQUARE, other])))
+ assert geom["type"] == "MultiPolygon"
+ assert len(geom["coordinates"]) == 2
+
+
+def test_an_interior_ring_survives():
+ """A courtyard is a second ring, and dropping it would inflate the roof."""
+ hole = [(2.0, 2.0), (4.0, 2.0), (4.0, 4.0), (2.0, 2.0)]
+ geom = parse_geometry_blob(gpkg_blob(wkb_polygon(SQUARE + [hole])))
+ assert len(geom["coordinates"]) == 2
+
+
+def test_empty_geometry_is_none_not_an_error():
+ assert parse_geometry_blob(gpkg_blob(b"", empty=True)) is None
+
+
+def test_rejects_a_blob_that_is_not_a_geopackage_geometry():
+ with pytest.raises(GeoPackageError, match="magic"):
+ parse_geometry_blob(b"XX" + bytes(20))
+
+
+def test_refuses_extended_geometry_rather_than_guessing():
+ blob = bytearray(gpkg_blob(wkb_polygon(SQUARE)))
+ blob[3] |= 0x20
+ with pytest.raises(GeoPackageError, match="extended"):
+ parse_geometry_blob(bytes(blob))
+
+
+def test_refuses_a_non_polygon_rather_than_mis_clipping():
+ point = struct.pack("B", 1) + struct.pack("
Date: Wed, 5 Aug 2026 13:45:36 +0200
Subject: [PATCH 11/27] fix(roofmodel): one axis order for building rings,
wherever they came from
Rings read from a GeoPackage arrive x=easting first, the way GIS files
store them. Rings converted from inline WGS84 geometry were stored the
way wgs84_to_sweref99tm returns them: northing first. Everything
downstream assumed the second convention, so every GeoPackage-sourced
building -- the normal Lantmateriet case -- reported its centroid near
(4 N, 63 E) in the Indian Ocean, an 8 000 km "distance", and a picker
map with nothing visible on it.
The suite missed it because every assertion was axis-blind: areas and
SWEREF centroids survive a consistent swap. It surfaced the first time
the module ran against a server and a human looked at where the
buildings landed.
ring_sweref now always holds (easting, northing): rings from degrees are
swapped into it, and projected rings are normalised per point -- eastings
stay under a million metres and northings start above six, so each point
states its own order and an EPSG-registry-ordered export (north first) is
folded in rather than mis-read. The WGS84 accessors and the site distance
unpack accordingly. A regression test feeds the same square in both
orders and requires real Stockholm coordinates and a sub-50 m distance
back from each; the fixture files mis-unpacked the projection the same
way the code did, and now match reality (LAZ x is an easting).
Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
---
roofmodel/ftw_roofmodel/buildings.py | 39 +++++++++++++++++++---
roofmodel/tests/test_buildings.py | 42 ++++++++++++++++++++----
roofmodel/tests/test_derive_footprint.py | 2 +-
roofmodel/tests/test_source_formats.py | 2 +-
4 files changed, 71 insertions(+), 14 deletions(-)
diff --git a/roofmodel/ftw_roofmodel/buildings.py b/roofmodel/ftw_roofmodel/buildings.py
index 595a64f15..469e11278 100644
--- a/roofmodel/ftw_roofmodel/buildings.py
+++ b/roofmodel/ftw_roofmodel/buildings.py
@@ -82,13 +82,13 @@ def centroid_sweref(self) -> tuple[float, float]:
def centroid_wgs84(self) -> tuple[float, float]:
e, n = self.centroid_sweref()
- return sweref.sweref99tm_to_wgs84(e, n)
+ return sweref.sweref99tm_to_wgs84(n, e)
def ring_wgs84(self) -> list[list[float]]:
"""GeoJSON ring: [lon, lat] pairs, closed."""
out = []
for e, n in self.ring_sweref:
- lat, lon = sweref.sweref99tm_to_wgs84(e, n)
+ lat, lon = sweref.sweref99tm_to_wgs84(n, e)
out.append([round(lon, 7), round(lat, 7)])
if out and out[0] != out[-1]:
out.append(out[0])
@@ -177,11 +177,40 @@ def _rings_from_geometry(geometry: dict[str, Any]) -> list[list[tuple[float, flo
return [r for r in rings if len(r) >= 3]
+def _normalize_sweref_ring(
+ ring: list[tuple[float, float]],
+) -> list[tuple[float, float]]:
+ """Force a projected ring into (easting, northing) order.
+
+ GIS files conventionally store x=easting first, but EPSG:3006 formally
+ declares north-first and some exports follow the registry. The two ranges
+ cannot collide -- eastings stay under a million metres, northings start
+ above six million -- so each point states its own order.
+ """
+ out = []
+ for point in ring:
+ x, y = float(point[0]), float(point[1])
+ if x > 1_000_000.0 and y < 1_000_000.0:
+ x, y = y, x
+ out.append((x, y))
+ return out
+
+
def _to_sweref(ring: list[tuple[float, float]]) -> list[tuple[float, float]]:
+ """Any ring -> (easting, northing) SWEREF, the order ring_sweref documents.
+
+ Both sources must land in the same order: the LiDAR clip compares these
+ rings against point coordinates, and wgs84_to_sweref99tm returns
+ (northing, easting) -- stored unswapped it would put every inline-geometry
+ building's clip window sideways.
+ """
if _looks_like_sweref(ring):
- return ring
+ return _normalize_sweref_ring(ring)
# GeoJSON order is [lon, lat].
- return [sweref.wgs84_to_sweref99tm(lat, lon) for lon, lat in ring]
+ return [
+ (e, n)
+ for n, e in (sweref.wgs84_to_sweref99tm(lat, lon) for lon, lat in ring)
+ ]
def _features_from_item(item: StacItem, client: GeotorgetClient | None = None) -> list[dict[str, Any]]:
@@ -238,7 +267,7 @@ def buildings_from_features(
fallback_id: str = "building",
) -> list[Building]:
"""Turn GeoJSON-ish features into ranked Building candidates."""
- site_e, site_n = sweref.wgs84_to_sweref99tm(latitude, longitude)
+ site_n, site_e = sweref.wgs84_to_sweref99tm(latitude, longitude)
out: list[Building] = []
for i, feat in enumerate(features):
for j, ring in enumerate(_rings_from_geometry(feat.get("geometry") or {})):
diff --git a/roofmodel/tests/test_buildings.py b/roofmodel/tests/test_buildings.py
index 27205ab9f..62232d396 100644
--- a/roofmodel/tests/test_buildings.py
+++ b/roofmodel/tests/test_buildings.py
@@ -72,11 +72,11 @@ def test_area_and_centroid_of_a_known_square():
def test_wgs84_rings_are_projected_before_measuring():
"""A ring in degrees must be recognised and converted, not measured raw."""
lat, lon = STOCKHOLM
- e, n = sweref.wgs84_to_sweref99tm(lat, lon)
+ n, e = sweref.wgs84_to_sweref99tm(lat, lon)
ring_sweref = square_ring(e, n, 12.0)
ring_wgs84 = []
for x, y in ring_sweref:
- blat, blon = sweref.sweref99tm_to_wgs84(x, y)
+ blat, blon = sweref.sweref99tm_to_wgs84(y, x) # ring is (E, N)
ring_wgs84.append([blon, blat]) # GeoJSON is [lon, lat]
[b] = buildings_from_features(
@@ -89,7 +89,7 @@ def test_wgs84_rings_are_projected_before_measuring():
def test_tiles_and_slivers_are_not_offered_as_buildings():
lat, lon = STOCKHOLM
- e, n = sweref.wgs84_to_sweref99tm(lat, lon)
+ n, e = sweref.wgs84_to_sweref99tm(lat, lon)
feats = [
{"geometry": {"type": "Polygon", "coordinates": [square_ring(e, n, 2500.0)]}, "id": "tile"},
{"geometry": {"type": "Polygon", "coordinates": [square_ring(e, n, 1.0)]}, "id": "sliver"},
@@ -101,7 +101,7 @@ def test_tiles_and_slivers_are_not_offered_as_buildings():
def test_candidates_come_back_nearest_first():
lat, lon = STOCKHOLM
- e, n = sweref.wgs84_to_sweref99tm(lat, lon)
+ n, e = sweref.wgs84_to_sweref99tm(lat, lon)
feats = [
{"geometry": {"type": "Polygon", "coordinates": [square_ring(e + 60, n, 10.0)]}, "id": "far"},
{"geometry": {"type": "Polygon", "coordinates": [square_ring(e + 5, n, 10.0)]}, "id": "near"},
@@ -114,7 +114,7 @@ def test_candidates_come_back_nearest_first():
def test_multipolygon_yields_one_candidate_per_part():
lat, lon = STOCKHOLM
- e, n = sweref.wgs84_to_sweref99tm(lat, lon)
+ n, e = sweref.wgs84_to_sweref99tm(lat, lon)
feat = {
"id": "pair",
"geometry": {
@@ -129,7 +129,7 @@ def test_multipolygon_yields_one_candidate_per_part():
def test_search_queries_the_building_collection_and_maps_results():
lat, lon = STOCKHOLM
- e, n = sweref.wgs84_to_sweref99tm(lat, lon)
+ n, e = sweref.wgs84_to_sweref99tm(lat, lon)
session = FakeSession({"features": [stac_feature(square_ring(e, n, 10.0), "b1")]})
client = GeotorgetClient(Credentials("u", "t"), session=session)
@@ -194,7 +194,7 @@ def test_clip_of_an_empty_cloud_is_empty_not_an_error():
def test_geojson_feature_is_wgs84_and_closed():
lat, lon = STOCKHOLM
- e, n = sweref.wgs84_to_sweref99tm(lat, lon)
+ n, e = sweref.wgs84_to_sweref99tm(lat, lon)
b = Building("b1", square_ring(e, n, 10.0), 100.0, 0.0)
feat = b.to_geojson()
@@ -204,3 +204,31 @@ def test_geojson_feature_is_wgs84_and_closed():
assert -180 <= x <= 180 and -90 <= y <= 90
assert feat["properties"]["latitude"] == pytest.approx(lat, abs=1e-3)
assert feat["properties"]["longitude"] == pytest.approx(lon, abs=1e-3)
+
+
+def test_both_ring_orders_come_back_at_the_real_site():
+ """Axis order must not depend on where the ring came from.
+
+ A GeoPackage stores x=easting first; wgs84_to_sweref99tm returns northing
+ first; EPSG's registry declares EPSG:3006 north-first and some exports
+ follow it. The first mismatch shipped: GeoPackage-sourced buildings — the
+ normal Lantmäteriet case — reported their centroids in the Indian Ocean
+ (lat ≈ 4°) with 8 000 km distances, while every test asserted only areas
+ and SWEREF centroids, both of which are blind to a consistent swap.
+ """
+ lat, lon = STOCKHOLM
+ n, e = sweref.wgs84_to_sweref99tm(lat, lon)
+ ring_en = square_ring(e, n, 10.0) # as a GeoPackage stores it
+ ring_ne = [(y, x) for x, y in ring_en] # as the EPSG registry says
+
+ for ring in (ring_en, ring_ne):
+ [b] = buildings_from_features(
+ [{"geometry": {"type": "Polygon", "coordinates": [ring]}, "id": "b"}],
+ latitude=lat, longitude=lon,
+ )
+ feat = b.to_geojson()
+ assert feat["properties"]["latitude"] == pytest.approx(lat, abs=1e-3)
+ assert feat["properties"]["longitude"] == pytest.approx(lon, abs=1e-3)
+ assert b.distance_m < 50.0, (
+ f"a building drawn around the site is {b.distance_m:.0f} m away"
+ )
diff --git a/roofmodel/tests/test_derive_footprint.py b/roofmodel/tests/test_derive_footprint.py
index 3932e7ef8..5489b2a70 100644
--- a/roofmodel/tests/test_derive_footprint.py
+++ b/roofmodel/tests/test_derive_footprint.py
@@ -59,7 +59,7 @@ def scene(monkeypatch):
so it extends across the whole tile and the two buildings compete for each
other's returns unless the cloud is clipped first.
"""
- e, n = sweref.wgs84_to_sweref99tm(*STOCKHOLM)
+ n, e = sweref.wgs84_to_sweref99tm(*STOCKHOLM)
mine = np.vstack([
roof_face(35, 180, 12, 6, (e, n, 0), seed=2),
roof_face(35, 0, 12, 6, (e, n + 6, 4.2), seed=3),
diff --git a/roofmodel/tests/test_source_formats.py b/roofmodel/tests/test_source_formats.py
index 1bcd434c3..e33b6d929 100644
--- a/roofmodel/tests/test_source_formats.py
+++ b/roofmodel/tests/test_source_formats.py
@@ -30,7 +30,7 @@
from .test_geopackage import build_gpkg, gpkg_blob, wkb_polygon
STOCKHOLM = (59.33, 18.07)
-E, N = sweref.wgs84_to_sweref99tm(*STOCKHOLM)
+N, E = sweref.wgs84_to_sweref99tm(*STOCKHOLM)
def square(cx, cy, w, d):
From b94672b67dbce7e1a81fd4fb9c7a208d9508ef24 Mon Sep 17 00:00:00 2001
From: Claude Fable 5
Date: Wed, 5 Aug 2026 14:37:42 +0200
Subject: [PATCH 12/27] fix(web): help bubbles survive quotes and the bottom of
the modal
The roofmodel section's help text ends with two quoted product names, and
the bubble cut off mid-sentence right before them. escHtml used the
textContent/innerHTML trick, which never escapes quotes -- so every
caller that builds an attribute (data-help, title, value) had its text
terminated at the first embedded quote, with the remainder parsed as
junk attribute names. Plain string replaces now cover both quote kinds.
With the full text rendering, the second failure appeared: the bubble
opens downward and the modal body is the scroll container that clips
it, so a badge low in the last section has less room below than a long
help text needs. settings.js now toggles .help-up on hover when the
badge sits in the lower part of the visible modal, and the bubble grows
upward into room that exists. Measured on hover because scroll
position, not the badge, decides which way is open.
Verified in headless Edge: the full text renders at both scroll
positions, upward when low, downward when high.
Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
---
web/settings.js | 32 +++++++++++++++++++++++++++++---
web/style.css | 10 ++++++++++
2 files changed, 39 insertions(+), 3 deletions(-)
diff --git a/web/settings.js b/web/settings.js
index cce548233..b3b29ef65 100644
--- a/web/settings.js
+++ b/web/settings.js
@@ -65,6 +65,24 @@
if (e.target === modal) modal.classList.add("hidden");
});
+ // The help bubble is pure CSS and normally opens downward, but the modal
+ // body is the scroll container that clips it: a badge in the last section
+ // has less room below it than a long help text needs. Flip the bubble
+ // upward when the badge sits in the lower part of the visible modal, so it
+ // grows into space that exists. Measured on hover because scroll position,
+ // not the badge, decides which way is open.
+ modal.addEventListener("mouseover", function (e) {
+ var badge = e.target && e.target.closest && e.target.closest(".help");
+ if (!badge) return;
+ var scroller = badge.closest(".modal-body");
+ if (!scroller) return;
+ var box = scroller.getBoundingClientRect();
+ var at = badge.getBoundingClientRect();
+ badge.classList.toggle(
+ "help-up", at.top + at.height / 2 > box.top + box.height * 0.55
+ );
+ });
+
tabsEl.addEventListener("click", function (e) {
if (e.target.tagName === "BUTTON" && e.target.dataset.tab) {
tabsEl.querySelectorAll("button").forEach(function (b) {
@@ -263,9 +281,17 @@
}
function escHtml(s) {
- var div = document.createElement("div");
- div.textContent = s == null ? "" : String(s);
- return div.innerHTML;
+ // Plain string replaces rather than the textContent/innerHTML trick: that
+ // trick never escapes quotes, and every caller that builds an attribute
+ // (value="...", title="...", data-help="...") has its value cut short at
+ // the first embedded quote — the rest of the text silently becomes junk
+ // attribute names.
+ return String(s == null ? "" : s)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
}
function renderTab(tab) {
diff --git a/web/style.css b/web/style.css
index df0e04bf6..037f82aa9 100644
--- a/web/style.css
+++ b/web/style.css
@@ -1969,6 +1969,16 @@ footer {
position: absolute;
left: 20px;
top: -4px;
+}
+/* Flipped variant: settings.js adds .help-up when the badge sits low in the
+ scrollable modal body, so the bubble grows into room that exists instead
+ of into the clip edge below. */
+.help.help-up:hover::after {
+ top: auto;
+ bottom: -4px;
+}
+.help:hover::after,
+.help.help-up:hover::after {
background: var(--ink-elevated);
border: 1px solid var(--line);
border-radius: 4px;
From ad5f86eff0f3259435b58db7cde99018810ca76c Mon Sep 17 00:00:00 2001
From: Fredrik Ahlgren
Date: Wed, 19 Aug 2026 14:48:01 +0200
Subject: [PATCH 13/27] fix(roofmodel): emit rated watts after si-core-units
Derived arrays pre-fill weather.pv_arrays, which now stores rated_w.
The Python module and Go host speak watts; kWp stays a test helper.
---
go/internal/roofmodel/roofmodel.go | 4 ++--
go/internal/roofmodel/roofmodel_test.go | 8 ++++----
go/internal/units/consistency_test.go | 12 ++++++++++++
roofmodel/ftw_roofmodel/pipeline.py | 6 +++---
roofmodel/ftw_roofmodel/segment.py | 12 ++++++++++--
roofmodel/tests/test_pipeline.py | 6 +++---
web/settings/tabs/weather.js | 6 +++++-
7 files changed, 39 insertions(+), 15 deletions(-)
diff --git a/go/internal/roofmodel/roofmodel.go b/go/internal/roofmodel/roofmodel.go
index 087b6504a..4ef5bb6ee 100644
--- a/go/internal/roofmodel/roofmodel.go
+++ b/go/internal/roofmodel/roofmodel.go
@@ -53,7 +53,7 @@ const (
// the document can pre-fill weather.pv_arrays directly.
type Array struct {
Name string `json:"name"`
- KWp float64 `json:"kwp"`
+ RatedW float64 `json:"rated_w"`
TiltDeg float64 `json:"tilt_deg"`
AzimuthDeg float64 `json:"azimuth_deg"`
AreaM2 float64 `json:"area_m2"`
@@ -285,7 +285,7 @@ func (m *Model) ToPVArrays() []config.PVArray {
tiltDeg, azimuthDeg := a.TiltDeg, a.AzimuthDeg
out = append(out, config.PVArray{
Name: a.Name,
- KWp: a.KWp,
+ RatedW: a.RatedW,
TiltDeg: &tiltDeg,
AzimuthDeg: &azimuthDeg,
})
diff --git a/go/internal/roofmodel/roofmodel_test.go b/go/internal/roofmodel/roofmodel_test.go
index 97398a798..3e70c7292 100644
--- a/go/internal/roofmodel/roofmodel_test.go
+++ b/go/internal/roofmodel/roofmodel_test.go
@@ -208,7 +208,7 @@ func TestDeriveParsesAModel(t *testing.T) {
doc := `{"schema_version":1,"planes_found":3,` +
`"site":{"latitude":59.33,"longitude":18.07,"radius_m":40},` +
`"source":{"provider":"lantmateriet","item_count":2,"dataset_datetime":"2018-03-01T00:00:00+00:00"},` +
- `"arrays":[{"name":"Roof south","kwp":7.2,"tilt_deg":35,"azimuth_deg":180,"area_m2":51.4,"segment_id":"seg-0"}],` +
+ `"arrays":[{"name":"Roof south","rated_w":7200,"tilt_deg":35,"azimuth_deg":180,"area_m2":51.4,"segment_id":"seg-0"}],` +
`"captured_at_ms":1519862400000,"derived_at_ms":1785456000000}`
cmd := stubModule(t, "stdout", doc)
s := svc(t, &config.RoofModel{Enabled: true, Command: cmd, GeotorgetUsername: "u", GeotorgetToken: "t"})
@@ -482,8 +482,8 @@ func TestDeriveIsTimeBoxed(t *testing.T) {
func TestToPVArraysMatchesConfigShape(t *testing.T) {
m := &Model{Arrays: []Array{
- {Name: "Roof south", KWp: 7.2, TiltDeg: 35, AzimuthDeg: 180, AreaM2: 51.4},
- {Name: "Roof west", KWp: 4.1, TiltDeg: 35, AzimuthDeg: 270, AreaM2: 29.3},
+ {Name: "Roof south", RatedW: 7200, TiltDeg: 35, AzimuthDeg: 180, AreaM2: 51.4},
+ {Name: "Roof west", RatedW: 4100, TiltDeg: 35, AzimuthDeg: 270, AreaM2: 29.3},
}}
got := m.ToPVArrays()
if len(got) != 2 {
@@ -492,7 +492,7 @@ func TestToPVArraysMatchesConfigShape(t *testing.T) {
if got[0].TiltDeg == nil || got[0].AzimuthDeg == nil {
t.Fatalf("derived array must carry both angles, got %+v", got[0])
}
- if got[0].Name != "Roof south" || got[0].KWp != 7.2 ||
+ if got[0].Name != "Roof south" || got[0].RatedW != 7200 ||
*got[0].TiltDeg != 35 || *got[0].AzimuthDeg != 180 {
t.Errorf("array 0 = %+v tilt=%v az=%v", got[0], *got[0].TiltDeg, *got[0].AzimuthDeg)
}
diff --git a/go/internal/units/consistency_test.go b/go/internal/units/consistency_test.go
index 053c1e6b5..c0e67f124 100644
--- a/go/internal/units/consistency_test.go
+++ b/go/internal/units/consistency_test.go
@@ -11,6 +11,7 @@ import (
"github.com/srcfl/ftw/go/internal/loadpoint"
"github.com/srcfl/ftw/go/internal/mpc"
"github.com/srcfl/ftw/go/internal/pvperf"
+ "github.com/srcfl/ftw/go/internal/roofmodel"
"github.com/srcfl/ftw/go/internal/telemetry"
"github.com/srcfl/ftw/go/internal/units"
"github.com/srcfl/ftw/go/internal/v2x"
@@ -54,6 +55,16 @@ func TestPVPerfArrayHasNoKWp(t *testing.T) {
}
}
+func TestRoofmodelArrayHasNoKWp(t *testing.T) {
+ typ := reflect.TypeOf(roofmodel.Array{})
+ if _, ok := typ.FieldByName("KWp"); ok {
+ t.Fatal("roofmodel.Array must not have KWp; store RatedW")
+ }
+ if _, ok := typ.FieldByName("RatedW"); !ok {
+ t.Fatal("roofmodel.Array must store RatedW (watts)")
+ }
+}
+
func TestMPCParamsSoCIsFraction(t *testing.T) {
typ := reflect.TypeOf(mpc.Params{})
for _, banned := range []string{"SoCMinPct", "SoCMaxPct", "InitialSoCPct"} {
@@ -255,6 +266,7 @@ func TestCoreBannedSoCPercentFieldNames(t *testing.T) {
reflect.TypeOf(mpc.SlotDirective{}),
reflect.TypeOf(forecast.Array{}),
reflect.TypeOf(pvperf.Array{}),
+ reflect.TypeOf(roofmodel.Array{}),
}
banned := []string{"CurrentSoCPct", "TargetSoCPct", "PluginSoCPct", "VehicleSoCPct", "SoCPct", "SoCMinPct", "SoCMaxPct", "SoCTargetPct", "LivePVSurplusSoCCapPct", "LoadpointSoCTargetPct", "KWp"}
for _, typ := range types {
diff --git a/roofmodel/ftw_roofmodel/pipeline.py b/roofmodel/ftw_roofmodel/pipeline.py
index 85f78b48a..639d9b657 100644
--- a/roofmodel/ftw_roofmodel/pipeline.py
+++ b/roofmodel/ftw_roofmodel/pipeline.py
@@ -64,7 +64,7 @@ class RoofModelError(RuntimeError):
@dataclasses.dataclass
class DerivedArray:
name: str
- kwp: float
+ rated_w: float
tilt_deg: float
azimuth_deg: float
area_m2: float
@@ -73,7 +73,7 @@ class DerivedArray:
def to_json(self) -> dict[str, Any]:
return {
"name": self.name,
- "kwp": round(self.kwp, 2),
+ "rated_w": round(self.rated_w),
"tilt_deg": round(self.tilt_deg, 1),
"azimuth_deg": round(self.azimuth_deg, 1),
"area_m2": round(self.area_m2, 1),
@@ -122,7 +122,7 @@ def planes_to_arrays(
arrays.append(
DerivedArray(
name=name,
- kwp=plane.kwp(packing_factor, module_w_per_m2),
+ rated_w=plane.rated_w(packing_factor, module_w_per_m2),
tilt_deg=plane.tilt_deg,
azimuth_deg=plane.azimuth_deg,
area_m2=plane.area_m2,
diff --git a/roofmodel/ftw_roofmodel/segment.py b/roofmodel/ftw_roofmodel/segment.py
index 33d6a13da..5a2bc0a11 100644
--- a/roofmodel/ftw_roofmodel/segment.py
+++ b/roofmodel/ftw_roofmodel/segment.py
@@ -53,13 +53,21 @@ class RoofPlane:
point_count: int
mean_height_m: float
+ def rated_w(
+ self,
+ packing_factor: float = DEFAULT_PACKING_FACTOR,
+ module_w_per_m2: float = DEFAULT_MODULE_W_PER_M2,
+ ) -> float:
+ """Installable DC capacity for this surface, in watts."""
+ return self.area_m2 * packing_factor * module_w_per_m2
+
def kwp(
self,
packing_factor: float = DEFAULT_PACKING_FACTOR,
module_w_per_m2: float = DEFAULT_MODULE_W_PER_M2,
) -> float:
- """Installable DC capacity for this surface, in kWp."""
- return self.area_m2 * packing_factor * module_w_per_m2 / 1000.0
+ """Installable DC capacity for this surface, in kWp (test helper)."""
+ return self.rated_w(packing_factor, module_w_per_m2) / 1000.0
def _fit_plane(points: np.ndarray) -> np.ndarray:
diff --git a/roofmodel/tests/test_pipeline.py b/roofmodel/tests/test_pipeline.py
index c597a4a20..570071001 100644
--- a/roofmodel/tests/test_pipeline.py
+++ b/roofmodel/tests/test_pipeline.py
@@ -188,8 +188,8 @@ def test_array_json_matches_the_config_field_names():
"""The document pre-fills weather.pv_arrays, so the keys must line up."""
planes = [RoofPlane(tilt_deg=35, azimuth_deg=180, area_m2=60, point_count=200, mean_height_m=6)]
payload = planes_to_arrays(planes)[0].to_json()
- assert set(payload) >= {"name", "kwp", "tilt_deg", "azimuth_deg"}
- assert payload["kwp"] > 0
+ assert set(payload) >= {"name", "rated_w", "tilt_deg", "azimuth_deg"}
+ assert payload["rated_w"] > 0
# --- end to end ------------------------------------------------------------
@@ -221,7 +221,7 @@ def test_derive_produces_a_versioned_document(monkeypatch):
south = model["arrays"][0]
assert south["azimuth_deg"] == pytest.approx(180.0, abs=3.0)
assert south["tilt_deg"] == pytest.approx(35.0, abs=3.0)
- assert south["kwp"] > 0
+ assert south["rated_w"] > 0
# Must be JSON-serialisable for the subprocess contract.
json.dumps(model)
diff --git a/web/settings/tabs/weather.js b/web/settings/tabs/weather.js
index 58484a7f7..05a8b7446 100644
--- a/web/settings/tabs/weather.js
+++ b/web/settings/tabs/weather.js
@@ -494,8 +494,12 @@
// Fill the form rather than saving: the operator sees the numbers and
// presses Save, so the panel config never changes behind their back.
ctx.config.weather.pv_arrays = arrays.map(function (a) {
+ var rated = Number(a.rated_w);
+ if (!(rated > 0) && Number(a.kwp) > 0) {
+ rated = ratedWattsFromLegacyKwp(a.kwp);
+ }
return {
- name: a.name || "", kwp: a.kwp,
+ name: a.name || "", rated_w: rated || 0,
tilt_deg: a.tilt_deg, azimuth_deg: a.azimuth_deg,
};
});
From 7cc0df93b9d1c25a8a7977e2f8a4d16f3f32ae77 Mon Sep 17 00:00:00 2001
From: Claude Fable 5
Date: Sat, 29 Aug 2026 18:16:30 +0200
Subject: [PATCH 14/27] feat(roofmodel): Basic-auth credentials,
catalog-agnostic STAC access
Lantmateriet provides no OAuth for its STAC download APIs, so the
credential is the operator's own Geotorget account username and password,
sent as HTTP Basic auth. The config keys become roofmodel.stac_username /
stac_password; the old geotorget_username/geotorget_token keys keep
working as aliases and migrate on the next save through the API.
While renaming the keys, the client sheds its Lantmateriet hardcodes:
stac_base_url, stac_buildings_collection, stac_lidar_collection and
stac_bbox_epsg point FTW at any STAC-conformant catalog (search is the
spec's POST {base}/search; the bbox CRS is per-catalog, 4326 per spec,
3006 for Lantmateriet). A custom catalog lifts the Sweden-only gate.
Point-cloud data must still arrive in SWEREF 99 TM metres.
Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
---
.changeset/roofmodel-stac-basic-auth.md | 15 +++
docs/roof-geometry.md | 46 ++++++-
go/internal/api/api.go | 6 +-
go/internal/api/api_roofmodel_test.go | 8 +-
go/internal/config/config.go | 75 +++++++++--
go/internal/config/roofmodel_secrets_test.go | 126 +++++++++++++------
go/internal/roofmodel/roofmodel.go | 25 +++-
go/internal/roofmodel/roofmodel_test.go | 126 ++++++++++++++++---
roofmodel/ftw_roofmodel/__main__.py | 51 +++++++-
roofmodel/ftw_roofmodel/buildings.py | 7 +-
roofmodel/ftw_roofmodel/geotorget.py | 67 ++++++----
roofmodel/ftw_roofmodel/pipeline.py | 28 +++--
roofmodel/ftw_roofmodel/sweref.py | 18 +++
roofmodel/tests/test_pipeline.py | 14 +++
roofmodel/tests/test_sweref.py | 25 ++++
web/settings/tabs/weather.js | 15 ++-
16 files changed, 528 insertions(+), 124 deletions(-)
create mode 100644 .changeset/roofmodel-stac-basic-auth.md
diff --git a/.changeset/roofmodel-stac-basic-auth.md b/.changeset/roofmodel-stac-basic-auth.md
new file mode 100644
index 000000000..7bd1bd2b4
--- /dev/null
+++ b/.changeset/roofmodel-stac-basic-auth.md
@@ -0,0 +1,15 @@
+---
+"ftw": minor
+---
+
+Roof-geometry credentials are now the Geotorget account username and password,
+sent as HTTP Basic auth — Lantmäteriet provides no OAuth for its STAC download
+APIs, so there is no issued token to paste. Configs that stored the secret
+under the old `geotorget_token` key keep working and migrate to
+`roofmodel.stac_password` on their next save.
+
+The STAC client is catalog-agnostic while it is at it: `stac_base_url`,
+`stac_buildings_collection`, `stac_lidar_collection` and `stac_bbox_epsg`
+point FTW at any STAC-conformant catalog (search is the spec's
+`POST {base}/search`), with Lantmäteriet as the default. A custom catalog also
+lifts the Sweden-only coordinate gate.
diff --git a/docs/roof-geometry.md b/docs/roof-geometry.md
index f6d90d10a..9c9bd1d5c 100644
--- a/docs/roof-geometry.md
+++ b/docs/roof-geometry.md
@@ -4,9 +4,10 @@ FTW's PV forecast needs the tilt and azimuth of each roof face. Typing them in
means measuring your own roof, and most people estimate. In Sweden the state
already flew a laser over it, so FTW can read the numbers instead.
-This is **optional and Sweden-only**. Everywhere else, and whenever anything
-below is missing, the numeric fields in **Settings → Weather → PV arrays** stay
-the way they work today.
+This is **optional, and Sweden-only by default** (any standard STAC catalog
+can stand in — see [Other countries, other catalogs](#other-countries-other-catalogs)).
+Everywhere else, and whenever anything below is missing, the numeric fields in
+**Settings → Weather → PV arrays** stay the way they work today.
## What you need
@@ -24,6 +25,12 @@ both and FTW searches them the same way. They differ only in what the items
point at, and FTW picks the right asset by its declared media type rather than
by its name — a catalogue that renames `data` to `punktmoln` keeps working.
+Authentication is **HTTP Basic with your Geotorget account username and
+password**. Lantmäteriet provides no OAuth for these STAC APIs, so there is no
+issued token to paste — the account credential itself is what the catalog
+accepts. FTW stores the password like every other secret: it is written to the
+config file on the host, masked in every API response, and never logged.
+
Ordering access is not instant — Lantmäteriet approves it — so do it before you
plan to use this.
@@ -38,6 +45,35 @@ point cloud works, which is enough to run the tests but not enough to derive a
real roof. GeoPackage needs nothing extra: it is a SQLite file, and FTW reads it
with the standard library.
+## Other countries, other catalogs
+
+The client speaks plain [STAC](https://stacspec.org/) — search is the spec's
+`POST {base}/search`, downloads follow asset hrefs, and authentication is
+ordinary HTTP Basic. Lantmäteriet is only the default. If another country
+publishes building footprints and LiDAR through a STAC API, point FTW at it:
+
+```yaml
+roofmodel:
+ enabled: true
+ stac_base_url: https://stac.example.org # STAC API root
+ stac_buildings_collection: buildings-vector # footprint polygons
+ stac_lidar_collection: lidar-pointcloud # LAZ/COPC point clouds
+ stac_bbox_epsg: 4326 # the spec's WGS84; 3006 = SWEREF
+ stac_username: you # omit both for an open catalog…
+ stac_password: "…" # …that needs no login
+```
+
+Setting `stac_base_url` also lifts the Sweden-only coordinate gate, since FTW
+cannot know what a third-party catalog covers.
+
+Two caveats. The search bbox CRS is per-catalog: the STAC spec mandates WGS84
+(`stac_bbox_epsg: 4326`), but Lantmäteriet expects SWEREF 99 TM, which is why
+the default stays `3006`. And the *data* itself must arrive in SWEREF 99 TM
+metres for now — the plane fitting works in that frame — so a foreign catalog
+needs its point clouds delivered in a matching projected CRS before the derived
+tilt/azimuth mean anything. Lifting that second limit means carrying a real
+projection library, which the module has so far deliberately avoided.
+
### Why picking a building also makes it fast
COPC — Cloud Optimized Point Cloud — is LAZ with the points ordered into an
@@ -57,7 +93,7 @@ tile whole — slower, same answer. The result records which path ran as
1. Open **Settings → Weather**.
2. Put the map marker on your building.
3. Under **Roof geometry from Lantmäteriet**, tick **Enable roof derivation**,
- enter your Geotorget username and token, and **Save**.
+ enter your Geotorget username and password, and **Save**.
4. Press **Find buildings here**. Footprints appear on the map and as a list.
5. Click your building. It highlights green.
6. Press **Read roof from LiDAR**.
@@ -123,7 +159,7 @@ is clear" are different claims.
| What you see | What it means |
|---|---|
| "Roof derivation is off" | Tick the box, add credentials, Save, retry |
-| "Geotorget rejected the credentials" | Wrong token, or the account has not been granted that product |
+| "the STAC catalog rejected the credentials" | Wrong username or password, or the account has not been granted that product |
| "No buildings found here" | The marker is not on a building, or you are outside Sweden |
| "only N LiDAR returns fall on building" | The building is newer than the scan, or you picked the wrong footprint |
| "No roof faces worth mounting panels on" | Everything found was north-facing or under 8 m² |
diff --git a/go/internal/api/api.go b/go/internal/api/api.go
index 06c59c175..84c21fb58 100644
--- a/go/internal/api/api.go
+++ b/go/internal/api/api.go
@@ -2545,8 +2545,8 @@ func roofModelErrorStatus(err error) int {
return 502
}
-// roofModelHasCredentials reports whether a Geotorget token is stored, without
-// revealing it.
+// roofModelHasCredentials reports whether STAC catalog credentials are
+// stored, without revealing them.
func (s *Server) roofModelHasCredentials() bool {
if s.deps.CfgMu == nil {
return false
@@ -2557,7 +2557,7 @@ func (s *Server) roofModelHasCredentials() bool {
return false
}
rm := s.deps.Cfg.RoofModel
- return rm.GeotorgetUsername != "" && rm.GeotorgetToken != ""
+ return rm.StacUser() != "" && rm.StacPass() != ""
}
// siteLocation returns the configured site coordinates.
diff --git a/go/internal/api/api_roofmodel_test.go b/go/internal/api/api_roofmodel_test.go
index 8e65f8eac..e24c9ff2b 100644
--- a/go/internal/api/api_roofmodel_test.go
+++ b/go/internal/api/api_roofmodel_test.go
@@ -101,7 +101,7 @@ func TestRoofModelBuildingsAcceptsAnExplicitCoordinate(t *testing.T) {
deps := depsAt(59.33, 18.07)
deps.RoofModel = roofmodel.FromConfig(&config.RoofModel{
Enabled: true, Command: "definitely-not-a-real-command",
- GeotorgetUsername: "u", GeotorgetToken: "t",
+ StacUsername: "u", StacPassword: "p",
})
// Berlin is outside Lantmateriet coverage; if the query coordinate were
@@ -123,8 +123,10 @@ func TestRoofModelBuildingsAcceptsAnExplicitCoordinate(t *testing.T) {
}
}
-// The Geotorget token is the operator's credential. Status may be reported;
-// the secret itself must never appear in a response.
+// The catalog password is the operator's credential. Status may be reported;
+// the secret itself must never appear in a response. This test deliberately
+// stores it under the legacy geotorget_token key: a config written before the
+// basic-auth redesign must stay every bit as private.
func TestRoofModelNeverEchoesTheToken(t *testing.T) {
deps := depsAt(59.33, 18.07)
deps.Cfg.RoofModel = &config.RoofModel{
diff --git a/go/internal/config/config.go b/go/internal/config/config.go
index 6837a9959..3038c427d 100644
--- a/go/internal/config/config.go
+++ b/go/internal/config/config.go
@@ -1448,20 +1448,48 @@ type Price struct {
// and its output only ever pre-fills the editable weather.pv_arrays. Absent or
// disabled, everything else behaves normally.
//
-// GeotorgetToken is the operator's own credential. It is redacted in API
-// responses by the existing sensitive-key rule (any key containing "token").
+// StacPassword (and its legacy alias GeotorgetToken) is the operator's own
+// credential. It is redacted in API responses.
type RoofModel struct {
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
// Command is the interpreter used for the module; defaults to "python3".
Command string `yaml:"command,omitempty" json:"command,omitempty"`
ModuleDir string `yaml:"module_dir,omitempty" json:"module_dir,omitempty"`
+ // StacUsername/StacPassword authenticate against the STAC catalog as HTTP
+ // Basic auth. Lantmäteriet provides no OAuth for its STAC download APIs,
+ // so for the default Geotorget catalog these are the operator's own
+ // account username and password.
+ StacUsername string `yaml:"stac_username,omitempty" json:"stac_username,omitempty"`
+ StacPassword string `yaml:"stac_password,omitempty" json:"stac_password,omitempty"`
+ // HasStacPassword is set only on the masked copy the API returns, so the
+ // UI can show that a password is stored without ever receiving it. Never
+ // written to YAML and never read from an incoming config.
+ HasStacPassword bool `yaml:"-" json:"has_stac_password,omitempty"`
+
+ // GeotorgetUsername/GeotorgetToken are legacy aliases from when the
+ // credential was assumed to be an issued token rather than the account
+ // password. Still read and still masked; the stac_* keys win if both are
+ // set, and a config saved through the API migrates to the stac_* keys.
GeotorgetUsername string `yaml:"geotorget_username,omitempty" json:"geotorget_username,omitempty"`
GeotorgetToken string `yaml:"geotorget_token,omitempty" json:"geotorget_token,omitempty"`
- // HasGeotorgetToken is set only on the masked copy the API returns, so the
- // UI can show that a token is stored without ever receiving it. Never
- // written to YAML and never read from an incoming config.
- HasGeotorgetToken bool `yaml:"-" json:"has_geotorget_token,omitempty"`
+
+ // StacBaseURL is the root of a STAC API; empty means Lantmäteriet's
+ // catalog. The search and download protocol is standard STAC, so any
+ // catalog that publishes building footprints and LiDAR behind Basic auth
+ // (or none) can be pointed at. Setting a custom URL also lifts the
+ // Sweden-only coverage gate, since FTW cannot know what a third-party
+ // catalog covers.
+ StacBaseURL string `yaml:"stac_base_url,omitempty" json:"stac_base_url,omitempty"`
+ // StacBuildingsCollection / StacLidarCollection name the catalog's
+ // building-footprint and LiDAR collections; empty means the two Geotorget
+ // products.
+ StacBuildingsCollection string `yaml:"stac_buildings_collection,omitempty" json:"stac_buildings_collection,omitempty"`
+ StacLidarCollection string `yaml:"stac_lidar_collection,omitempty" json:"stac_lidar_collection,omitempty"`
+ // StacBboxEPSG is the CRS of the bbox sent to STAC search. The STAC spec
+ // mandates WGS84 (4326), but Lantmäteriet's catalog expects SWEREF 99 TM
+ // (3006), which is the default here.
+ StacBboxEPSG int `yaml:"stac_bbox_epsg,omitempty" json:"stac_bbox_epsg,omitempty"`
// RadiusM is how far around the site to pull LiDAR (default 40 m).
RadiusM float64 `yaml:"radius_m,omitempty" json:"radius_m,omitempty"`
@@ -1473,6 +1501,24 @@ type RoofModel struct {
TimeoutS int `yaml:"timeout_s,omitempty" json:"timeout_s,omitempty"`
}
+// StacUser returns the catalog username, whichever key it was configured
+// under.
+func (r *RoofModel) StacUser() string {
+ if r.StacUsername != "" {
+ return r.StacUsername
+ }
+ return r.GeotorgetUsername
+}
+
+// StacPass returns the catalog Basic-auth password. The legacy
+// geotorget_token key holds the same secret under an older name.
+func (r *RoofModel) StacPass() string {
+ if r.StacPassword != "" {
+ return r.StacPassword
+ }
+ return r.GeotorgetToken
+}
+
// Weather is the weather-forecast source config.
type Weather struct {
Provider string `yaml:"provider" json:"provider"` // met_no | openweather | open_meteo | forecast_solar | none
@@ -1612,8 +1658,13 @@ func (c Config) MaskSecrets() Config {
cp := *out.RoofModel
// The UI has to distinguish "no credential stored" from "one is stored
// but masked", or an operator cannot tell whether they still need to
- // paste their Geotorget token in.
- cp.HasGeotorgetToken = strings.TrimSpace(cp.GeotorgetToken) != ""
+ // paste their Geotorget password in. The masked copy is also folded to
+ // the canonical stac_* keys so the UI reads a single shape however old
+ // the YAML is.
+ cp.StacUsername = cp.StacUser()
+ cp.HasStacPassword = strings.TrimSpace(cp.StacPass()) != ""
+ cp.StacPassword = ""
+ cp.GeotorgetUsername = ""
cp.GeotorgetToken = ""
out.RoofModel = &cp
}
@@ -1712,8 +1763,12 @@ func (incoming *Config) PreserveMaskedSecrets(existing *Config) {
if incoming.Assistant != nil && existing.Assistant != nil && incoming.Assistant.APIKey == "" {
incoming.Assistant.APIKey = existing.Assistant.APIKey
}
- if incoming.RoofModel != nil && existing.RoofModel != nil && incoming.RoofModel.GeotorgetToken == "" {
- incoming.RoofModel.GeotorgetToken = existing.RoofModel.GeotorgetToken
+ if incoming.RoofModel != nil && existing.RoofModel != nil &&
+ incoming.RoofModel.StacPassword == "" && incoming.RoofModel.GeotorgetToken == "" {
+ // Restore under the canonical key wherever the existing secret lives;
+ // this is also what migrates an old geotorget_token config to
+ // stac_password on its first save through the API.
+ incoming.RoofModel.StacPassword = existing.RoofModel.StacPass()
}
if incoming.Notifications != nil && existing.Notifications != nil &&
incoming.Notifications.Ntfy != nil && existing.Notifications.Ntfy != nil {
diff --git a/go/internal/config/roofmodel_secrets_test.go b/go/internal/config/roofmodel_secrets_test.go
index fe1e36d71..b02e3dfa3 100644
--- a/go/internal/config/roofmodel_secrets_test.go
+++ b/go/internal/config/roofmodel_secrets_test.go
@@ -2,88 +2,140 @@ package config
import "testing"
-// The Geotorget token is the operator's own credential for Lantmateriet. It
-// must never come back out of the API, and -- the failure that actually bites --
-// saving the settings form must not wipe it, because the form only ever sends
-// back the blank it was given.
+// The STAC catalog password — for Lantmäteriet, the operator's own Geotorget
+// account password, since no OAuth is offered for those APIs. It must never
+// come back out of the API, and — the failure that actually bites — saving
+// the settings form must not wipe it, because the form only ever sends back
+// the blank it was given.
-func TestRoofModelMaskSecretsHidesTheTokenButSaysOneExists(t *testing.T) {
+func TestRoofModelMaskSecretsHidesThePasswordButSaysOneExists(t *testing.T) {
c := Config{RoofModel: &RoofModel{
- Enabled: true,
- GeotorgetUsername: "operator@example.com",
- GeotorgetToken: "gt_secret_value",
+ Enabled: true,
+ StacUsername: "operator@example.com",
+ StacPassword: "gt_secret_value",
}}
m := c.MaskSecrets()
- if m.RoofModel.GeotorgetToken != "" {
- t.Errorf("token leaked through the API: %q", m.RoofModel.GeotorgetToken)
+ if m.RoofModel.StacPassword != "" {
+ t.Errorf("password leaked through the API: %q", m.RoofModel.StacPassword)
}
- if !m.RoofModel.HasGeotorgetToken {
- t.Error("UI cannot tell a stored token from a missing one")
+ if !m.RoofModel.HasStacPassword {
+ t.Error("UI cannot tell a stored password from a missing one")
}
// The username is not a secret, and blanking it would make the form look
// empty when it is not.
- if m.RoofModel.GeotorgetUsername != "operator@example.com" {
- t.Errorf("username got blanked: %q", m.RoofModel.GeotorgetUsername)
+ if m.RoofModel.StacUsername != "operator@example.com" {
+ t.Errorf("username got blanked: %q", m.RoofModel.StacUsername)
}
- if c.RoofModel.GeotorgetToken != "gt_secret_value" {
+ if c.RoofModel.StacPassword != "gt_secret_value" {
t.Error("masking mutated the original config")
}
}
-func TestRoofModelMaskSecretsReportsNoTokenWhenUnset(t *testing.T) {
- for _, tok := range []string{"", " "} {
- c := Config{RoofModel: &RoofModel{Enabled: true, GeotorgetToken: tok}}
- if c.MaskSecrets().RoofModel.HasGeotorgetToken {
- t.Errorf("token %q reported as stored", tok)
+// A config written before the basic-auth redesign stores the same secret
+// under geotorget_token. It must mask just as hard, and the masked copy must
+// present the canonical stac_* shape so the UI only ever reads one thing.
+func TestRoofModelMaskSecretsFoldsTheLegacyKeys(t *testing.T) {
+ c := Config{RoofModel: &RoofModel{
+ Enabled: true,
+ GeotorgetUsername: "operator",
+ GeotorgetToken: "legacy_secret",
+ }}
+ m := c.MaskSecrets()
+
+ if m.RoofModel.GeotorgetToken != "" || m.RoofModel.StacPassword != "" {
+ t.Errorf("secret leaked: token=%q password=%q",
+ m.RoofModel.GeotorgetToken, m.RoofModel.StacPassword)
+ }
+ if !m.RoofModel.HasStacPassword {
+ t.Error("a legacy-stored secret was reported as absent")
+ }
+ if m.RoofModel.StacUsername != "operator" || m.RoofModel.GeotorgetUsername != "" {
+ t.Errorf("masked copy not folded to stac_*: stac=%q legacy=%q",
+ m.RoofModel.StacUsername, m.RoofModel.GeotorgetUsername)
+ }
+}
+
+func TestRoofModelMaskSecretsReportsNoPasswordWhenUnset(t *testing.T) {
+ for _, pw := range []string{"", " "} {
+ c := Config{RoofModel: &RoofModel{Enabled: true, StacPassword: pw}}
+ if c.MaskSecrets().RoofModel.HasStacPassword {
+ t.Errorf("password %q reported as stored", pw)
}
}
}
-// Saving any unrelated setting round-trips the whole config, so an empty token
-// from the UI means "unchanged", not "delete it".
-func TestRoofModelPreserveMaskedSecretsKeepsTheStoredToken(t *testing.T) {
+// Saving any unrelated setting round-trips the whole config, so an empty
+// password from the UI means "unchanged", not "delete it".
+func TestRoofModelPreserveMaskedSecretsKeepsTheStoredPassword(t *testing.T) {
existing := &Config{RoofModel: &RoofModel{
- Enabled: true, GeotorgetUsername: "operator", GeotorgetToken: "gt_secret_value",
+ Enabled: true, StacUsername: "operator", StacPassword: "gt_secret_value",
}}
incoming := &Config{RoofModel: &RoofModel{
- Enabled: true, GeotorgetUsername: "operator", GeotorgetToken: "", RadiusM: 60,
+ Enabled: true, StacUsername: "operator", StacPassword: "", RadiusM: 60,
}}
incoming.PreserveMaskedSecrets(existing)
- if incoming.RoofModel.GeotorgetToken != "gt_secret_value" {
- t.Errorf("token = %q, want it preserved", incoming.RoofModel.GeotorgetToken)
+ if incoming.RoofModel.StacPassword != "gt_secret_value" {
+ t.Errorf("password = %q, want it preserved", incoming.RoofModel.StacPassword)
}
if incoming.RoofModel.RadiusM != 60 {
t.Error("the edit being saved was lost")
}
}
-// Pasting a new token has to replace the old one, or a rotated credential
+// A secret stored under the legacy key survives a save and migrates to the
+// canonical key, which is how old configs move forward without the operator
+// retyping anything.
+func TestRoofModelPreserveMaskedSecretsMigratesALegacyToken(t *testing.T) {
+ existing := &Config{RoofModel: &RoofModel{GeotorgetToken: "legacy_secret"}}
+ incoming := &Config{RoofModel: &RoofModel{}}
+
+ incoming.PreserveMaskedSecrets(existing)
+
+ if incoming.RoofModel.StacPassword != "legacy_secret" {
+ t.Errorf("password = %q, want the legacy secret under the new key",
+ incoming.RoofModel.StacPassword)
+ }
+}
+
+// Pasting a new password has to replace the old one, or a rotated credential
// could never be entered.
-func TestRoofModelPreserveMaskedSecretsAcceptsANewToken(t *testing.T) {
- existing := &Config{RoofModel: &RoofModel{GeotorgetToken: "old_token"}}
- incoming := &Config{RoofModel: &RoofModel{GeotorgetToken: "new_token"}}
+func TestRoofModelPreserveMaskedSecretsAcceptsANewPassword(t *testing.T) {
+ existing := &Config{RoofModel: &RoofModel{StacPassword: "old_password"}}
+ incoming := &Config{RoofModel: &RoofModel{StacPassword: "new_password"}}
incoming.PreserveMaskedSecrets(existing)
- if incoming.RoofModel.GeotorgetToken != "new_token" {
- t.Errorf("token = %q, want the newly entered one", incoming.RoofModel.GeotorgetToken)
+ if incoming.RoofModel.StacPassword != "new_password" {
+ t.Errorf("password = %q, want the newly entered one", incoming.RoofModel.StacPassword)
}
}
// Enabling the module for the first time has no existing section to copy from.
func TestRoofModelPreserveMaskedSecretsSurvivesAMissingSection(t *testing.T) {
- incoming := &Config{RoofModel: &RoofModel{GeotorgetToken: "first_token"}}
+ incoming := &Config{RoofModel: &RoofModel{StacPassword: "first_password"}}
incoming.PreserveMaskedSecrets(&Config{})
- if incoming.RoofModel.GeotorgetToken != "first_token" {
- t.Errorf("token = %q", incoming.RoofModel.GeotorgetToken)
+ if incoming.RoofModel.StacPassword != "first_password" {
+ t.Errorf("password = %q", incoming.RoofModel.StacPassword)
}
none := &Config{}
- none.PreserveMaskedSecrets(&Config{RoofModel: &RoofModel{GeotorgetToken: "x"}})
+ none.PreserveMaskedSecrets(&Config{RoofModel: &RoofModel{StacPassword: "x"}})
if none.RoofModel != nil {
t.Error("a section the operator never configured was invented")
}
}
+
+// Both credential spellings resolve through the accessors, stac_* winning.
+func TestRoofModelCredentialAccessors(t *testing.T) {
+ r := &RoofModel{GeotorgetUsername: "legacy-u", GeotorgetToken: "legacy-p"}
+ if r.StacUser() != "legacy-u" || r.StacPass() != "legacy-p" {
+ t.Errorf("legacy keys not readable: %q %q", r.StacUser(), r.StacPass())
+ }
+ r.StacUsername, r.StacPassword = "new-u", "new-p"
+ if r.StacUser() != "new-u" || r.StacPass() != "new-p" {
+ t.Errorf("stac_* keys must win: %q %q", r.StacUser(), r.StacPass())
+ }
+}
diff --git a/go/internal/roofmodel/roofmodel.go b/go/internal/roofmodel/roofmodel.go
index 4ef5bb6ee..0955a41fb 100644
--- a/go/internal/roofmodel/roofmodel.go
+++ b/go/internal/roofmodel/roofmodel.go
@@ -220,10 +220,13 @@ func (s *Service) run(ctx context.Context, lat, lon float64, mode, buildingID st
if !s.Enabled() {
return nil, ErrDisabled
}
- if !coverage.Covers("lantmateriet", lat, lon) {
+ // The Sweden gate belongs to the default Lantmäteriet catalog only. An
+ // operator pointing at another country's STAC catalog knows what it
+ // covers; FTW does not, so it stops pretending to.
+ if s.cfg.StacBaseURL == "" && !coverage.Covers("lantmateriet", lat, lon) {
return nil, fmt.Errorf("%w: (%.4f, %.4f) is not in Sweden", ErrOutsideCoverage, lat, lon)
}
- if s.cfg.GeotorgetUsername == "" || s.cfg.GeotorgetToken == "" {
+ if s.cfg.StacUser() == "" || s.cfg.StacPass() == "" {
return nil, ErrNoCredentials
}
@@ -235,14 +238,28 @@ func (s *Service) run(ctx context.Context, lat, lon float64, mode, buildingID st
"--mode", mode,
"--lat", fmt.Sprintf("%.6f", lat),
"--lon", fmt.Sprintf("%.6f", lon),
- "--username", s.cfg.GeotorgetUsername,
- "--token", s.cfg.GeotorgetToken,
+ "--username", s.cfg.StacUser(),
+ "--password", s.cfg.StacPass(),
"--radius-m", fmt.Sprintf("%.1f", s.radius()),
"--packing-factor", fmt.Sprintf("%.3f", s.packingFactor()),
}
if buildingID != "" {
args = append(args, "--building-id", buildingID)
}
+ // A custom catalog replaces the Lantmäteriet defaults piecewise; anything
+ // left empty falls back to the module's own Geotorget defaults.
+ if s.cfg.StacBaseURL != "" {
+ args = append(args, "--stac-base-url", s.cfg.StacBaseURL)
+ }
+ if s.cfg.StacBuildingsCollection != "" {
+ args = append(args, "--buildings-collection", s.cfg.StacBuildingsCollection)
+ }
+ if s.cfg.StacLidarCollection != "" {
+ args = append(args, "--lidar-collection", s.cfg.StacLidarCollection)
+ }
+ if s.cfg.StacBboxEPSG != 0 {
+ args = append(args, "--bbox-epsg", fmt.Sprintf("%d", s.cfg.StacBboxEPSG))
+ }
cmd := exec.CommandContext(ctx, s.command(), args...)
if s.cfg.ModuleDir != "" {
cmd.Env = append(cmd.Environ(), "PYTHONPATH="+s.cfg.ModuleDir)
diff --git a/go/internal/roofmodel/roofmodel_test.go b/go/internal/roofmodel/roofmodel_test.go
index 3e70c7292..a0e670100 100644
--- a/go/internal/roofmodel/roofmodel_test.go
+++ b/go/internal/roofmodel/roofmodel_test.go
@@ -122,7 +122,7 @@ func TestDeriveRefusesOutsideSwedenWithoutSpawning(t *testing.T) {
s := svc(t, &config.RoofModel{
Enabled: true,
Command: "definitely-not-a-real-command",
- GeotorgetUsername: "u", GeotorgetToken: "t",
+ StacUsername: "u", StacPassword: "t",
})
for _, c := range []struct {
name string
@@ -153,7 +153,7 @@ func TestSwedishBoxAdmitsSomeNonSwedishPointsByDesign(t *testing.T) {
s := svc(t, &config.RoofModel{
Enabled: true,
Command: "definitely-not-a-real-command",
- GeotorgetUsername: "u", GeotorgetToken: "t",
+ StacUsername: "u", StacPassword: "t",
})
_, err := s.Derive(context.Background(), 59.91, 10.75, "") // Oslo
if errors.Is(err, ErrOutsideCoverage) {
@@ -170,7 +170,7 @@ func TestSwedishBoxCoversBorderTowns(t *testing.T) {
s := svc(t, &config.RoofModel{
Enabled: true,
Command: "definitely-not-a-real-command",
- GeotorgetUsername: "u", GeotorgetToken: "t",
+ StacUsername: "u", StacPassword: "t",
})
for _, c := range []struct {
name string
@@ -196,7 +196,7 @@ func TestDeriveRequiresCredentials(t *testing.T) {
{"no token", "u", ""},
{"neither", "", ""},
} {
- s := svc(t, &config.RoofModel{Enabled: true, Command: "no-such-command", GeotorgetUsername: c.user, GeotorgetToken: c.token})
+ s := svc(t, &config.RoofModel{Enabled: true, Command: "no-such-command", StacUsername: c.user, StacPassword: c.token})
_, err := s.Derive(context.Background(), stockholmLat, stockholmLon, "")
if !errors.Is(err, ErrNoCredentials) {
t.Errorf("%s: err = %v, want ErrNoCredentials", c.name, err)
@@ -211,7 +211,7 @@ func TestDeriveParsesAModel(t *testing.T) {
`"arrays":[{"name":"Roof south","rated_w":7200,"tilt_deg":35,"azimuth_deg":180,"area_m2":51.4,"segment_id":"seg-0"}],` +
`"captured_at_ms":1519862400000,"derived_at_ms":1785456000000}`
cmd := stubModule(t, "stdout", doc)
- s := svc(t, &config.RoofModel{Enabled: true, Command: cmd, GeotorgetUsername: "u", GeotorgetToken: "t"})
+ s := svc(t, &config.RoofModel{Enabled: true, Command: cmd, StacUsername: "u", StacPassword: "t"})
m, err := s.Derive(context.Background(), stockholmLat, stockholmLon, "")
if err != nil {
@@ -238,7 +238,7 @@ func TestDeriveCarriesHowTheLidarWasFetched(t *testing.T) {
`"building":{"building_id":"b-1","area_m2":144,"returns_used":220,"returns_in_radius":260},` +
`"arrays":[]}`
cmd := stubModule(t, "stdout", doc)
- s := svc(t, &config.RoofModel{Enabled: true, Command: cmd, GeotorgetUsername: "u", GeotorgetToken: "t"})
+ s := svc(t, &config.RoofModel{Enabled: true, Command: cmd, StacUsername: "u", StacPassword: "t"})
m, err := s.Derive(context.Background(), stockholmLat, stockholmLon, "b-1")
if err != nil {
@@ -260,7 +260,7 @@ func TestDerivePassesTheSiteAndCredentials(t *testing.T) {
cmd := stubModule(t, "record", record)
s := svc(t, &config.RoofModel{
Enabled: true, Command: cmd, ModuleDir: dir,
- GeotorgetUsername: "operator", GeotorgetToken: "secret-token",
+ StacUsername: "operator", StacPassword: "secret-token",
RadiusM: 25,
})
@@ -282,7 +282,7 @@ func TestDerivePassesTheSiteAndCredentials(t *testing.T) {
"--lat 59.330000",
"--lon 18.070000",
"--username operator",
- "--token secret-token",
+ "--password secret-token",
"--radius-m 25.0",
} {
if !strings.Contains(line, want) {
@@ -308,7 +308,7 @@ func TestDerivePassesThePickedBuilding(t *testing.T) {
cmd := stubModule(t, "record", record)
s := svc(t, &config.RoofModel{
Enabled: true, Command: cmd,
- GeotorgetUsername: "u", GeotorgetToken: "t",
+ StacUsername: "u", StacPassword: "t",
})
if _, err := s.Derive(context.Background(), stockholmLat, stockholmLon, "bldg-42"); err != nil {
@@ -331,7 +331,7 @@ func TestDeriveOmitsTheBuildingFlagWhenNoneIsPicked(t *testing.T) {
record := dir + string(os.PathSeparator) + "invocation.json"
cmd := stubModule(t, "record", record)
s := svc(t, &config.RoofModel{
- Enabled: true, Command: cmd, GeotorgetUsername: "u", GeotorgetToken: "t",
+ Enabled: true, Command: cmd, StacUsername: "u", StacPassword: "t",
})
if _, err := s.Derive(context.Background(), stockholmLat, stockholmLon, ""); err != nil {
@@ -348,7 +348,7 @@ func TestBuildingsListsFootprints(t *testing.T) {
`{"type":"Feature","id":"b1","geometry":{"type":"Polygon","coordinates":[[[18.0,59.3],[18.001,59.3],[18.001,59.301],[18.0,59.3]]]},"properties":{"area_m2":120.5}},` +
`{"type":"Feature","id":"b2","geometry":{"type":"Polygon","coordinates":[[[18.01,59.3],[18.011,59.3],[18.011,59.301],[18.01,59.3]]]},"properties":{"area_m2":64.0}}]}`
cmd := stubModule(t, "stdout", doc)
- s := svc(t, &config.RoofModel{Enabled: true, Command: cmd, GeotorgetUsername: "u", GeotorgetToken: "t"})
+ s := svc(t, &config.RoofModel{Enabled: true, Command: cmd, StacUsername: "u", StacPassword: "t"})
list, err := s.Buildings(context.Background(), stockholmLat, stockholmLon)
if err != nil {
@@ -367,7 +367,7 @@ func TestBuildingsUsesBuildingsMode(t *testing.T) {
dir := t.TempDir()
record := dir + string(os.PathSeparator) + "invocation.json"
cmd := stubModule(t, "record", record)
- s := svc(t, &config.RoofModel{Enabled: true, Command: cmd, GeotorgetUsername: "u", GeotorgetToken: "t"})
+ s := svc(t, &config.RoofModel{Enabled: true, Command: cmd, StacUsername: "u", StacPassword: "t"})
// The stub answers with a roof model, not a building list; only the
// invocation matters here.
@@ -383,7 +383,7 @@ func TestBuildingsUsesBuildingsMode(t *testing.T) {
func TestBuildingsRefusesOutsideCoverageAndWithoutCredentials(t *testing.T) {
s := svc(t, &config.RoofModel{
Enabled: true, Command: "definitely-not-a-real-command",
- GeotorgetUsername: "u", GeotorgetToken: "t",
+ StacUsername: "u", StacPassword: "t",
})
if _, err := s.Buildings(context.Background(), 52.52, 13.40); !errors.Is(err, ErrOutsideCoverage) {
t.Errorf("Berlin: err = %v, want ErrOutsideCoverage", err)
@@ -418,7 +418,7 @@ func readInvocation(t *testing.T, path string) stubInvocation {
func TestDeriveSurfacesTheModuleErrorMessage(t *testing.T) {
cmd := stubModule(t, "stderr",
`{"error":"Geotorget rejected the credentials","kind":"MissingCredentials"}`)
- s := svc(t, &config.RoofModel{Enabled: true, Command: cmd, GeotorgetUsername: "u", GeotorgetToken: "t"})
+ s := svc(t, &config.RoofModel{Enabled: true, Command: cmd, StacUsername: "u", StacPassword: "t"})
_, err := s.Derive(context.Background(), stockholmLat, stockholmLon, "")
if err == nil {
@@ -433,7 +433,7 @@ func TestDeriveSurfacesTheModuleErrorMessage(t *testing.T) {
// rather than being mistaken for a successful empty model.
func TestDeriveReportsNonJSONFailure(t *testing.T) {
cmd := stubModule(t, "stderr", "Traceback (most recent call last):\n MemoryError\n")
- s := svc(t, &config.RoofModel{Enabled: true, Command: cmd, GeotorgetUsername: "u", GeotorgetToken: "t"})
+ s := svc(t, &config.RoofModel{Enabled: true, Command: cmd, StacUsername: "u", StacPassword: "t"})
_, err := s.Derive(context.Background(), stockholmLat, stockholmLon, "")
if err == nil || !strings.Contains(err.Error(), "roof model failed") {
@@ -443,7 +443,7 @@ func TestDeriveReportsNonJSONFailure(t *testing.T) {
func TestDeriveRejectsUnknownSchemaVersion(t *testing.T) {
cmd := stubModule(t, "stdout", `{"schema_version":99,"arrays":[]}`)
- s := svc(t, &config.RoofModel{Enabled: true, Command: cmd, GeotorgetUsername: "u", GeotorgetToken: "t"})
+ s := svc(t, &config.RoofModel{Enabled: true, Command: cmd, StacUsername: "u", StacPassword: "t"})
_, err := s.Derive(context.Background(), stockholmLat, stockholmLon, "")
if err == nil || !strings.Contains(err.Error(), "schema_version") {
@@ -453,7 +453,7 @@ func TestDeriveRejectsUnknownSchemaVersion(t *testing.T) {
func TestDeriveRejectsUnreadableOutput(t *testing.T) {
cmd := stubModule(t, "stdout", "not-json-at-all")
- s := svc(t, &config.RoofModel{Enabled: true, Command: cmd, GeotorgetUsername: "u", GeotorgetToken: "t"})
+ s := svc(t, &config.RoofModel{Enabled: true, Command: cmd, StacUsername: "u", StacPassword: "t"})
_, err := s.Derive(context.Background(), stockholmLat, stockholmLon, "")
if err == nil || !strings.Contains(err.Error(), "unreadable") {
@@ -467,7 +467,7 @@ func TestDeriveIsTimeBoxed(t *testing.T) {
cmd := stubModule(t, "hang", "")
s := svc(t, &config.RoofModel{
Enabled: true, Command: cmd,
- GeotorgetUsername: "u", GeotorgetToken: "t", TimeoutS: 1,
+ StacUsername: "u", StacPassword: "t", TimeoutS: 1,
})
start := time.Now()
@@ -509,3 +509,93 @@ func TestToPVArraysMatchesConfigShape(t *testing.T) {
t.Error("nil model must yield nil arrays")
}
}
+
+// A config written before the basic-auth redesign still derives: the legacy
+// geotorget_* keys resolve through the accessors and the secret reaches the
+// module as --password, whatever key it was stored under.
+func TestDeriveAcceptsLegacyGeotorgetKeys(t *testing.T) {
+ dir := t.TempDir()
+ record := dir + string(os.PathSeparator) + "invocation.json"
+ cmd := stubModule(t, "record", record)
+ s := svc(t, &config.RoofModel{
+ Enabled: true, Command: cmd, ModuleDir: dir,
+ GeotorgetUsername: "legacy-op", GeotorgetToken: "legacy-secret",
+ })
+
+ if _, err := s.Derive(context.Background(), stockholmLat, stockholmLon, ""); err != nil {
+ t.Fatal(err)
+ }
+
+ raw, err := os.ReadFile(record)
+ if err != nil {
+ t.Fatalf("stub recorded nothing: %v", err)
+ }
+ var got stubInvocation
+ if err := json.Unmarshal(raw, &got); err != nil {
+ t.Fatal(err)
+ }
+ line := strings.Join(got.Args, " ")
+ for _, want := range []string{"--username legacy-op", "--password legacy-secret"} {
+ if !strings.Contains(line, want) {
+ t.Errorf("args %q missing %q", line, want)
+ }
+ }
+ if strings.Contains(line, "--token") {
+ t.Errorf("args %q still use the retired --token flag", line)
+ }
+}
+
+// A custom STAC catalog lifts the Sweden-only gate — FTW cannot know what a
+// third-party catalog covers — and every stac_* setting must cross the
+// process boundary.
+func TestDeriveCustomCatalogSkipsSwedenGateAndPassesStacArgs(t *testing.T) {
+ dir := t.TempDir()
+ record := dir + string(os.PathSeparator) + "invocation.json"
+ cmd := stubModule(t, "record", record)
+ s := svc(t, &config.RoofModel{
+ Enabled: true, Command: cmd, ModuleDir: dir,
+ StacUsername: "u", StacPassword: "p",
+ StacBaseURL: "https://stac.example.org",
+ StacBuildingsCollection: "buildings-vector",
+ StacLidarCollection: "lidar-pointcloud",
+ StacBboxEPSG: 4326,
+ })
+
+ // Berlin: outside Lantmäteriet coverage, fine for a custom catalog.
+ if _, err := s.Derive(context.Background(), 52.52, 13.40, ""); err != nil {
+ t.Fatal(err)
+ }
+
+ raw, err := os.ReadFile(record)
+ if err != nil {
+ t.Fatalf("stub recorded nothing: %v", err)
+ }
+ var got stubInvocation
+ if err := json.Unmarshal(raw, &got); err != nil {
+ t.Fatal(err)
+ }
+ line := strings.Join(got.Args, " ")
+ for _, want := range []string{
+ "--stac-base-url https://stac.example.org",
+ "--buildings-collection buildings-vector",
+ "--lidar-collection lidar-pointcloud",
+ "--bbox-epsg 4326",
+ } {
+ if !strings.Contains(line, want) {
+ t.Errorf("args %q missing %q", line, want)
+ }
+ }
+}
+
+// Without a custom catalog the Sweden gate still holds — the redesign must
+// not have quietly opened the default catalog to the whole planet.
+func TestDeriveDefaultCatalogStillRefusesOutsideSweden(t *testing.T) {
+ s := svc(t, &config.RoofModel{
+ Enabled: true, Command: "no-such-command",
+ StacUsername: "u", StacPassword: "p",
+ })
+ _, err := s.Derive(context.Background(), 52.52, 13.40, "")
+ if !errors.Is(err, ErrOutsideCoverage) {
+ t.Errorf("err = %v, want ErrOutsideCoverage", err)
+ }
+}
diff --git a/roofmodel/ftw_roofmodel/__main__.py b/roofmodel/ftw_roofmodel/__main__.py
index 7b04e6406..b5dc71f81 100644
--- a/roofmodel/ftw_roofmodel/__main__.py
+++ b/roofmodel/ftw_roofmodel/__main__.py
@@ -12,7 +12,14 @@
import sys
from .buildings import DEFAULT_SEARCH_RADIUS_M, search_buildings
-from .geotorget import Credentials, GeotorgetClient, GeotorgetError
+from .geotorget import (
+ COLLECTION_BUILDINGS,
+ COLLECTION_LIDAR,
+ DEFAULT_BASE_URL,
+ Credentials,
+ GeotorgetClient,
+ GeotorgetError,
+)
from .pipeline import SCHEMA_VERSION, RoofModelError, derive
@@ -32,22 +39,52 @@ def main(argv: list[str] | None = None) -> int:
help="footprint to clip the LiDAR to, from a --mode buildings run",
)
p.add_argument("--search-radius-m", type=float, default=DEFAULT_SEARCH_RADIUS_M)
- p.add_argument("--username", default="", help="Geotorget username")
- p.add_argument("--token", default="", help="Geotorget token/password")
+ p.add_argument("--username", default="", help="STAC catalog username (Geotorget account)")
+ p.add_argument(
+ "--password",
+ "--token", # legacy spelling, from when the credential was assumed to be a token
+ dest="password",
+ default="",
+ help="STAC catalog password, sent as HTTP Basic auth (Geotorget account password)",
+ )
p.add_argument("--radius-m", type=float, default=40.0)
p.add_argument("--packing-factor", type=float, default=0.70)
p.add_argument("--module-w-per-m2", type=float, default=200.0)
+ p.add_argument(
+ "--stac-base-url",
+ default=DEFAULT_BASE_URL,
+ help="STAC API root; search is POST {base}/search (default: Lantmäteriet)",
+ )
+ p.add_argument(
+ "--buildings-collection",
+ default=COLLECTION_BUILDINGS,
+ help="collection id for building footprints",
+ )
+ p.add_argument(
+ "--lidar-collection",
+ default=COLLECTION_LIDAR,
+ help="collection id for LiDAR point clouds",
+ )
+ p.add_argument(
+ "--bbox-epsg",
+ type=int,
+ choices=(3006, 4326),
+ default=3006,
+ help="CRS of the search bbox: 3006 for Lantmäteriet, 4326 per the STAC spec",
+ )
args = p.parse_args(argv)
- credentials = Credentials(args.username, args.token)
+ credentials = Credentials(args.username, args.password)
try:
if args.mode == "buildings":
- client = GeotorgetClient(credentials)
+ client = GeotorgetClient(credentials, base_url=args.stac_base_url)
found = search_buildings(
client,
latitude=args.lat,
longitude=args.lon,
radius_m=args.search_radius_m,
+ collection=args.buildings_collection,
+ bbox_epsg=args.bbox_epsg,
)
payload = {
"schema_version": SCHEMA_VERSION,
@@ -63,6 +100,10 @@ def main(argv: list[str] | None = None) -> int:
packing_factor=args.packing_factor,
module_w_per_m2=args.module_w_per_m2,
building_id=args.building_id or None,
+ base_url=args.stac_base_url,
+ buildings_collection=args.buildings_collection,
+ lidar_collection=args.lidar_collection,
+ bbox_epsg=args.bbox_epsg,
)
except (GeotorgetError, RoofModelError) as exc:
json.dump({"error": str(exc), "kind": type(exc).__name__}, sys.stderr)
diff --git a/roofmodel/ftw_roofmodel/buildings.py b/roofmodel/ftw_roofmodel/buildings.py
index 469e11278..1961123c1 100644
--- a/roofmodel/ftw_roofmodel/buildings.py
+++ b/roofmodel/ftw_roofmodel/buildings.py
@@ -298,11 +298,12 @@ def search_buildings(
longitude: float,
radius_m: float = DEFAULT_SEARCH_RADIUS_M,
limit: int = 50,
+ collection: str = COLLECTION_BUILDINGS,
+ bbox_epsg: int = 3006,
) -> list[Building]:
"""Building footprints near a site, nearest first."""
- south, west, north, east = sweref.metre_box_around(latitude, longitude, radius_m)
- bbox = sweref.bbox_wgs84_to_sweref99tm(south, west, north, east)
- items = client.search(COLLECTION_BUILDINGS, bbox, limit=limit)
+ bbox = sweref.stac_search_bbox(latitude, longitude, radius_m, bbox_epsg)
+ items = client.search(collection, bbox, limit=limit)
features: list[dict[str, Any]] = []
for item in items:
features.extend(_features_from_item(item, client))
diff --git a/roofmodel/ftw_roofmodel/geotorget.py b/roofmodel/ftw_roofmodel/geotorget.py
index 72553ea0a..bca88fc72 100644
--- a/roofmodel/ftw_roofmodel/geotorget.py
+++ b/roofmodel/ftw_roofmodel/geotorget.py
@@ -1,14 +1,26 @@
-"""Lantmaeteriet Geotorget access: authentication and STAC search.
+"""STAC catalog access: authentication, search, and asset download.
-Two products are used, both free open data (CC BY 4.0) but both gated behind a
-Geotorget account the operator orders themselves:
+The default catalog is Lantmaeteriet's Geotorget, where two products are used,
+both free open data (CC BY 4.0) but both gated behind a Geotorget account the
+operator orders themselves:
* *Byggnad Nedladdning, vektor* -- building footprint polygons.
* *Laserdata Nedladdning, Skog* -- airborne LiDAR, 1-2 points/m2, from 2018.
-Credentials are the operator's own and are never shipped, logged or echoed back
-through the API. FTW stores them the same way it stores `weather.api_key`, and
-redacts them in config responses.
+Authentication is HTTP Basic with the operator's own Geotorget account
+username and password: Lantmaeteriet provides no OAuth for its STAC download
+APIs, so the account credential is the only door. Credentials are the
+operator's own and are never shipped, logged or echoed back through the API.
+FTW stores them the same way it stores `weather.api_key`, and redacts them in
+config responses.
+
+Nothing below is Lantmaeteriet-specific beyond the defaults: search is the
+standard `POST {base}/search` of the STAC API spec and downloads follow asset
+hrefs, so any STAC-conformant catalog behind Basic auth (or none) works by
+pointing `base_url` and the collection ids elsewhere. The one non-standard
+wrinkle is the search bbox CRS -- the spec mandates WGS84, Lantmaeteriet
+expects SWEREF 99 TM -- which is why callers choose the bbox they send (see
+`--bbox-epsg` in __main__).
Only `requests` is used. The STAC API is plain JSON over HTTP, so pulling in
pystac-client would add a dependency for a search body we can write in six
@@ -22,7 +34,8 @@
import datetime as dt
from typing import Any, Iterable
-DEFAULT_BASE_URL = "https://api.lantmateriet.se"
+# Root of the STAC API. The standard search endpoint is POST {base}/search.
+DEFAULT_BASE_URL = "https://api.lantmateriet.se/stac"
# Collection ids as published in Lantmaeteriet's STAC catalogue. Both products
# are STAC APIs over the same base URL and the same credentials; they differ
@@ -66,9 +79,10 @@ class Credentials:
def validate(self) -> None:
if not self.username or not self.password:
raise MissingCredentials(
- "Geotorget username and token are both required; order access at "
- "https://geotorget.lantmateriet.se and set roofmodel.geotorget_username "
- "and roofmodel.geotorget_token"
+ "a STAC username and password are both required; for Lantmäteriet, "
+ "order access at https://geotorget.lantmateriet.se and set "
+ "roofmodel.stac_username and roofmodel.stac_password to your "
+ "Geotorget account credentials"
)
@@ -203,8 +217,13 @@ def _item_from_feature(feature: dict[str, Any]) -> StacItem:
)
-class GeotorgetClient:
- """Thin STAC client for Lantmaeteriet's download APIs."""
+class StacClient:
+ """Thin client for a STAC search-and-download API over HTTP Basic auth.
+
+ Defaults target Lantmaeteriet's Geotorget catalog, but nothing here
+ depends on it: base_url and the collection ids passed to `search` are the
+ whole coupling.
+ """
def __init__(
self,
@@ -232,29 +251,31 @@ def session(self) -> Any:
def search(
self,
collection: str,
- bbox_sweref: tuple[float, float, float, float],
+ bbox: tuple[float, float, float, float],
limit: int = 20,
) -> list[StacItem]:
- """POST /stac/search for one collection over a SWEREF 99 TM bbox.
+ """POST {base}/search for one collection over a bbox.
- bbox is (min_easting, min_northing, max_easting, max_northing); the
- catalogue is published in EPSG:3006, so no reprojection happens here.
+ The bbox is (min_x, min_y, max_x, max_y) in whatever CRS the catalog
+ expects -- the STAC spec says WGS84 lon/lat, Lantmaeteriet expects
+ SWEREF 99 TM -- so the caller chooses what to send and no reprojection
+ happens here.
"""
body = {
"collections": [collection],
- "bbox": list(bbox_sweref),
+ "bbox": list(bbox),
"limit": limit,
}
- url = f"{self._base_url}/stac/search"
+ url = f"{self._base_url}/search"
try:
resp = self._session.post(url, json=body, timeout=self._timeout)
except Exception as exc: # network, DNS, TLS
raise GeotorgetError(f"STAC search failed: {exc}") from exc
if resp.status_code in (401, 403):
raise MissingCredentials(
- f"Geotorget rejected the credentials for {collection} "
- f"(HTTP {resp.status_code}). Check the account has ordered access "
- "to this product."
+ f"the STAC catalog rejected the credentials for {collection} "
+ f"(HTTP {resp.status_code}). Check the username and password, and "
+ "that the account has ordered access to this product."
)
if resp.status_code != 200:
raise GeotorgetError(f"STAC search returned HTTP {resp.status_code}")
@@ -276,3 +297,7 @@ def newest_capture(items: Iterable[StacItem]) -> dt.datetime | None:
"""Most recent known capture date across items, or None if none carry one."""
dates = [i.captured_at for i in items if i.captured_at is not None]
return max(dates) if dates else None
+
+
+# The client predates its generalization; the old name stays importable.
+GeotorgetClient = StacClient
diff --git a/roofmodel/ftw_roofmodel/pipeline.py b/roofmodel/ftw_roofmodel/pipeline.py
index 639d9b657..d31f32286 100644
--- a/roofmodel/ftw_roofmodel/pipeline.py
+++ b/roofmodel/ftw_roofmodel/pipeline.py
@@ -25,6 +25,7 @@
search_buildings,
)
from .geotorget import (
+ COLLECTION_BUILDINGS,
COLLECTION_LIDAR,
Credentials,
GeotorgetClient,
@@ -187,6 +188,10 @@ def derive(
module_w_per_m2: float = DEFAULT_MODULE_W_PER_M2,
building_id: str | None = None,
now: dt.datetime | None = None,
+ base_url: str = geotorget.DEFAULT_BASE_URL,
+ buildings_collection: str = COLLECTION_BUILDINGS,
+ lidar_collection: str = COLLECTION_LIDAR,
+ bbox_epsg: int = 3006,
) -> dict[str, Any]:
"""Derive a roof model for one site and return it as a JSON-ready dict.
@@ -194,14 +199,19 @@ def derive(
clip the LiDAR to that footprint before segmenting. Without it the whole
radius is segmented, which will happily return the neighbour's roof and lets
coplanar buildings steal each other's points; see buildings.py.
+
+ The catalog parameters default to Lantmaeteriet; any STAC-conformant
+ catalog can stand in (see geotorget.py), as long as its data arrives in
+ SWEREF 99 TM -- the segmentation works in that frame.
"""
if client is None:
- client = GeotorgetClient(credentials)
+ client = GeotorgetClient(credentials, base_url=base_url)
chosen: Building | None = None
if building_id:
candidates = search_buildings(
- client, latitude=latitude, longitude=longitude, radius_m=radius_m
+ client, latitude=latitude, longitude=longitude, radius_m=radius_m,
+ collection=buildings_collection, bbox_epsg=bbox_epsg,
)
chosen = next((b for b in candidates if b.building_id == building_id), None)
if chosen is None:
@@ -210,17 +220,17 @@ def derive(
"have been picked against a different coordinate"
)
- south, west, north, east = sweref.metre_box_around(latitude, longitude, radius_m)
- bbox = sweref.bbox_wgs84_to_sweref99tm(south, west, north, east)
+ bbox = sweref.stac_search_bbox(latitude, longitude, radius_m, bbox_epsg)
try:
- lidar_items: list[StacItem] = client.search(COLLECTION_LIDAR, bbox)
+ lidar_items: list[StacItem] = client.search(lidar_collection, bbox)
except GeotorgetError:
raise
if not lidar_items:
+ hint = "; Lantmaeteriet data is Sweden only" if lidar_collection == COLLECTION_LIDAR else ""
raise RoofModelError(
- f"no LiDAR tiles cover ({latitude:.5f}, {longitude:.5f}); "
- "Lantmaeteriet data is Sweden only"
+ f"no LiDAR tiles cover ({latitude:.5f}, {longitude:.5f}) in "
+ f"collection {lidar_collection!r}{hint}"
)
points, fetch = _read_lidar(client, lidar_items, chosen)
@@ -248,8 +258,8 @@ def derive(
"schema_version": SCHEMA_VERSION,
"site": {"latitude": latitude, "longitude": longitude, "radius_m": radius_m},
"source": {
- "provider": "lantmateriet",
- "collection": COLLECTION_LIDAR,
+ "provider": "lantmateriet" if base_url == geotorget.DEFAULT_BASE_URL else base_url,
+ "collection": lidar_collection,
"item_count": len(lidar_items),
"dataset_datetime": captured.isoformat() if captured else None,
# "copc-window" means only the footprint's neighbourhood was moved
diff --git a/roofmodel/ftw_roofmodel/sweref.py b/roofmodel/ftw_roofmodel/sweref.py
index 3c2f699d6..2d5e8f296 100644
--- a/roofmodel/ftw_roofmodel/sweref.py
+++ b/roofmodel/ftw_roofmodel/sweref.py
@@ -160,6 +160,24 @@ def bbox_wgs84_to_sweref99tm(
return min(eastings), min(northings), max(eastings), max(northings)
+def stac_search_bbox(
+ lat: float, lon: float, radius_m: float, bbox_epsg: int = 3006
+) -> tuple[float, float, float, float]:
+ """Bounding box around a site, in the CRS a STAC catalog expects.
+
+ EPSG:3006 (the default) is what Lantmaeteriet's catalog takes; EPSG:4326
+ in lon/lat order is what the STAC spec itself mandates, for catalogs that
+ follow it. Anything else would need a projection stack this module
+ deliberately does not carry.
+ """
+ south, west, north, east = metre_box_around(lat, lon, radius_m)
+ if bbox_epsg == 4326:
+ return (west, south, east, north)
+ if bbox_epsg == 3006:
+ return bbox_wgs84_to_sweref99tm(south, west, north, east)
+ raise ValueError(f"unsupported bbox EPSG {bbox_epsg}; use 3006 or 4326")
+
+
def metre_box_around(lat: float, lon: float, radius_m: float) -> tuple[float, float, float, float]:
"""Return a WGS84 (min_lat, min_lon, max_lat, max_lon) box of +/- radius_m.
diff --git a/roofmodel/tests/test_pipeline.py b/roofmodel/tests/test_pipeline.py
index 570071001..ab7b77b02 100644
--- a/roofmodel/tests/test_pipeline.py
+++ b/roofmodel/tests/test_pipeline.py
@@ -102,11 +102,25 @@ def test_search_sends_the_collection_and_bbox():
assert len(items) == 1
(url, body), = session.posts
+ # The default catalog root is Lantmaeteriet's /stac, so the standard
+ # {base}/search lands on the same URL it always has.
assert url.endswith("/stac/search")
assert body["collections"] == [COLLECTION_LIDAR]
assert body["bbox"] == [600000.0, 6500000.0, 600100.0, 6500100.0]
+def test_search_speaks_standard_stac_to_a_custom_catalog():
+ """Any STAC-conformant catalog works: base_url and collection ids are the
+ whole coupling, and search is the spec's POST {base}/search."""
+ session = FakeSession(search={"features": []})
+ client = GeotorgetClient(CREDS, session=session, base_url="https://stac.example.org/")
+ client.search("lidar-pointcloud", (5.0, 50.0, 6.0, 51.0))
+
+ (url, body), = session.posts
+ assert url == "https://stac.example.org/search"
+ assert body["collections"] == ["lidar-pointcloud"]
+
+
def test_capture_date_is_read_from_stac_datetime():
session = FakeSession(search={"features": [feature(datetime_value="2019-04-02T09:30:00Z")]})
items = GeotorgetClient(CREDS, session=session).search(COLLECTION_LIDAR, (0, 0, 1, 1))
diff --git a/roofmodel/tests/test_sweref.py b/roofmodel/tests/test_sweref.py
index f6f20a4b1..1cd6dfa35 100644
--- a/roofmodel/tests/test_sweref.py
+++ b/roofmodel/tests/test_sweref.py
@@ -110,6 +110,31 @@ def test_metre_box_brackets_its_centre():
assert west < 18.07 < east
+def test_stac_search_bbox_sweref_matches_the_long_form():
+ from ftw_roofmodel.sweref import bbox_wgs84_to_sweref99tm, stac_search_bbox
+
+ south, west, north, east = metre_box_around(59.33, 18.07, 40.0)
+ assert stac_search_bbox(59.33, 18.07, 40.0) == bbox_wgs84_to_sweref99tm(
+ south, west, north, east
+ )
+
+
+def test_stac_search_bbox_wgs84_is_lon_lat_ordered():
+ """The STAC spec's bbox is [west, south, east, north] in degrees."""
+ from ftw_roofmodel.sweref import stac_search_bbox
+
+ west, south, east, north = stac_search_bbox(59.33, 18.07, 40.0, bbox_epsg=4326)
+ assert west < 18.07 < east
+ assert south < 59.33 < north
+
+
+def test_stac_search_bbox_refuses_a_crs_it_cannot_produce():
+ from ftw_roofmodel.sweref import stac_search_bbox
+
+ with pytest.raises(ValueError):
+ stac_search_bbox(59.33, 18.07, 40.0, bbox_epsg=3857)
+
+
def test_degree_box_would_have_been_wrong_at_high_latitude():
"""Guards the reason metre_box_around exists: a fixed degree offset gives
wildly different ground distances at Malmoe and Kiruna, so anyone tempted to
diff --git a/web/settings/tabs/weather.js b/web/settings/tabs/weather.js
index 05a8b7446..57edfae52 100644
--- a/web/settings/tabs/weather.js
+++ b/web/settings/tabs/weather.js
@@ -300,20 +300,23 @@
function roofFieldset(ctx) {
var field = ctx.field, help = ctx.help, config = ctx.config;
if (!config.roofmodel) config.roofmodel = {};
- var stored = config.roofmodel.has_geotorget_token;
+ var stored = config.roofmodel.has_stac_password;
return '';
+ '' +
+ // The forecast inputs — location, weather source, PV arrays, roof
+ // geometry. The sections are owned by tabs/weather.js, which no
+ // longer has a tab button of its own.
+ ((S.tabs.weather && S.tabs.weather.render) ? S.tabs.weather.render(ctx) : "");
+ },
+ after: function (ctx) {
+ // Wire the forecast-input sections rendered above (map, PV arrays,
+ // roof derivation) — weather.js owns their behaviour.
+ if (S.tabs.weather && S.tabs.weather.after) S.tabs.weather.after(ctx);
},
};
})();
diff --git a/web/settings/tabs/planner.js b/web/settings/tabs/planner.js
index 579126aa7..c6cc9bda0 100644
--- a/web/settings/tabs/planner.js
+++ b/web/settings/tabs/planner.js
@@ -1,4 +1,6 @@
-// Settings → Planner tab: MPC planner scalars.
+// Settings → Planner tab: MPC planner scalars. The forecast inputs the
+// planner consumes (location, weather source, PV arrays, roof geometry)
+// render on the Control tab via tabs/weather.js.
(function () {
var S = (window.FTWSettings = window.FTWSettings || { tabs: {} });
S.tabs = S.tabs || {};
@@ -71,7 +73,7 @@
kHtml = '
PV forecast safety k is not set in YAML. The Plan card slider owns it, anywhere from 0 to 2 in steps of 0.05.
' +
'';
}
- return '
' +
+ var mpcHtml = '
' +
'' +
'
' +
@@ -81,8 +83,8 @@
field("Max SoC (0–1)", "planner.soc_max", "number", 0.90,
"Highest SoC the planner will charge to. 0.90 = 90%.") +
'
' +
- '
' +
- '' +
+ '
';
+ var engineHtml = '' +
'Engine controls — leave these unless you are debugging.' +
'
' +
- 'The planner requires working price + weather forecasts. When disabled the system runs in the manual mode set on the Control page.' +
+ 'The planner reads its forecast inputs — location, weather source, PV arrays, roof geometry — from the Control tab, and needs a working price forecast from the Price tab. When disabled the system runs in the manual mode set on the Control page.' +
'
';
},
after: function (ctx) {
@@ -221,6 +224,7 @@
})
.catch(function () {}); // unreachable → line stays hidden
}
+
},
};
diff --git a/web/settings/tabs/weather.js b/web/settings/tabs/weather.js
index 5332bdccc..a380ef71c 100644
--- a/web/settings/tabs/weather.js
+++ b/web/settings/tabs/weather.js
@@ -1,13 +1,15 @@
-// Settings → Weather tab: forecast provider + location + PV arrays.
-// Owns its own MapLibre loader + PV-array editor + 3D preview loader
-// so the Settings shell stays weather-agnostic.
+// Forecast-input sections of the Settings → Control tab: forecast
+// provider + location + PV arrays. Registered as S.tabs.weather but
+// rendered inside the Control tab (control.js delegates here); there is
+// no Weather tab button. Owns its own MapLibre loader + PV-array editor
+// + 3D preview loader so the Settings shell stays weather-agnostic.
(function () {
var S = (window.FTWSettings = window.FTWSettings || { tabs: {} });
S.tabs = S.tabs || {};
// MapLibre GL JS (BSD-3), vendored under /vendor/maplibre and loaded on
// demand exactly the way this tab already lazy-loads its other heavy
- // optional dependency — the picker is ~1 MB and only the Weather tab needs
+ // optional dependency — the picker is ~1 MB and only these sections need
// it. Shipping it on the box follows the same policy as /vendor/three and
// the Leaflet copy this replaced: no third-party JS from a CDN, and the map
// must load when the gateway cannot reach the internet.