From f750ad92c5cb95da7369e5a74ec421aa069d341f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren <6883838+frahlg@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:01:01 +0200 Subject: [PATCH 1/2] feat(coverage): say where each data source works, instead of an empty chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Price-driven planning is Europe-only and nothing said so — a site outside ENTSO-E got an empty price curve with no explanation (#726). - go/internal/coverage is the registry: every forecast and price source declares its area, country list, licence, and a lat/lon box. - GET /api/data-sources answers for this site, or for a pin still being dragged. covers:false is definitive; covers:true means worth trying. - The Weather tab renders that under the map. The Price tab flags a location outside every European bidding zone. - docs/data-coverage.md is the prose; the registry is the source of truth. STRÅNG scoring and roof geometry are not shipped, so they are not advertised. #734 already carries a later version of this plus STRÅNG and should rebase onto this when it lands. Part of #726. --- .changeset/data-coverage-api.md | 7 + README.md | 1 + docs/architecture.md | 7 + docs/data-coverage.md | 95 +++++++++++++ go/internal/api/api.go | 78 ++++++++++ go/internal/api/api_datasources_test.go | 154 ++++++++++++++++++++ go/internal/api/security_test.go | 1 + go/internal/coverage/coverage.go | 180 ++++++++++++++++++++++++ go/internal/coverage/coverage_test.go | 174 +++++++++++++++++++++++ web/price-zone-picker.test.mjs | 7 + web/settings/tabs/price.js | 41 ++++++ web/settings/tabs/weather.js | 63 ++++++++- web/settings/tabs/weather.test.mjs | 33 ++++- web/style.css | 32 +++++ 14 files changed, 871 insertions(+), 2 deletions(-) create mode 100644 .changeset/data-coverage-api.md create mode 100644 docs/data-coverage.md create mode 100644 go/internal/api/api_datasources_test.go create mode 100644 go/internal/coverage/coverage.go create mode 100644 go/internal/coverage/coverage_test.go diff --git a/.changeset/data-coverage-api.md b/.changeset/data-coverage-api.md new file mode 100644 index 000000000..e984727de --- /dev/null +++ b/.changeset/data-coverage-api.md @@ -0,0 +1,7 @@ +--- +"ftw": minor +--- + +Settings now says when a location is outside a data source's domain, instead of leaving the operator to infer it from an empty chart. + +`GET /api/data-sources` reports every forecast and price source with its coverage area, country list, licence, whether a key is needed, and whether it reaches this site. The Weather tab renders that under the map; the Price tab flags a pin that sits outside every European bidding zone. Weather and PV forecasting still work worldwide; price-driven planning remains Europe-only, and there is still no manual or fixed-tariff provider. See `docs/data-coverage.md`. 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/architecture.md b/docs/architecture.md index d4a7447bf..8a1070c1e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -158,6 +158,13 @@ schema. The handlers registered in [`go/internal/api/api.go`](../go/internal/api/api.go) define the HTTP surface. Driver metadata defines the device catalog. These sources replace manually duplicated reference docs. +External data sources do not all work everywhere. Weather and PV forecasts +are worldwide; every spot-price provider is European, and there is no +manual tariff to stand in outside that domain. `GET /api/data-sources` +and [data-coverage.md](data-coverage.md) are the product statement of +where each source reaches. The registry lives in +[`go/internal/coverage`](../go/internal/coverage). + Some startup bindings cannot be hot-reloaded, including state paths, API listener and selected integration transports. Normal device and control configuration is reloaded through diff --git a/docs/data-coverage.md b/docs/data-coverage.md new file mode 100644 index 000000000..159deea49 --- /dev/null +++ b/docs/data-coverage.md @@ -0,0 +1,95 @@ +# Geographic coverage of external data sources + +FTW controls hardware anywhere, but it depends on external data for two +things that ship today: **spot prices** and **weather/PV forecasts**. Those +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.** +- There is **no manual or fixed-tariff price provider** to stand in outside + Europe. + +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 and Price settings tabs render the same data. This +file is the prose; [`go/internal/coverage`](../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. 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. + +Prices are stored in **minor units of the configured currency** per kWh +(öre, cent, øre, …). ENTSO-E figures that arrive in another currency are +converted with **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. + +The zone picker is served from `GET /api/prices/zones`, the same table the +fetchers use. Country first, zone second. See +[`go/internal/prices/zones.go`](../go/internal/prices/zones.go). + +## 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. Prefer `open_meteo` or +`forecast_solar` when the site has array geometry: they carry an irradiance +signal, which is what the orientation-aware plane-of-array model needs. + +## What is not in this tree yet + +Two further data sources are regional and are **not shipped** on this +branch. They are listed so a non-European site is not surprised later: + +| Capability | Planned source | Coverage when it lands | +|---|---|---| +| PV performance scoring / forecast calibration | SMHI STRÅNG historical irradiance ([#734](https://github.com/srcfl/ftw/pull/734), [#726](https://github.com/srcfl/ftw/issues/726)) | Nordic region only | +| Automatic roof geometry | Lantmäteriet via STAC ([#717](https://github.com/srcfl/ftw/discussions/717), [#735](https://github.com/srcfl/ftw/pull/735)) | Sweden by default; any conformant STAC catalog | + +Until those land, a site anywhere still has manual array geometry on the +Weather tab, and the self-learning PV twin still calibrates from measured +production. + +## What a non-European site loses today + +| 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 | +| Manual array geometry | Yes | diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 40bfec1b3..de61c95ff 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" @@ -495,6 +496,7 @@ 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/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 +2422,82 @@ 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, so a true near a corner +// still means "worth trying", not "guaranteed". False is reliable — that +// location is definitely not served. +// +// This exists because every price provider is European and nothing +// previously said so: a site outside those areas got an empty result and +// no explanation. See #726 and docs/data-coverage.md. +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 + } + if s.deps.CfgMu != nil { + 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) +} + // ---- 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..f6748a066 --- /dev/null +++ b/go/internal/api/api_datasources_test.go @@ -0,0 +1,154 @@ +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 findSource(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 TestDataSourcesListsEveryShippedSource(t *testing.T) { + resp := getDataSources(t, depsAt(59.33, 18.07), "") + for _, id := range []string{ + "met_no", "openweather", "open_meteo", "forecast_solar", + "sourceful", "elprisetjustnu", "entsoe", + } { + findSource(t, resp, id) + } +} + +func TestDataSourcesCoversNordicSite(t *testing.T) { + resp := getDataSources(t, depsAt(59.33, 18.07), "") + for _, id := range []string{"elprisetjustnu", "sourceful", "open_meteo"} { + s := findSource(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 every price provider does 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 := findSource(t, resp, id) + if s.Covers == nil || !*s.Covers { + t.Errorf("%s: forecast providers are worldwide, want covers=true", id) + } + } + for _, id := range []string{"sourceful", "elprisetjustnu", "entsoe"} { + s := findSource(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) + } + } +} + +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 := findSource(t, resp, "sourceful"); 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) + } +} + +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") + } +} + +func TestDataSourcesCarriesRegionMetadata(t *testing.T) { + resp := getDataSources(t, depsAt(59.33, 18.07), "") + + sf := findSource(t, resp, "sourceful") + if sf.Worldwide { + t.Error("sourceful must not be reported worldwide") + } + if sf.Area == "" || len(sf.Countries) == 0 { + t.Error("sourceful should carry an area and country list") + } + if sf.RequiresKey { + t.Error("sourceful needs no API key") + } + + if ow := findSource(t, resp, "openweather"); !ow.RequiresKey { + t.Error("openweather requires an API key") + } + if mn := findSource(t, resp, "met_no"); !mn.Worldwide { + t.Error("met_no is worldwide") + } + if ep := findSource(t, resp, "entsoe"); !ep.RequiresKey { + t.Error("entsoe requires an API key") + } +} diff --git a/go/internal/api/security_test.go b/go/internal/api/security_test.go index 82c746631..7783e9161 100644 --- a/go/internal/api/security_test.go +++ b/go/internal/api/security_test.go @@ -208,6 +208,7 @@ func TestAuthenticateLeavesOrdinaryReadsAndOAuthCallbackCompatible(t *testing.T) {method: http.MethodGet, path: "/api/oauth/myuplink/callback?code=code&state=state"}, {method: http.MethodGet, path: "/api/energy/history"}, {method: http.MethodGet, path: "/api/prices"}, + {method: http.MethodGet, path: "/api/data-sources"}, {method: http.MethodGet, path: "/api/mpc/plan"}, // Dashboard poll — must stay open so lan_auth does not pop a login // on every 2s status tick. diff --git a/go/internal/coverage/coverage.go b/go/internal/coverage/coverage.go new file mode 100644 index 000000000..ab18c123f --- /dev/null +++ b/go/internal/coverage/coverage.go @@ -0,0 +1,180 @@ +// Package coverage records where each external data source FTW talks to +// actually returns usable data. +// +// FTW controls hardware anywhere, but several of its sources are regional: +// every price provider is European. Nothing in the code said so, so a site +// in Australia would get an empty price curve 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. 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. +package coverage + +// Kind groups sources by what they supply, so the UI can present forecast +// and price coverage separately. +type Kind string + +const ( + KindForecast Kind = "forecast" + 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) +} + +// europeDomain is a generous box around ENTSO-E member markets. Iceland sits +// just inside the western edge and is not a bidding zone we serve — Covers +// true still means "worth trying". +var europeDomain = &BBox{MinLat: 34.0, MinLon: -25.0, MaxLat: 72.0, MaxLon: 45.0} + +// swedenDomain covers SE1–SE4. Northern Norway is outside it on purpose. +var swedenDomain = &BBox{MinLat: 55.0, MinLon: 10.0, MaxLat: 69.5, MaxLon: 24.5} + +// sources is the registry. Keep it ordered by kind then id so the API response +// is stable and diffs stay readable. Only sources that actually ship belong +// here; a planned source with no client is not a product. +var sources = []Source{ + { + 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: "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: "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: "openweather", Kind: KindForecast, Label: "OpenWeather", + Area: "Worldwide", + RequiresKey: true, + Note: "Cloud cover only — same cloud-derated prior as MET Norway.", + }, + { + ID: "elprisetjustnu", Kind: KindPrice, Label: "Elpriset just nu", + Area: "Sweden", + Countries: []string{"SE"}, + BBox: swedenDomain, + Note: "Swedish bidding zones SE1–SE4 only. No key required.", + }, + { + ID: "entsoe", Kind: KindPrice, Label: "ENTSO-E Transparency", + Area: "Europe", + Countries: europeanPriceCountries, + BBox: europeDomain, + RequiresKey: true, + Note: "ENTSO-E member bidding zones. Needs an operator-supplied API key.", + }, + { + ID: "sourceful", Kind: KindPrice, Label: "Sourceful (cached ENTSO-E)", + Area: "Europe", + Countries: europeanPriceCountries, + BBox: europeDomain, + Note: "European day-ahead bidding zones. No key required.", + }, +} + +// europeanPriceCountries are the ISO codes of countries that appear in the +// price zone table (go/internal/prices/zones.go). Shared by sourceful and +// entsoe because both resolve to the same underlying bidding zones. Keep in +// lock-step with that table — TestEuropeanPriceCountriesMatchZoneTable fails +// when a new country lands without being listed here. +var europeanPriceCountries = []string{ + "AT", "BE", "BG", "CH", "CZ", "DE", "DK", "EE", "ES", "FI", + "FR", "GR", "HR", "HU", "IT", "LT", "LU", "LV", "ME", "NL", + "NO", "PL", "PT", "RO", "RS", "SE", "SI", "SK", "UA", +} + +// 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..a844e5a2f --- /dev/null +++ b/go/internal/coverage/coverage_test.go @@ -0,0 +1,174 @@ +package coverage + +import ( + "testing" + + "github.com/srcfl/ftw/go/internal/prices" +) + +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) + } + 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) { + got := ForKind(KindPrice) + if len(got) == 0 { + t.Fatal("no price sources registered") + } + for _, s := range got { + 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) { + 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") + } + if Covers("elprisetjustnu", 69.65, 18.96) { + t.Error("elprisetjustnu must not claim Tromsø") + } +} + +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}, + {30, 40, true}, + {20, 30, true}, + {9.99, 30, false}, + {20, 40.01, false}, + } { + 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) + } + } +} + +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) + } + } + } +} + +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") + } +} + +// zoneCountryISO is the country-name → ISO 3166-1 alpha-2 map for every +// country currently in the price zone table. A new country in zones.go that +// is missing here is a coverage-registry miss, not a silent expansion. +var zoneCountryISO = map[string]string{ + "Austria": "AT", + "Belgium": "BE", + "Bulgaria": "BG", + "Croatia": "HR", + "Czech Republic": "CZ", + "Denmark": "DK", + "Estonia": "EE", + "Finland": "FI", + "France": "FR", + "Germany": "DE", + "Greece": "GR", + "Hungary": "HU", + "Italy": "IT", + "Latvia": "LV", + "Lithuania": "LT", + "Luxembourg": "LU", + "Montenegro": "ME", + "Netherlands": "NL", + "Norway": "NO", + "Poland": "PL", + "Portugal": "PT", + "Romania": "RO", + "Serbia": "RS", + "Slovakia": "SK", + "Slovenia": "SI", + "Spain": "ES", + "Sweden": "SE", + "Switzerland": "CH", + "Ukraine": "UA", +} + +func TestEuropeanPriceCountriesMatchZoneTable(t *testing.T) { + src, ok := ByID("sourceful") + if !ok { + t.Fatal("sourceful not registered") + } + have := map[string]bool{} + for _, c := range src.Countries { + have[c] = true + } + for _, z := range prices.Zones() { + code, mapped := zoneCountryISO[z.Country] + if !mapped { + t.Errorf("zone country %q has no ISO mapping; add it to coverage", z.Country) + continue + } + if !have[code] { + t.Errorf("sourceful countries missing %s (%s) from the zone table", code, z.Country) + } + } +} diff --git a/web/price-zone-picker.test.mjs b/web/price-zone-picker.test.mjs index 32ce707a1..0061f66c2 100644 --- a/web/price-zone-picker.test.mjs +++ b/web/price-zone-picker.test.mjs @@ -57,6 +57,13 @@ describe("price zone picker", () => { assert.match(priceTab, /FALLBACK/); assert.match(setupHtml, /