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, /
' +
'Sourceful is the keyless default and covers every European bidding zone ENTSO-E publishes. ' +
'elprisetjustnu.se remains a Sweden-only alternative. Direct ENTSO-E access needs an API key. ' +
+ 'There is no provider for markets outside Europe, and no manual or fixed-tariff option. ' +
'FX rates come from ECB daily.' +
'
';
},
@@ -186,6 +188,7 @@
if (note) {
note.style.display = currency !== "SEK" && Number(vat) === 25 ? "block" : "none";
}
+ renderPriceCoverage(ctx);
if (!input || !warn) return;
function check() {
var v = parseFloat(input.value);
@@ -197,4 +200,42 @@
check();
},
};
+
+ // Price sources are European. If the Weather-tab pin is outside that
+ // domain, say so here rather than leaving the operator to infer it from
+ // an empty chart. See #726.
+ function renderPriceCoverage(ctx) {
+ var host = document.getElementById("price-coverage");
+ if (!host) return;
+ var w = (ctx.config && ctx.config.weather) || {};
+ var lat = w.latitude, lon = w.longitude;
+ var q = "";
+ if (lat != null && lon != null && (Number(lat) !== 0 || Number(lon) !== 0)) {
+ q = "?lat=" + encodeURIComponent(lat) + "&lon=" + encodeURIComponent(lon);
+ }
+ var fetchFn = ctx.apiFetch || fetch;
+ fetchFn("/api/data-sources" + q)
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (d) {
+ if (!d || !Array.isArray(d.sources)) return;
+ var prices = d.sources.filter(function (s) { return s.kind === "price"; });
+ if (!prices.length) return;
+ var covered = prices.filter(function (s) { return s.covers !== false; });
+ var esc = ctx.escHtml;
+ if (q && covered.length === 0) {
+ host.innerHTML = '
' +
+ "This location is outside every price source. Price-driven planning " +
+ "needs a European bidding zone — there is no provider for markets " +
+ "outside Europe.
";
+ return;
+ }
+ var names = prices.map(function (s) {
+ var ok = s.covers !== false;
+ return esc(s.label) + (ok ? "" : " (not this location)");
+ }).join(", ");
+ host.innerHTML = '
Price sources: ' +
+ names + ".
";
+ })
+ .catch(function () { /* coverage is advisory; the static note remains */ });
+ }
})();
diff --git a/web/settings/tabs/weather.js b/web/settings/tabs/weather.js
index 2eb6b174c..184e3133d 100644
--- a/web/settings/tabs/weather.js
+++ b/web/settings/tabs/weather.js
@@ -152,6 +152,7 @@
lonInput.value = lo.toFixed(4);
setByPath(ctx.config, "weather.latitude", la);
setByPath(ctx.config, "weather.longitude", lo);
+ renderCoverage(ctx, la, lo);
}
marker.on("dragend", function () {
var ll = marker.getLatLng();
@@ -166,6 +167,7 @@
if (!isNaN(la) && !isNaN(lo)) {
marker.setLatLng([la, lo]);
map.panTo([la, lo]);
+ renderCoverage(ctx, la, lo);
}
}
latInput.addEventListener("change", syncFromInputs);
@@ -173,6 +175,60 @@
setTimeout(function () { map.invalidateSize(); }, 150);
}
+ // Data-source coverage for the current pin. Every price provider is
+ // European and until now nothing said so — a site outside them just got
+ // empty results. Rendered from GET /api/data-sources, which answers for
+ // a specific lat/lon.
+ var coverageSeq = 0;
+ function renderCoverage(ctx, lat, lon) {
+ var host = document.getElementById("data-coverage");
+ if (!host) return;
+ // Coordinates change on marker drag; keep only the newest response so
+ // a slow early request cannot overwrite a fast later one.
+ var seq = ++coverageSeq;
+ var q = lat != null && lon != null
+ ? "?lat=" + encodeURIComponent(lat) + "&lon=" + encodeURIComponent(lon)
+ : "";
+ var fetchFn = ctx.apiFetch || fetch;
+ fetchFn("/api/data-sources" + q)
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (d) {
+ if (seq !== coverageSeq || !d || !Array.isArray(d.sources)) return;
+ host.innerHTML = coverageHTML(ctx.escHtml, d.sources);
+ })
+ .catch(function () { /* coverage is advisory; never break the tab */ });
+ }
+
+ var kindLabels = { forecast: "Forecast", irradiance: "Irradiance", price: "Price" };
+ var kindOrder = ["forecast", "irradiance", "price"];
+
+ function coverageHTML(esc, sources) {
+ var unavailable = sources.filter(function (s) { return s.covers === false; });
+ var rows = kindOrder.map(function (kind) {
+ var list = sources.filter(function (s) { return s.kind === kind; });
+ if (!list.length) return "";
+ var names = list.map(function (s) {
+ var ok = s.covers !== false;
+ var mark = ok ? "✓" : "✕";
+ var title = (s.area || "") + (s.note ? " — " + s.note : "");
+ return '' + mark + " " + esc(s.label) + "";
+ }).join(' · ');
+ return '
";
+ }).join("");
+ var warn = "";
+ if (unavailable.length) {
+ var msg = unavailable.length === 1
+ ? unavailable[0].label + " does not cover this location."
+ : unavailable.length + " sources do not cover this location.";
+ warn = '
' + esc(msg) + " Hover for details.
";
+ }
+ return '
' +
+ '
Data sources available here
' +
+ rows + warn + "
";
+ }
+
function arraysSummary(n) {
var count = Number(n) || 0;
if (count > 0) {
@@ -211,6 +267,7 @@
'' +
'' +
'
Click or drag the marker to set your location.
' +
+ '' +
field("PV rated (W)", "weather.pv_rated_w", "number", 10000) +
field("API key (OpenWeather only)", "weather.api_key", "text", "") +
'' +
@@ -233,6 +290,10 @@
initWeatherMap(ctx);
renderPVArrays(ctx);
refreshArraysSummary(ctx.config);
+ // The map may fail (no WebGL); coverage must still render, so drive
+ // it from the numeric fields rather than from the map.
+ var w = (ctx.config && ctx.config.weather) || {};
+ renderCoverage(ctx, w.latitude, w.longitude);
var addBtn = document.getElementById("pv-array-add");
if (addBtn) addBtn.addEventListener("click", function () {
ctx.config.weather.pv_arrays.push({ name: "", rated_w: 0, tilt_deg: 35, azimuth_deg: 180 });
@@ -242,5 +303,5 @@
},
};
- S.tabs.weather._pure = { arraysSummary: arraysSummary };
+ S.tabs.weather._pure = { arraysSummary: arraysSummary, coverageHTML: coverageHTML };
})();
diff --git a/web/settings/tabs/weather.test.mjs b/web/settings/tabs/weather.test.mjs
index 592fcd781..f60d1e1ce 100644
--- a/web/settings/tabs/weather.test.mjs
+++ b/web/settings/tabs/weather.test.mjs
@@ -7,7 +7,7 @@ const source = readFileSync(new URL("./weather.js", import.meta.url), "utf8");
globalThis.window = {};
await import("./weather.js");
const tab = globalThis.window.FTWSettings.tabs.weather;
-const { arraysSummary } = tab._pure;
+const { arraysSummary, coverageHTML } = tab._pure;
function stubCtx(weather) {
return {
@@ -82,3 +82,34 @@ describe("weather household path", () => {
assert.doesNotMatch(source, /observed.*pv_rated_w|peak.*pv_rated_w/i);
});
});
+
+describe("weather data-source coverage", () => {
+ it("reserves a slot under the map for coverage of this pin", () => {
+ const html = tab.render(stubCtx());
+ const normal = html.slice(0, html.indexOf(" {
+ const esc = (s) => String(s);
+ const sydney = coverageHTML(esc, [
+ { kind: "forecast", label: "Open-Meteo", area: "Worldwide", covers: true },
+ { kind: "price", label: "Sourceful (cached ENTSO-E)", area: "Europe", covers: false, note: "European day-ahead" },
+ { kind: "price", label: "Elpriset just nu", area: "Sweden", covers: false },
+ ]);
+ assert.match(sydney, /Data sources available here/);
+ assert.match(sydney, /Forecast/);
+ assert.match(sydney, /Price/);
+ assert.match(sydney, /data-coverage-miss/);
+ assert.match(sydney, /2 sources do not cover this location/);
+ assert.doesNotMatch(sydney, /Irradiance/);
+
+ const stockholm = coverageHTML(esc, [
+ { kind: "forecast", label: "Open-Meteo", area: "Worldwide", covers: true },
+ { kind: "price", label: "Sourceful (cached ENTSO-E)", area: "Europe", covers: true },
+ ]);
+ assert.doesNotMatch(stockholm, /data-coverage-miss/);
+ assert.doesNotMatch(stockholm, /does not cover this location/);
+ });
+});
diff --git a/web/style.css b/web/style.css
index df0e04bf6..a9403b1ef 100644
--- a/web/style.css
+++ b/web/style.css
@@ -1600,6 +1600,38 @@ footer {
border-color: var(--amber) !important;
background: color-mix(in oklch, var(--amber) 10%, transparent) !important;
}
+#data-coverage { margin: 0 0 10px; }
+.data-coverage-panel {
+ font-size: 0.72rem;
+ font-family: var(--mono);
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 8px 10px;
+ background: var(--ink-sunken);
+}
+.data-coverage-title {
+ color: var(--text-dim);
+ margin-bottom: 4px;
+}
+.data-coverage-row { margin: 2px 0; }
+.data-coverage-kind {
+ display: inline-block;
+ min-width: 74px;
+ color: var(--text-dim);
+}
+.data-coverage-ok { color: var(--text-dim); }
+.data-coverage-miss { color: var(--amber); }
+.data-coverage-sep { color: var(--text-dim); }
+.data-coverage-warn {
+ color: var(--amber);
+ font-size: 0.72rem;
+ margin: 6px 0 0;
+}
+.data-coverage-note {
+ color: var(--text-dim);
+ font-size: 0.8rem;
+ margin: 8px 0 0;
+}
.plan-summary {
font-size: 0.8rem;
color: var(--text-dim, #a0a0a0);
From b8ecc20a829044efc9711e8b28050cc353c0adfb Mon Sep 17 00:00:00 2001
From: Fredrik Ahlgren <6883838+frahlg@users.noreply.github.com>
Date: Thu, 3 Sep 2026 07:28:11 +0200
Subject: [PATCH 2/2] feat(prices): static / time-of-use tariff so planning
works outside Europe
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The coverage API and settings panel are gone — #734 already owns that
surface. What a non-European site actually needed was a price source.
price.provider: static takes a flat rate in the install currency, plus
optional local-time windows (overnight wrap, optional weekdays). Grid
tariff and VAT still apply. Live day-ahead remains Europe-only.
Part of #726.
---
.changeset/data-coverage-api.md | 7 -
.changeset/static-price-provider.md | 7 +
docs/architecture.md | 9 +-
docs/data-coverage.md | 98 ++++++------
go/internal/api/api.go | 78 ---------
go/internal/api/api_datasources_test.go | 154 ------------------
go/internal/api/security_test.go | 1 -
go/internal/config/config.go | 50 +++++-
go/internal/config/config_test.go | 34 ++++
go/internal/coverage/coverage.go | 180 ---------------------
go/internal/coverage/coverage_test.go | 174 ---------------------
go/internal/prices/prices.go | 10 +-
go/internal/prices/static.go | 160 +++++++++++++++++++
go/internal/prices/static_test.go | 186 ++++++++++++++++++++++
web/components/price-units.js | 4 +
web/components/price-units.test.mjs | 2 +-
web/price-zone-picker.test.mjs | 10 +-
web/settings/tabs/price.js | 200 +++++++++++++++---------
web/settings/tabs/weather.js | 63 +-------
web/settings/tabs/weather.test.mjs | 33 +---
web/setup.html | 6 +
web/setup.js | 27 +++-
web/setup.test.mjs | 2 +-
web/style.css | 32 ----
24 files changed, 666 insertions(+), 861 deletions(-)
delete mode 100644 .changeset/data-coverage-api.md
create mode 100644 .changeset/static-price-provider.md
delete mode 100644 go/internal/api/api_datasources_test.go
delete mode 100644 go/internal/coverage/coverage.go
delete mode 100644 go/internal/coverage/coverage_test.go
create mode 100644 go/internal/prices/static.go
create mode 100644 go/internal/prices/static_test.go
diff --git a/.changeset/data-coverage-api.md b/.changeset/data-coverage-api.md
deleted file mode 100644
index e984727de..000000000
--- a/.changeset/data-coverage-api.md
+++ /dev/null
@@ -1,7 +0,0 @@
----
-"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/.changeset/static-price-provider.md b/.changeset/static-price-provider.md
new file mode 100644
index 000000000..fdfc6fc92
--- /dev/null
+++ b/.changeset/static-price-provider.md
@@ -0,0 +1,7 @@
+---
+"ftw": minor
+---
+
+A static / time-of-use price provider, so price-driven planning works outside Europe.
+
+`price.provider: static` takes a flat rate in the install currency, plus optional local-time windows that override peak hours (overnight wrap, optional weekdays). Grid tariff and VAT still apply. Live day-ahead spot remains Europe-only; this is the stand-in for every other market. Settings and the setup wizard offer it next to Sourceful and ENTSO-E.
diff --git a/docs/architecture.md b/docs/architecture.md
index 8a1070c1e..209a7c1f4 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -159,11 +159,10 @@ schema. The handlers registered in
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).
+are worldwide; live day-ahead spot is European. A site outside ENTSO-E
+uses `price.provider: static` — a flat rate or time-of-use schedule —
+which is how price-driven planning works without a market feed. See
+[data-coverage.md](data-coverage.md).
Some startup bindings cannot be hot-reloaded, including state paths, API
listener and selected integration transports. Normal device and control
diff --git a/docs/data-coverage.md b/docs/data-coverage.md
index 159deea49..e61cde9fa 100644
--- a/docs/data-coverage.md
+++ b/docs/data-coverage.md
@@ -1,32 +1,18 @@
# 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:
+FTW controls hardware anywhere. The data it plans with does not.
- **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.
+- **Day-ahead spot prices work in Europe only.**
+- **A static / time-of-use tariff works anywhere.** That is how a site
+ outside ENTSO-E (or on a flat retail contract inside it) gets
+ price-driven planning.
-> **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.
+A site outside Europe still gets monitoring, safety, control, the
+dashboard, weather forecasts and the self-learning PV twin. What it did
+not have, until `price.provider: static`, was a price source.
-## Spot prices — Europe only
+## Spot prices — Europe, or a tariff you type
Configured under `price.provider`.
@@ -34,24 +20,42 @@ Configured under `price.provider`.
|---|---|---|---|
| `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. |
+| `entsoe` | ENTSO-E member markets | Yes | Direct from the Transparency Platform. |
+| `static` | **Worldwide** | No | Flat rate or time-of-use schedule the operator types. |
| `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.
+There is still no live adapter for CAISO, ERCOT, PJM, AEMO/NEM, JEPX or
+the other non-European markets. `static` is the stand-in: a US TOU
+tariff, a Japanese flat contract, or any other schedule that can be
+written as hours and a rate.
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.
+(öre, cent, ¢, pence, …). ENTSO-E figures that arrive in another
+currency are converted with **ECB** daily FX rates. Static prices are
+already in the install currency; nothing is converted.
> 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).
+> meter readings, not prices, so it is not a fifth price source.
+
+```yaml
+price:
+ provider: static
+ currency: USD
+ static_ore_kwh: 8 # off-peak, ¢/kWh (minor units)
+ static_tou:
+ - { start: "07:00", end: "23:00", ore_kwh: 22 } # peak
+ grid_tariff_ore_kwh: 4
+ vat_percent: 0
+```
+
+Windows are local time on the box. First match wins; hours they miss
+keep `static_ore_kwh`. Overnight windows wrap (`22:00`–`06:00`). An
+optional `days` list (`mon`…`sun`) limits a window to those weekdays.
+
+The zone picker is served from `GET /api/prices/zones` and is hidden
+when the provider is `static`. See
+[`go/internal/prices/zones.go`](../go/internal/prices/zones.go) and
+[`go/internal/prices/static.go`](../go/internal/prices/static.go).
## Weather and PV forecasts — worldwide
@@ -64,26 +68,21 @@ Configured under `weather.provider`. All four work at any latitude/longitude.
| `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.
+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 |
+| PV performance scoring / forecast calibration | SMHI STRÅNG ([#734](https://github.com/srcfl/ftw/pull/734)) | Nordic region only |
+| Automatic roof geometry | Lantmäteriet via STAC ([#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.
+Until those land, array geometry stays a manual entry on the Weather
+tab, and the PV twin still calibrates from measured production.
-## What a non-European site loses today
+## What a non-European site has today
| Capability | Works outside Europe? |
|---|---|
@@ -91,5 +90,6 @@ production.
| Telemetry, history, dashboard | Yes |
| Weather + PV forecasting | Yes |
| Self-learning PV twin | Yes |
-| Price-driven planning / optimisation | **No** — no price source |
+| Price-driven planning | **Yes, via `static`** |
+| Live day-ahead spot | **No** — Europe only |
| Manual array geometry | Yes |
diff --git a/go/internal/api/api.go b/go/internal/api/api.go
index de61c95ff..40bfec1b3 100644
--- a/go/internal/api/api.go
+++ b/go/internal/api/api.go
@@ -36,7 +36,6 @@ 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"
@@ -496,7 +495,6 @@ 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)
@@ -2422,82 +2420,6 @@ 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
deleted file mode 100644
index f6748a066..000000000
--- a/go/internal/api/api_datasources_test.go
+++ /dev/null
@@ -1,154 +0,0 @@
-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 7783e9161..82c746631 100644
--- a/go/internal/api/security_test.go
+++ b/go/internal/api/security_test.go
@@ -208,7 +208,6 @@ 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/config/config.go b/go/internal/config/config.go
index ab95bd361..d43dcf0f6 100644
--- a/go/internal/config/config.go
+++ b/go/internal/config/config.go
@@ -1411,7 +1411,7 @@ type StateConf struct {
// Price is the spot-price source config.
type Price struct {
- Provider string `yaml:"provider" json:"provider"` // sourceful | elprisetjustnu | entsoe | none
+ Provider string `yaml:"provider" json:"provider"` // sourceful | elprisetjustnu | entsoe | static | none
Zone string `yaml:"zone,omitempty" json:"zone,omitempty"`
GridTariffOreKwh float64 `yaml:"grid_tariff_ore_kwh,omitempty" json:"grid_tariff_ore_kwh,omitempty"`
VATPercent float64 `yaml:"vat_percent,omitempty" json:"vat_percent,omitempty"`
@@ -1438,6 +1438,37 @@ type Price struct {
// most Swedish customer agreements pass through. Set to a pointer
// to 0.0 if you have a guaranteed-zero-floor agreement.
ExportFloorOreKwh *float64 `yaml:"export_floor_ore_kwh,omitempty" json:"export_floor_ore_kwh,omitempty"`
+
+ // StaticOreKwh is the default energy price in minor units per kWh when
+ // provider=static. Time-of-use windows below override matching hours;
+ // hours they miss keep this rate. Zero is a real price (free energy).
+ StaticOreKwh float64 `yaml:"static_ore_kwh,omitempty" json:"static_ore_kwh,omitempty"`
+ // StaticTOU is an optional time-of-use schedule in the box's local
+ // time. First matching window wins. An empty Days list means every day.
+ StaticTOU []TOUWindow `yaml:"static_tou,omitempty" json:"static_tou,omitempty"`
+}
+
+// TOUWindow is one time-of-use period for provider=static.
+type TOUWindow struct {
+ Start string `yaml:"start" json:"start"` // "HH:MM" local, inclusive
+ End string `yaml:"end" json:"end"` // "HH:MM" local, exclusive; "24:00" allowed
+ OreKwh float64 `yaml:"ore_kwh" json:"ore_kwh"`
+ Days []string `yaml:"days,omitempty" json:"days,omitempty"` // mon..sun; empty = every day
+}
+
+// Validate checks a static tariff's windows. Other providers are accepted
+// as-is: an unknown name currently disables fetching rather than failing
+// config load, and that behaviour stays.
+func (p *Price) Validate() error {
+ if p == nil || !strings.EqualFold(p.Provider, "static") {
+ return nil
+ }
+ for i, w := range p.StaticTOU {
+ if strings.TrimSpace(w.Start) == "" || strings.TrimSpace(w.End) == "" {
+ return fmt.Errorf("price.static_tou[%d]: start and end are required (HH:MM)", i)
+ }
+ }
+ return nil
}
// Weather is the weather-forecast source config.
@@ -1562,6 +1593,18 @@ func (c Config) MaskSecrets() Config {
if out.Price != nil {
cp := *out.Price
cp.APIKey = ""
+ if len(cp.StaticTOU) > 0 {
+ tou := make([]TOUWindow, len(cp.StaticTOU))
+ copy(tou, cp.StaticTOU)
+ for i := range tou {
+ if len(tou[i].Days) > 0 {
+ days := make([]string, len(tou[i].Days))
+ copy(days, tou[i].Days)
+ tou[i].Days = days
+ }
+ }
+ cp.StaticTOU = tou
+ }
out.Price = &cp
}
if out.Weather != nil {
@@ -2170,6 +2213,11 @@ func (c *Config) Validate() error {
if err := c.Assistant.Validate(); err != nil {
return err
}
+ if c.Price != nil {
+ if err := c.Price.Validate(); err != nil {
+ return err
+ }
+ }
if err := c.validateVehicles(); err != nil {
return err
}
diff --git a/go/internal/config/config_test.go b/go/internal/config/config_test.go
index d5a4658d1..eec42c553 100644
--- a/go/internal/config/config_test.go
+++ b/go/internal/config/config_test.go
@@ -628,6 +628,40 @@ batteries:
}
}
+func TestStaticPriceParsesTOU(t *testing.T) {
+ yaml := minimalYAML + `
+price:
+ provider: static
+ currency: USD
+ static_ore_kwh: 8
+ static_tou:
+ - start: "07:00"
+ end: "23:00"
+ ore_kwh: 22
+ days: [mon, tue, wed, thu, fri]
+`
+ c, err := Parse([]byte(yaml), ".")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.Price.Provider != "static" || c.Price.StaticOreKwh != 8 {
+ t.Fatalf("flat = %+v", c.Price)
+ }
+ if len(c.Price.StaticTOU) != 1 || c.Price.StaticTOU[0].Start != "07:00" {
+ t.Fatalf("tou = %+v", c.Price.StaticTOU)
+ }
+ if err := c.Validate(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestStaticPriceRejectsEmptyTOUWindow(t *testing.T) {
+ p := &Price{Provider: "static", StaticTOU: []TOUWindow{{Start: "", End: "23:00"}}}
+ if err := p.Validate(); err == nil {
+ t.Fatal("expected error")
+ }
+}
+
func TestPVArrayGeometryDistinguishesMissingFromZero(t *testing.T) {
yaml := minimalYAML + `
weather:
diff --git a/go/internal/coverage/coverage.go b/go/internal/coverage/coverage.go
deleted file mode 100644
index ab18c123f..000000000
--- a/go/internal/coverage/coverage.go
+++ /dev/null
@@ -1,180 +0,0 @@
-// 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
deleted file mode 100644
index a844e5a2f..000000000
--- a/go/internal/coverage/coverage_test.go
+++ /dev/null
@@ -1,174 +0,0 @@
-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/go/internal/prices/prices.go b/go/internal/prices/prices.go
index 3defa4963..ae3b276b8 100644
--- a/go/internal/prices/prices.go
+++ b/go/internal/prices/prices.go
@@ -11,6 +11,8 @@
// hourly if the provider returns that.
// - entsoe — All EU, needs ENTSO-E transparency platform API key.
// Resolution varies per bidding zone (15m or 60m).
+// - static — Operator-supplied flat rate or time-of-use schedule. No
+// network. Works anywhere, including markets with no day-ahead feed.
//
// Consumer price = (spot + grid_tariff) × (1 + VAT/100). We store both
// pure spot AND the consumer total so the UI can surface either.
@@ -563,7 +565,11 @@ func FromConfig(cfg *config.Price, st *state.Store, fx FXConverter) *Service {
}
zone := cfg.Zone
if zone == "" {
- zone = "SE3"
+ if cfg.Provider == "static" {
+ zone = "STATIC"
+ } else {
+ zone = "SE3"
+ }
}
// An unset currency follows the zone: picking BE should not leave a
// Belgian household paying in öre. Unknown zones keep the old default.
@@ -588,6 +594,8 @@ func FromConfig(cfg *config.Price, st *state.Store, fx FXConverter) *Service {
ep.Currency = currency
ep.FX = fx
p = ep
+ case "static":
+ p = NewStatic(cfg)
default:
return nil
}
diff --git a/go/internal/prices/static.go b/go/internal/prices/static.go
new file mode 100644
index 000000000..88cf1b303
--- /dev/null
+++ b/go/internal/prices/static.go
@@ -0,0 +1,160 @@
+package prices
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/srcfl/ftw/go/internal/config"
+)
+
+// StaticProvider synthesises a day of prices from a configured flat rate
+// or time-of-use schedule. It is the worldwide stand-in for markets that
+// have no day-ahead feed: a US TOU tariff, a Japanese flat contract, or
+// any other schedule the operator can type.
+//
+// Rates are minor units per kWh of the install currency (the same unit as
+// grid_tariff_ore_kwh). Fetch converts them to major units so Apply can
+// turn them back — the same path every other provider uses.
+type StaticProvider struct {
+ OreKwh float64
+ TOU []config.TOUWindow
+ // Loc is the timezone windows are interpreted in. Nil means the box's
+ // local zone, which is what a household tariff is billed in.
+ Loc *time.Location
+}
+
+func NewStatic(cfg *config.Price) *StaticProvider {
+ if cfg == nil {
+ return &StaticProvider{}
+ }
+ tou := append([]config.TOUWindow(nil), cfg.StaticTOU...)
+ return &StaticProvider{OreKwh: cfg.StaticOreKwh, TOU: tou}
+}
+
+func (s *StaticProvider) Name() string { return "static" }
+
+func (s *StaticProvider) loc() *time.Location {
+ if s != nil && s.Loc != nil {
+ return s.Loc
+ }
+ return time.Local
+}
+
+// Fetch returns 96 quarter-hour slots for the civil day of `day` in the
+// provider's location. Zone is ignored: a static tariff is not a bidding
+// zone. An empty result is never returned — a configured static source
+// always has a price, including zero.
+func (s *StaticProvider) Fetch(_ context.Context, _ string, day time.Time) ([]RawPrice, error) {
+ loc := s.loc()
+ start := time.Date(day.In(loc).Year(), day.In(loc).Month(), day.In(loc).Day(), 0, 0, 0, 0, loc)
+ const slotMin = 15
+ out := make([]RawPrice, 0, 96)
+ for i := 0; i < 96; i++ {
+ t := start.Add(time.Duration(i*slotMin) * time.Minute)
+ ore, err := s.rateAt(t)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, RawPrice{
+ SlotStart: t,
+ SlotLenMin: slotMin,
+ SEKPerKWh: ore / 100,
+ })
+ }
+ return out, nil
+}
+
+func (s *StaticProvider) rateAt(t time.Time) (float64, error) {
+ t = t.In(s.loc())
+ mins := t.Hour()*60 + t.Minute()
+ dow := strings.ToLower(t.Weekday().String()[:3]) // sun, mon, …
+ for _, w := range s.TOU {
+ ok, err := windowMatches(w, mins, dow)
+ if err != nil {
+ return 0, err
+ }
+ if ok {
+ return w.OreKwh, nil
+ }
+ }
+ return s.OreKwh, nil
+}
+
+func windowMatches(w config.TOUWindow, mins int, dow string) (bool, error) {
+ if !daysMatch(w.Days, dow) {
+ return false, nil
+ }
+ start, err := parseClockMinutes(w.Start)
+ if err != nil {
+ return false, fmt.Errorf("static TOU start %q: %w", w.Start, err)
+ }
+ end, err := parseClockMinutes(w.End)
+ if err != nil {
+ return false, fmt.Errorf("static TOU end %q: %w", w.End, err)
+ }
+ if start == end {
+ return false, fmt.Errorf("static TOU window %s–%s is empty", w.Start, w.End)
+ }
+ if start < end {
+ return mins >= start && mins < end, nil
+ }
+ // Overnight: 22:00–06:00.
+ return mins >= start || mins < end, nil
+}
+
+func daysMatch(days []string, dow string) bool {
+ if len(days) == 0 {
+ return true
+ }
+ for _, d := range days {
+ if dayToken(d) == dow {
+ return true
+ }
+ }
+ return false
+}
+
+func dayToken(s string) string {
+ s = strings.ToLower(strings.TrimSpace(s))
+ switch s {
+ case "sunday", "sun":
+ return "sun"
+ case "monday", "mon":
+ return "mon"
+ case "tuesday", "tue", "tues":
+ return "tue"
+ case "wednesday", "wed":
+ return "wed"
+ case "thursday", "thu", "thur", "thurs":
+ return "thu"
+ case "friday", "fri":
+ return "fri"
+ case "saturday", "sat":
+ return "sat"
+ default:
+ if len(s) >= 3 {
+ return s[:3]
+ }
+ return s
+ }
+}
+
+// parseClockMinutes reads "HH:MM" or "H:MM". "24:00" is midnight at the
+// end of the day (1440), so a window can close on the civil day boundary.
+func parseClockMinutes(s string) (int, error) {
+ s = strings.TrimSpace(s)
+ var h, m int
+ n, err := fmt.Sscanf(s, "%d:%d", &h, &m)
+ if err != nil || n != 2 {
+ return 0, fmt.Errorf("want HH:MM")
+ }
+ if h == 24 && m == 0 {
+ return 24 * 60, nil
+ }
+ if h < 0 || h > 23 || m < 0 || m > 59 {
+ return 0, fmt.Errorf("hour 0–23 and minute 0–59, or 24:00")
+ }
+ return h*60 + m, nil
+}
diff --git a/go/internal/prices/static_test.go b/go/internal/prices/static_test.go
new file mode 100644
index 000000000..abdf5b991
--- /dev/null
+++ b/go/internal/prices/static_test.go
@@ -0,0 +1,186 @@
+package prices
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/srcfl/ftw/go/internal/config"
+)
+
+func TestStaticFlatRateFillsADay(t *testing.T) {
+ loc := time.FixedZone("test", 2*3600)
+ p := &StaticProvider{OreKwh: 12.5, Loc: loc}
+ day := time.Date(2026, 9, 3, 15, 0, 0, 0, loc)
+ rows, err := p.Fetch(context.Background(), "ignored", day)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rows) != 96 {
+ t.Fatalf("slots = %d, want 96", len(rows))
+ }
+ if rows[0].SlotStart.Hour() != 0 || rows[0].SlotStart.Minute() != 0 {
+ t.Errorf("first slot = %s, want local midnight", rows[0].SlotStart)
+ }
+ if rows[0].SlotLenMin != 15 {
+ t.Errorf("slot = %d", rows[0].SlotLenMin)
+ }
+ // 12.5 minor units → 0.125 major, the unit Apply multiplies by 100.
+ if rows[0].SEKPerKWh != 0.125 {
+ t.Errorf("major = %g, want 0.125", rows[0].SEKPerKWh)
+ }
+ last := rows[len(rows)-1]
+ if last.SlotStart.Hour() != 23 || last.SlotStart.Minute() != 45 {
+ t.Errorf("last slot = %s", last.SlotStart)
+ }
+ if last.SEKPerKWh != rows[0].SEKPerKWh {
+ t.Errorf("flat rate drifted: first %g last %g", rows[0].SEKPerKWh, last.SEKPerKWh)
+ }
+}
+
+func TestStaticTOUOverridesFlat(t *testing.T) {
+ loc := time.FixedZone("test", 0)
+ p := &StaticProvider{
+ OreKwh: 8,
+ Loc: loc,
+ TOU: []config.TOUWindow{
+ {Start: "07:00", End: "23:00", OreKwh: 22},
+ },
+ }
+ day := time.Date(2026, 9, 3, 0, 0, 0, 0, loc) // Thursday
+ rows, err := p.Fetch(context.Background(), "", day)
+ if err != nil {
+ t.Fatal(err)
+ }
+ at := func(h, m int) float64 {
+ t.Helper()
+ want := time.Date(2026, 9, 3, h, m, 0, 0, loc)
+ for _, r := range rows {
+ if r.SlotStart.Equal(want) {
+ return r.SEKPerKWh * 100
+ }
+ }
+ t.Fatalf("missing slot %02d:%02d", h, m)
+ return 0
+ }
+ if got := at(6, 45); got != 8 {
+ t.Errorf("06:45 = %g, want off-peak 8", got)
+ }
+ if got := at(7, 0); got != 22 {
+ t.Errorf("07:00 = %g, want peak 22", got)
+ }
+ if got := at(22, 45); got != 22 {
+ t.Errorf("22:45 = %g, want peak 22", got)
+ }
+ if got := at(23, 0); got != 8 {
+ t.Errorf("23:00 = %g, want off-peak 8", got)
+ }
+}
+
+func TestStaticOvernightWindow(t *testing.T) {
+ loc := time.UTC
+ p := &StaticProvider{
+ OreKwh: 20,
+ Loc: loc,
+ TOU: []config.TOUWindow{
+ {Start: "22:00", End: "06:00", OreKwh: 5},
+ },
+ }
+ day := time.Date(2026, 9, 3, 12, 0, 0, 0, loc)
+ rows, err := p.Fetch(context.Background(), "", day)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ore := func(h int) float64 {
+ t.Helper()
+ want := time.Date(2026, 9, 3, h, 0, 0, 0, loc)
+ for _, r := range rows {
+ if r.SlotStart.Equal(want) {
+ return r.SEKPerKWh * 100
+ }
+ }
+ t.Fatalf("missing hour %d", h)
+ return 0
+ }
+ if ore(22) != 5 || ore(5) != 5 {
+ t.Errorf("overnight: 22h=%g 5h=%g, want 5", ore(22), ore(5))
+ }
+ if ore(6) != 20 || ore(12) != 20 {
+ t.Errorf("daytime: 6h=%g 12h=%g, want 20", ore(6), ore(12))
+ }
+}
+
+func TestStaticWeekdayWindows(t *testing.T) {
+ loc := time.UTC
+ p := &StaticProvider{
+ OreKwh: 10,
+ Loc: loc,
+ TOU: []config.TOUWindow{
+ {Start: "00:00", End: "24:00", OreKwh: 30, Days: []string{"sat", "sunday"}},
+ },
+ }
+ thu := time.Date(2026, 9, 3, 0, 0, 0, 0, loc) // Thursday
+ sat := time.Date(2026, 9, 5, 0, 0, 0, 0, loc)
+ thuRows, err := p.Fetch(context.Background(), "", thu)
+ if err != nil {
+ t.Fatal(err)
+ }
+ satRows, err := p.Fetch(context.Background(), "", sat)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if thuRows[48].SEKPerKWh*100 != 10 {
+ t.Errorf("Thursday noon = %g, want weekday 10", thuRows[48].SEKPerKWh*100)
+ }
+ if satRows[48].SEKPerKWh*100 != 30 {
+ t.Errorf("Saturday noon = %g, want weekend 30", satRows[48].SEKPerKWh*100)
+ }
+}
+
+func TestStaticZeroIsARealPrice(t *testing.T) {
+ p := &StaticProvider{OreKwh: 0, Loc: time.UTC}
+ rows, err := p.Fetch(context.Background(), "", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rows) != 96 {
+ t.Fatalf("slots = %d", len(rows))
+ }
+ if rows[0].SEKPerKWh != 0 {
+ t.Errorf("got %g", rows[0].SEKPerKWh)
+ }
+}
+
+func TestFromConfigStatic(t *testing.T) {
+ s := FromConfig(&config.Price{
+ Provider: "static",
+ Currency: "USD",
+ StaticOreKwh: 14,
+ }, nil, nil)
+ if s == nil {
+ t.Fatal("expected service")
+ }
+ if s.Provider.Name() != "static" {
+ t.Errorf("name = %s", s.Provider.Name())
+ }
+ if s.Zone != "STATIC" {
+ t.Errorf("zone = %s, want STATIC when none is set", s.Zone)
+ }
+ if s.Currency != "USD" {
+ t.Errorf("currency = %s", s.Currency)
+ }
+}
+
+func TestParseClockMinutes(t *testing.T) {
+ got, err := parseClockMinutes("7:00")
+ if err != nil || got != 7*60 {
+ t.Errorf("7:00 → %d %v", got, err)
+ }
+ got, err = parseClockMinutes("24:00")
+ if err != nil || got != 24*60 {
+ t.Errorf("24:00 → %d %v", got, err)
+ }
+ if _, err := parseClockMinutes("25:00"); err == nil {
+ t.Error("25:00 should fail")
+ }
+}
diff --git a/web/components/price-units.js b/web/components/price-units.js
index 3bd609840..c8d3386bb 100644
--- a/web/components/price-units.js
+++ b/web/components/price-units.js
@@ -24,6 +24,10 @@ const UNITS = {
CZK: { label: "Kč", perKwh: "Kč/kWh", axis: "Kč", scale: 0.01, decimals: 2 },
HUF: { label: "Ft", perKwh: "Ft/kWh", axis: "Ft", scale: 0.01, decimals: 1 },
RON: { label: "lei", perKwh: "lei/kWh", axis: "lei", scale: 0.01, decimals: 2 },
+ USD: { label: "¢", perKwh: "¢/kWh", axis: "¢", scale: 1, decimals: 1 },
+ GBP: { label: "p", perKwh: "p/kWh", axis: "p", scale: 1, decimals: 1 },
+ AUD: { label: "c", perKwh: "c/kWh", axis: "c", scale: 1, decimals: 1 },
+ CAD: { label: "¢", perKwh: "¢/kWh", axis: "¢", scale: 1, decimals: 1 },
};
// A currency with no entry above is shown in its major unit under its ISO
diff --git a/web/components/price-units.test.mjs b/web/components/price-units.test.mjs
index bb10f57c9..4bf10677e 100644
--- a/web/components/price-units.test.mjs
+++ b/web/components/price-units.test.mjs
@@ -15,7 +15,7 @@ import {
describe("price units", () => {
it("keeps minor units where households quote them", () => {
- for (const [code, label] of [["SEK", "öre"], ["EUR", "cent"], ["NOK", "øre"], ["DKK", "øre"]]) {
+ for (const [code, label] of [["SEK", "öre"], ["EUR", "cent"], ["NOK", "øre"], ["DKK", "øre"], ["USD", "¢"], ["GBP", "p"]]) {
const u = unitFor(code);
assert.equal(u.label, label);
assert.equal(u.scale, 1, `${code} should stay in minor units`);
diff --git a/web/price-zone-picker.test.mjs b/web/price-zone-picker.test.mjs
index 0061f66c2..716aeae49 100644
--- a/web/price-zone-picker.test.mjs
+++ b/web/price-zone-picker.test.mjs
@@ -58,11 +58,11 @@ describe("price zone picker", () => {
assert.match(setupHtml, /';
}
+ function touEditor(ctx, unit) {
+ var escHtml = ctx.escHtml;
+ var windows = (ctx.config.price && ctx.config.price.static_tou) || [];
+ var rows = windows.map(function (w, i) {
+ return '
' +
+ '
' +
+ '' +
+ '
' +
+ '
' +
+ '' +
+ '
' +
+ '
' +
+ '' +
+ '
' +
+ '
' +
+ '' +
+ '
' +
+ '' +
+ '
';
+ }).join("");
+ return '' +
+ 'Time-of-use windows — leave empty for a flat rate.' +
+ '
' +
+ 'Local time on the box. First matching window wins; hours they miss keep the flat rate. ' +
+ 'Overnight windows wrap (22:00–06:00). Days are weekday names; blank means every day.
' +
+ '
' + rows + '
' +
+ '' +
+ '';
+ }
+
+ function bindTOU(ctx) {
+ var host = document.getElementById("static-tou-list");
+ if (host) {
+ host.oninput = function (e) {
+ var idx = e.target && e.target.dataset && e.target.dataset.tou;
+ if (idx == null || idx === "") return;
+ var fieldName = e.target.dataset.field;
+ var arr = ctx.config.price.static_tou;
+ if (!arr || !arr[idx]) return;
+ if (fieldName === "ore_kwh") {
+ var v = parseFloat(e.target.value);
+ if (!isNaN(v)) arr[idx][fieldName] = v;
+ } else if (fieldName === "days") {
+ arr[idx].days = e.target.value.split(/[,\s]+/).filter(Boolean);
+ } else {
+ arr[idx][fieldName] = e.target.value;
+ }
+ };
+ host.onclick = function (e) {
+ var idx = e.target && e.target.dataset && e.target.dataset.touRemove;
+ if (idx == null || idx === "") return;
+ ctx.config.price.static_tou.splice(parseInt(idx, 10), 1);
+ ctx.captureCurrentTab();
+ ctx.renderTab("price");
+ };
+ }
+ var add = document.getElementById("static-tou-add");
+ if (add) add.addEventListener("click", function () {
+ if (!ctx.config.price.static_tou) ctx.config.price.static_tou = [];
+ ctx.config.price.static_tou.push({ start: "07:00", end: "23:00", ore_kwh: 0, days: [] });
+ ctx.captureCurrentTab();
+ ctx.renderTab("price");
+ });
+ }
+
S.tabs.price = {
render: function (ctx) {
var field = ctx.field, selectField = ctx.selectField, escHtml = ctx.escHtml;
if (!ctx.config.price) ctx.config.price = {};
var cfg = ctx.config.price;
var provider = cfg.provider || "sourceful";
- var zoneCode = cfg.zone || "SE3";
+ var isStatic = provider === "static";
+ var zoneCode = cfg.zone || (isStatic ? "STATIC" : "SE3");
// Switching to a Sweden-only provider while sitting on a foreign
// zone would leave both selects empty, so the zone follows the
- // provider back into range.
+ // provider back into range. STATIC is not a bidding zone.
var available = zonesFor(provider);
- if (!available.some(function (z) { return z.code === zoneCode; }) && available.length) {
- zoneCode = available[0].code;
- ctx.setByPath(ctx.config, "price.zone", zoneCode);
+ if (!isStatic) {
+ if (zoneCode === "STATIC" || !available.some(function (z) { return z.code === zoneCode; })) {
+ if (available.length) {
+ zoneCode = available[0].code;
+ ctx.setByPath(ctx.config, "price.zone", zoneCode);
+ }
+ }
+ } else if (!cfg.zone) {
+ ctx.setByPath(ctx.config, "price.zone", "STATIC");
}
var zone = zoneByCode(zoneCode);
var country = zone ? zone.country : "Sweden";
- var currency = cfg.currency || (zone ? zone.currency : "SEK");
+ var currency = cfg.currency || (isStatic ? "SEK" : (zone ? zone.currency : "SEK"));
var unit = window.FTWUnits ? window.FTWUnits.unitLabel(currency) : "öre";
var countryOpts = countriesFor(provider).map(function (c) {
@@ -74,22 +153,32 @@
}).join("");
var inCountry = zonesFor(provider).filter(function (z) { return z.country === country; });
var zoneOpts = inCountry.map(function (z) {
- // "Sweden — Stockholm" under the Sweden heading reads as
- // "SE3 · Stockholm"; a country with one zone is just its code.
var sub = z.name && z.name.indexOf(" — ") > 0 ? z.name.split(" — ")[1] : "";
return opt(escHtml(z.code), escHtml(sub ? z.code + " · " + sub : z.code), z.code === zoneCode);
}).join("");
- return '