From 90de0830a7ab074c9f7bd7bc12e51eeffe20e1ed Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 31 Jul 2026 16:07:22 +0200 Subject: [PATCH 1/2] feat(prices): every European bidding zone, billed in its own currency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zone picker offered twelve Nordic codes typed into two ` +elements and the Go side knew twelve EIC codes, so a household in Belgium, +the Netherlands or Spain could not choose its own zone even though the +Sourceful API has served every ENTSO-E area all along. Both lists now come +from one table, `go/internal/prices/zones.go`, generated from that API's +`/areas` endpoint and served to the UI at `GET /api/prices/zones`. The +picker asks for a country first and a zone second, because everyone knows +they live in Italy and nobody knows their area code is `IT-CENTRE-NORTH`. + +Currency stops being Swedish by assumption. It defaults to the currency of +the chosen zone, and the price API — which converts to EUR and SEK and +quietly answers anything else with EUR — is only asked for those two; +every other currency is converted here from EUR with the ECB rates the +service already caches. Where no rate is available the fetch fails rather +than storing a number that is wrong by an exchange rate, which is a number +the planner would spend money on. The old 11.5 SEK/EUR fallback is gone for +the same reason. + +Two related faults fixed on the way. The direct ENTSO-E provider assumed +every day-ahead document is priced in EUR; Poland and Hungary among others +publish in their own currency, so it now reads `currency_Unit.name` and +converts from that. Its EIC code for Germany was the country code rather +than the DE-LU bidding zone, and NO5 was missing outright. + +Prices are stored as minor units per kWh with no currency attached, so +changing the currency clears the price cache — otherwise cost history would +add öre to cent. The next fetch refills today and tomorrow. Every price +label in the UI now follows the configured currency: öre, cent, øre, grosz, +Rappen, or the major unit where the minor one is out of circulation +(4.30 Kč/kWh, not 430 haléř). + +Existing installs are untouched: no zone means SE3, no currency means SEK, +and a Swedish install still asks the API for SEK exactly as before. diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 4baa48a9..969d7542 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -307,6 +307,7 @@ func (s *Server) routes() { s.handle("GET /api/energy/history.csv", s.handleEnergyHistoryCSV) s.handle("GET /api/savings/daily", s.handleSavingsDaily) s.handle("GET /api/prices", s.handlePrices) + s.handle("GET /api/prices/zones", s.handlePriceZones) s.handle("GET /api/forecast", s.handleForecast) s.handle("GET /api/mpc/plan", s.handleMPCPlan) s.handle("POST /api/mpc/replan", s.handleMPCReplan) @@ -1989,12 +1990,34 @@ func (s *Server) handlePrices(w http.ResponseWriter, r *http.Request) { return } writeJSON(w, 200, map[string]any{ - "zone": s.deps.Prices.Zone, - "items": rows, - "enabled": true, + "zone": s.deps.Prices.Zone, + "currency": s.deps.Prices.Currency, + "items": rows, + "enabled": true, }) } +// ---- /api/prices/zones ---- +// +// The bidding zones a household can pick, with the currency each one is +// billed in. Served from the same table the fetchers use, so the picker +// can't offer a zone the providers don't know. +// +// Response: {"zones": [{code, country, currency, name}]} +func (s *Server) handlePriceZones(w http.ResponseWriter, _ *http.Request) { + all := prices.Zones() + items := make([]map[string]string, 0, len(all)) + for _, z := range all { + items = append(items, map[string]string{ + "code": z.Code, + "country": z.Country, + "currency": z.Currency, + "name": z.Name(), + }) + } + writeJSON(w, 200, map[string]any{"zones": items}) +} + // ---- /api/forecast ---- // // Query params: range=24h|48h|3d (default 48h lookahead). diff --git a/go/internal/prices/prices.go b/go/internal/prices/prices.go index 942d37d4..3defa496 100644 --- a/go/internal/prices/prices.go +++ b/go/internal/prices/prices.go @@ -3,8 +3,8 @@ // // Supported: // - sourceful — Default. Keyless European day-ahead prices through -// Sourceful's cached ENTSO-E API. Resolution varies per bidding zone -// (currently 15m in the Nordics). +// Sourceful's cached ENTSO-E API, for every zone in zones.go. +// Resolution varies per bidding zone (currently 15m in most of Europe). // - elprisetjustnu — Sweden, zones SE1-SE4, no API key. Since late 2025 // NordPool publishes in 15-minute PTU (quarterly) resolution; this // package defaults to the quarterly endpoint and can fall back to @@ -14,7 +14,12 @@ // // Consumer price = (spot + grid_tariff) × (1 + VAT/100). We store both // pure spot AND the consumer total so the UI can surface either. -// Prices are in öre/kWh (1 SEK = 100 öre). +// +// Stored prices are in minor units of the configured currency per kWh — +// öre for SEK, cent for EUR, øre for NOK and DKK. One install holds one +// currency: the cache is cleared when the currency changes, because rows +// carry no currency of their own and mixed units would silently corrupt +// cost history. package prices import ( @@ -46,7 +51,8 @@ type Provider interface { Fetch(ctx context.Context, zone string, day time.Time) ([]RawPrice, error) } -// RawPrice is one time slot's pure-spot price in SEK/kWh (before grid + VAT). +// RawPrice is one time slot's pure-spot price in major units of the +// configured currency per kWh (SEK/kWh, EUR/kWh, …), before grid + VAT. // SlotLenMin is typically 15 (NordPool PTU) or 60 (legacy hourly). type RawPrice struct { SlotStart time.Time @@ -57,27 +63,56 @@ type RawPrice struct { // ---- Sourceful ---- // SourcefulProvider uses the same keyless, cached ENTSO-E price API as the -// Sourceful Energy app. The API returns the requested currency per MWh; FTW -// always requests SEK so the package-wide SEK/kWh invariant remains true. +// Sourceful Energy app. +// +// The API converts to SEK on request but serves nothing else — every other +// currency code silently returns EUR. So we ask it only for what it really +// has (see sourcefulNative), and convert the rest ourselves from EUR with +// the same ECB rates the API uses. type SourcefulProvider struct { Client *http.Client BaseURL string // override in tests + + // Currency is the ISO code the caller wants prices in. Empty means SEK. + Currency string + // FX converts the EUR the API falls back to into Currency. Required + // when Currency is neither EUR nor SEK. + FX FXConverter } +// sourcefulNative lists the currencies the price API converts to itself. +// Anything else has to go through EURToNative. +var sourcefulNative = map[string]bool{"EUR": true, "SEK": true} + // NewSourceful returns a provider pointed at Sourceful's production API. func NewSourceful() *SourcefulProvider { return &SourcefulProvider{ - Client: &http.Client{Timeout: 15 * time.Second}, - BaseURL: "https://novacore-mainnet.sourceful.dev/services/price/electricity", + Client: &http.Client{Timeout: 15 * time.Second}, + BaseURL: "https://novacore-mainnet.sourceful.dev/services/price/electricity", + Currency: "SEK", } } func (s *SourcefulProvider) Name() string { return "sourceful" } +// apiCurrency is what we ask the API for: the wanted currency when it can +// serve it, EUR otherwise. +func (s *SourcefulProvider) apiCurrency() string { + want := strings.ToUpper(strings.TrimSpace(s.Currency)) + if want == "" { + return "SEK" + } + if sourcefulNative[want] { + return want + } + return "EUR" +} + func (s *SourcefulProvider) Fetch(ctx context.Context, zone string, day time.Time) ([]RawPrice, error) { zone = strings.ToUpper(strings.TrimSpace(zone)) - endpoint := fmt.Sprintf("%s/%s?currency=SEK&date=%s&days=1", - strings.TrimRight(s.BaseURL, "/"), url.PathEscape(zone), day.Format("2006-01-02")) + apiCur := s.apiCurrency() + endpoint := fmt.Sprintf("%s/%s?currency=%s&date=%s&days=1", + strings.TrimRight(s.BaseURL, "/"), url.PathEscape(zone), apiCur, day.Format("2006-01-02")) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err @@ -112,8 +147,11 @@ func (s *SourcefulProvider) Fetch(ctx context.Context, zone string, day time.Tim if payload.Area != "" && !strings.EqualFold(payload.Area, zone) { return nil, fmt.Errorf("sourceful: response area %q does not match %q", payload.Area, zone) } - if !strings.EqualFold(payload.Currency, "SEK") { - return nil, fmt.Errorf("sourceful: unexpected currency %q", payload.Currency) + // The API answers an unsupported currency code with EUR rather than an + // error, so this check is what stops a NOK install from storing EUR + // numbers labelled NOK. + if !strings.EqualFold(payload.Currency, apiCur) { + return nil, fmt.Errorf("sourceful: asked for %s, got %q", apiCur, payload.Currency) } if !strings.HasSuffix(strings.ToUpper(strings.TrimSpace(payload.Unit)), "MWH") { return nil, fmt.Errorf("sourceful: unexpected unit %q", payload.Unit) @@ -122,6 +160,12 @@ func (s *SourcefulProvider) Fetch(ctx context.Context, zone string, day time.Tim if slotMin <= 0 { return nil, fmt.Errorf("sourceful: bad resolution %q", payload.Resolution) } + // Currencies the API can't serve arrive as EUR and are converted here. + want := strings.ToUpper(strings.TrimSpace(s.Currency)) + convert := want != "" && want != apiCur + if convert && s.FX == nil { + return nil, fmt.Errorf("sourceful: no exchange rate source for %s→%s", apiCur, want) + } out := make([]RawPrice, 0, len(payload.Prices)) for _, point := range payload.Prices { @@ -129,10 +173,18 @@ func (s *SourcefulProvider) Fetch(ctx context.Context, zone string, day time.Tim if err != nil { return nil, fmt.Errorf("sourceful: datetime %q: %w", point.Datetime, err) } + perKWh := point.Price / 1000.0 + if convert { + native, ok := s.FX.Convert(perKWh, apiCur, want) + if !ok { + return nil, fmt.Errorf("sourceful: no %s→%s rate yet", apiCur, want) + } + perKWh = native + } out = append(out, RawPrice{ SlotStart: t, SlotLenMin: slotMin, - SEKPerKWh: point.Price / 1000.0, + SEKPerKWh: perKWh, }) } return out, nil @@ -225,19 +277,21 @@ func (e *ElpriserProvider) Fetch(ctx context.Context, zone string, day time.Time // https://transparency.entsoe.eu/ then email for activation (~1 day). // // Fetches the A44 day-ahead Publication_MarketDocument for a bidding zone -// (EIC codes below), decodes its TimeSeries > Period > Point structure -// (handling both PT60M and PT15M resolutions and the sparse carry-forward -// representation), and converts EUR/MWh to native currency per kWh via -// EURToNative. Returns {} for a day not yet published, like elprisetjustnu. +// (EIC codes come from the zone catalog in zones.go), decodes its +// TimeSeries > Period > Point structure (handling both PT60M and PT15M +// resolutions and the sparse carry-forward representation), and converts +// EUR/MWh to the configured currency per kWh via EURToNative. Returns {} +// for a day not yet published, like elprisetjustnu. type ENTSOEProvider struct { Client *http.Client APIKey string BaseURL string // Currency is the ISO code the caller wants prices in (default SEK). - // ENTSOE publishes EUR/MWh; we convert via EURToNative if non-nil. - Currency string - EURToNative func(eur float64) float64 // returns amount in native currency + // Most zones publish EUR/MWh; FX converts from whatever the document + // says to Currency, and is only needed when the two differ. + Currency string + FX FXConverter } // NewENTSOE returns a provider — caller must set APIKey. @@ -252,31 +306,15 @@ func NewENTSOE(apiKey string) *ENTSOEProvider { func (e *ENTSOEProvider) Name() string { return "entsoe" } -// EIC codes for common zones. Full list at -// https://eepublicdownloads.entsoe.eu/clean-documents/EDI/Library/Y_codes_list.pdf -var entsoeZoneEIC = map[string]string{ - "SE1": "10Y1001A1001A44P", - "SE2": "10Y1001A1001A45N", - "SE3": "10Y1001A1001A46L", - "SE4": "10Y1001A1001A47J", - "NO1": "10YNO-1--------2", - "NO2": "10YNO-2--------T", - "NO3": "10YNO-3--------J", - "NO4": "10YNO-4--------9", - "DK1": "10YDK-1--------W", - "DK2": "10YDK-2--------M", - "FI": "10YFI-1--------U", - "DE": "10Y1001A1001A83F", -} - func (e *ENTSOEProvider) Fetch(ctx context.Context, zone string, day time.Time) ([]RawPrice, error) { if e.APIKey == "" { return nil, errors.New("entsoe: API key required") } - eic, ok := entsoeZoneEIC[zone] + z, ok := LookupZone(zone) if !ok { return nil, fmt.Errorf("entsoe: unknown zone %q", zone) } + eic := z.EIC periodStart := day.UTC().Format("200601021504") periodEnd := day.Add(24 * time.Hour).UTC().Format("200601021504") url := fmt.Sprintf("%s?documentType=A44&in_Domain=%s&out_Domain=%s&periodStart=%s&periodEnd=%s&securityToken=%s", @@ -304,9 +342,10 @@ func (e *ENTSOEProvider) Fetch(ctx context.Context, zone string, day time.Time) // ---- ENTSOE XML decode ---- // // The transparency platform returns a Publication_MarketDocument with -// nested TimeSeries > Period > Point entries. Prices are EUR/MWh. We -// convert to native currency per kWh via EURToNative (set from config FX; -// falls back to a ballpark 11.5 SEK/EUR when unwired). +// nested TimeSeries > Period > Point entries. Most zones publish EUR/MWh, +// but not all — Poland and Hungary among others publish in their own +// currency — so each TimeSeries carries its own currency_Unit.name and we +// convert from that, not from an assumed EUR. // // The struct tags carry no namespace, which encoding/xml matches by local // name regardless of the document's default xmlns — so the dotted element @@ -317,7 +356,8 @@ type entsoePublication struct { } type entsoeTimeSeries struct { - Periods []entsoePeriod `xml:"Period"` + Currency string `xml:"currency_Unit.name"` + Periods []entsoePeriod `xml:"Period"` } type entsoePeriod struct { @@ -332,14 +372,38 @@ type entsoePoint struct { Amount float64 `xml:"price.amount"` } -// eurMWhToNative converts an EUR/MWh figure to native currency per kWh, -// using the provider's configured converter or the ballpark fallback. -func (e *ENTSOEProvider) eurMWhToNative(eurPerMWh float64) float64 { - eurPerKWh := eurPerMWh / 1000.0 - if e.EURToNative != nil { - return e.EURToNative(eurPerKWh) +// wantCurrency is the ISO code prices are stored in; empty config means SEK, +// which is what NewENTSOE and FromConfig already default to. +func (e *ENTSOEProvider) wantCurrency() string { + if c := strings.ToUpper(strings.TrimSpace(e.Currency)); c != "" { + return c + } + return "SEK" +} + +// perMWhToNative converts a published price per MWh into the configured +// currency per kWh. from is the document's own currency; a document that +// already publishes in the wanted currency needs no rate at all. +// +// A missing rate is an error rather than a guess: a price off by an +// exchange rate steers dispatch and lands in cost history. +func (e *ENTSOEProvider) perMWhToNative(amountPerMWh float64, from string) (float64, error) { + perKWh := amountPerMWh / 1000.0 + want := e.wantCurrency() + if from == "" { + from = "EUR" // the platform's default, and what it omits + } + if strings.EqualFold(from, want) { + return perKWh, nil + } + if e.FX == nil { + return 0, fmt.Errorf("entsoe: no exchange rate source for %s→%s", from, want) } - return eurPerKWh * 11.5 + v, ok := e.FX.Convert(perKWh, from, want) + if !ok { + return 0, fmt.Errorf("entsoe: no %s→%s rate yet", from, want) + } + return v, nil } // parseXML decodes a day-ahead Publication_MarketDocument into raw slots. @@ -354,7 +418,7 @@ func (e *ENTSOEProvider) parseXML(body []byte) ([]RawPrice, error) { var out []RawPrice for _, ts := range doc.TimeSeries { for _, pd := range ts.Periods { - rows, err := e.expandPeriod(pd) + rows, err := e.expandPeriod(pd, ts.Currency) if err != nil { return nil, err } @@ -368,7 +432,7 @@ func (e *ENTSOEProvider) parseXML(body []byte) ([]RawPrice, error) { // A44 representation: a Point is omitted when its price equals the previous // position's, so we carry the last seen price forward to fill the period's // full slot count (derived from the timeInterval, not the Point count). -func (e *ENTSOEProvider) expandPeriod(pd entsoePeriod) ([]RawPrice, error) { +func (e *ENTSOEProvider) expandPeriod(pd entsoePeriod, docCurrency string) ([]RawPrice, error) { slotMin := resolutionMinutes(pd.Resolution) if slotMin <= 0 { return nil, fmt.Errorf("entsoe: bad resolution %q", pd.Resolution) @@ -407,10 +471,14 @@ func (e *ENTSOEProvider) expandPeriod(pd entsoePeriod) ([]RawPrice, error) { if !have { continue // no leading price to carry yet } + native, err := e.perMWhToNative(last, docCurrency) + if err != nil { + return nil, err + } out = append(out, RawPrice{ SlotStart: start.Add(time.Duration(pos-1) * time.Duration(slotMin) * time.Minute), SlotLenMin: slotMin, - SEKPerKWh: e.eurMWhToNative(last), + SEKPerKWh: native, }) } return out, nil @@ -454,10 +522,10 @@ type Applier struct { VATPercent float64 } -// Apply computes total öre/kWh the consumer pays (spot + grid tariff) × (1 + VAT). -// Returns (spot_ore, total_ore). +// Apply computes the total the consumer pays, (spot + grid tariff) × +// (1 + VAT), in minor units per kWh. Returns (spot, total). func (a Applier) Apply(sekPerKwh float64) (spotOre, totalOre float64) { - spotOre = sekPerKwh * 100 // SEK/kWh → öre/kWh + spotOre = sekPerKwh * 100 // major → minor units (SEK→öre, EUR→cent) // Consumer cost: (spot + grid tariff) * (1 + VAT/100) totalOre = (spotOre + a.GridTariffOreKwh) * (1 + a.VATPercent/100) return @@ -471,6 +539,9 @@ type Service struct { Store *state.Store Applier Applier Zone string + // Currency the stored minor units are in. Read by the API so the UI + // can label them. + Currency string stop chan struct{} done chan struct{} @@ -478,8 +549,8 @@ type Service struct { // FXConverter abstracts currency conversion so the prices package // doesn't need to import currency/ directly (and FromConfig callers -// can pass a test stub). If nil, ENTSOE assumes 1 EUR = 11.5 SEK — a -// ballpark used only until live rates land. +// can pass a test stub). When it is nil, or has no rate for the pair, a +// provider that needs conversion fails its fetch instead of guessing. type FXConverter interface { Convert(amount float64, from, to string) (float64, bool) } @@ -490,38 +561,36 @@ func FromConfig(cfg *config.Price, st *state.Store, fx FXConverter) *Service { if cfg == nil || cfg.Provider == "" || cfg.Provider == "none" { return nil } - currency := cfg.Currency + zone := cfg.Zone + if zone == "" { + 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. + currency := strings.ToUpper(strings.TrimSpace(cfg.Currency)) + if currency == "" { + currency = ZoneCurrency(zone) + } if currency == "" { currency = "SEK" } var p Provider switch cfg.Provider { case "sourceful": - p = NewSourceful() + sp := NewSourceful() + sp.Currency = currency + sp.FX = fx + p = sp case "elprisetjustnu": p = NewElpriser() case "entsoe": ep := NewENTSOE(cfg.APIKey) ep.Currency = currency - if fx != nil { - ep.EURToNative = func(eur float64) float64 { - v, ok := fx.Convert(eur, "EUR", currency) - if !ok { - return eur * 11.5 // fallback until rates land - } - return v - } - } else { - ep.EURToNative = func(eur float64) float64 { return eur * 11.5 } - } + ep.FX = fx p = ep default: return nil } - zone := cfg.Zone - if zone == "" { - zone = "SE3" - } vat := cfg.VATPercent if vat == 0 { vat = 25 @@ -530,18 +599,49 @@ func FromConfig(cfg *config.Price, st *state.Store, fx FXConverter) *Service { Provider: p, Store: st, Zone: zone, + Currency: currency, Applier: Applier{GridTariffOreKwh: cfg.GridTariffOreKwh, VATPercent: vat}, stop: make(chan struct{}), done: make(chan struct{}), } } +// priceCurrencyKey records the currency the cached price rows are in. +const priceCurrencyKey = "prices/currency" + // Start begins the fetch-on-schedule goroutine. Does an initial fetch // immediately + every hour (plus specifically at 13:05 CET for day-ahead release). func (s *Service) Start(ctx context.Context) { + s.syncCachedCurrency() go s.loop(ctx) } +// syncCachedCurrency empties the price cache when the configured currency +// no longer matches the currency the cached rows were fetched in. Runs +// before the first fetch so no reader ever sees two currencies at once. +func (s *Service) syncCachedCurrency() { + if s.Store == nil || s.Currency == "" { + return + } + prev, ok := s.Store.LoadConfig(priceCurrencyKey) + if ok && prev == s.Currency { + return + } + if ok && prev != "" { + n, err := s.Store.ClearPrices() + if err != nil { + slog.Warn("price cache clear failed after currency change", + "from", prev, "to", s.Currency, "err", err) + return + } + slog.Warn("price currency changed — cached prices cleared", + "from", prev, "to", s.Currency, "rows", n) + } + if err := s.Store.SaveConfig(priceCurrencyKey, s.Currency); err != nil { + slog.Warn("could not record price currency", "err", err) + } +} + // Stop terminates the fetcher. func (s *Service) Stop() { close(s.stop) diff --git a/go/internal/prices/prices_test.go b/go/internal/prices/prices_test.go index 44f5626d..4cebb27d 100644 --- a/go/internal/prices/prices_test.go +++ b/go/internal/prices/prices_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "reflect" "strings" "testing" "time" @@ -121,8 +122,62 @@ func TestSourcefulRejectsUnexpectedCurrency(t *testing.T) { defer srv.Close() p := &SourcefulProvider{Client: &http.Client{}, BaseURL: srv.URL} _, err := p.Fetch(context.Background(), "SE3", time.Now()) - if err == nil || !strings.Contains(err.Error(), "unexpected currency") { - t.Fatalf("got error %v, want unexpected currency", err) + if err == nil || !strings.Contains(err.Error(), "asked for SEK") { + t.Fatalf("got error %v, want a currency mismatch", err) + } +} + +// The API serves EUR and SEK and quietly answers anything else with EUR. +// A euro zone asks for EUR directly; a Norwegian asks for EUR and converts, +// because asking for NOK would return EUR numbers labelled EUR. +func TestSourcefulAsksForServableCurrency(t *testing.T) { + var asked []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cur := r.URL.Query().Get("currency") + asked = append(asked, cur) + area := strings.Trim(r.URL.Path, "/") + fmt.Fprintf(w, `{"area":%q,"currency":%q,"unit":"MWH","resolution":"PT60M", + "prices":[{"datetime":"2026-07-31T00:00:00+00:00","price":180.0}]}`, area, cur) + })) + defer srv.Close() + + eur := &SourcefulProvider{Client: &http.Client{}, BaseURL: srv.URL, Currency: "EUR"} + rows, err := eur.Fetch(context.Background(), "BE", time.Now()) + if err != nil { + t.Fatalf("EUR fetch: %v", err) + } + if math.Abs(rows[0].SEKPerKWh-0.180) > 1e-9 { + t.Errorf("EUR price: got %g, want 0.18/kWh", rows[0].SEKPerKWh) + } + + nok := &SourcefulProvider{Client: &http.Client{}, BaseURL: srv.URL, Currency: "NOK", FX: fxStub{rate: 11.7}} + rows, err = nok.Fetch(context.Background(), "NO1", time.Now()) + if err != nil { + t.Fatalf("NOK fetch: %v", err) + } + if math.Abs(rows[0].SEKPerKWh-0.180*11.7) > 1e-9 { + t.Errorf("NOK price: got %g, want %g/kWh", rows[0].SEKPerKWh, 0.180*11.7) + } + if want := []string{"EUR", "EUR"}; !reflect.DeepEqual(asked, want) { + t.Errorf("asked the API for %v, want %v", asked, want) + } +} + +// Without a rate, a NOK install gets no prices rather than EUR numbers +// stored as if they were NOK. +func TestSourcefulFailsWithoutRateForNonNativeCurrency(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"area":"NO1","currency":"EUR","unit":"MWH","resolution":"PT60M", + "prices":[{"datetime":"2026-07-31T00:00:00+00:00","price":180.0}]}`) + })) + defer srv.Close() + for name, p := range map[string]*SourcefulProvider{ + "no FX source": {Client: &http.Client{}, BaseURL: srv.URL, Currency: "NOK"}, + "no rate yet": {Client: &http.Client{}, BaseURL: srv.URL, Currency: "NOK", FX: fxStub{}}, + } { + if _, err := p.Fetch(context.Background(), "NO1", time.Now()); err == nil { + t.Errorf("%s: expected an error, got prices", name) + } } } @@ -336,15 +391,26 @@ func entsoeServer(t *testing.T, body string) (*ENTSOEProvider, func()) { fmt.Fprint(w, body) })) p := &ENTSOEProvider{ - Client: &http.Client{}, - APIKey: "test-key", - BaseURL: srv.URL, - Currency: "SEK", - EURToNative: func(eur float64) float64 { return eur }, // identity + Client: &http.Client{}, + APIKey: "test-key", + BaseURL: srv.URL, + Currency: "SEK", + FX: fxStub{rate: 1}, // identity } return p, srv.Close } +// fxStub converts at a fixed rate, or refuses when rate is 0 — standing in +// for the ECB service before rates land. +type fxStub struct{ rate float64 } + +func (f fxStub) Convert(amount float64, _, _ string) (float64, bool) { + if f.rate == 0 { + return 0, false + } + return amount * f.rate, true +} + // A real day-ahead A44 document, trimmed to a 3-hour PT60M period. The // default xmlns + the dotted element names (price.amount) are exactly // what the live transparency platform emits. @@ -444,26 +510,73 @@ func TestENTSOEFifteenMinCarriesForwardGaps(t *testing.T) { } } -// With no converter wired (NewENTSOE path before FromConfig sets one), -// the parser must still produce a sane SEK figure rather than emitting -// raw EUR. Falls back to the ballpark 11.5 SEK/EUR. -func TestENTSOEFallsBackToBallparkFXWhenConverterNil(t *testing.T) { +// A EUR document with no way to reach SEK must fail the fetch. Storing the +// EUR figure as if it were SEK would understate every price elevenfold and +// steer the planner; no prices at all is the safe answer. +func TestENTSOEFailsWithoutExchangeRate(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, entsoeHourlyXML) })) defer srv.Close() - p := &ENTSOEProvider{Client: &http.Client{}, APIKey: "k", BaseURL: srv.URL} // EURToNative nil day, _ := time.Parse("2006-01-02", "2026-06-03") - rows, err := p.Fetch(context.Background(), "SE3", day) + + for name, p := range map[string]*ENTSOEProvider{ + "no FX source": {Client: &http.Client{}, APIKey: "k", BaseURL: srv.URL, Currency: "SEK"}, + "no rate yet": {Client: &http.Client{}, APIKey: "k", BaseURL: srv.URL, Currency: "SEK", FX: fxStub{}}, + } { + if _, err := p.Fetch(context.Background(), "SE3", day); err == nil { + t.Errorf("%s: expected an error, got prices", name) + } + } +} + +// A household paying in the currency the document already publishes needs +// no rate at all — Belgium reading a EUR document is the common case. +func TestENTSOENeedsNoRateWhenCurrencyMatches(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, entsoeHourlyXML) + })) + defer srv.Close() + p := &ENTSOEProvider{Client: &http.Client{}, APIKey: "k", BaseURL: srv.URL, Currency: "EUR"} + day, _ := time.Parse("2006-01-02", "2026-06-03") + rows, err := p.Fetch(context.Background(), "BE", day) if err != nil { t.Fatalf("fetch: %v", err) } - if len(rows) != 3 { - t.Fatalf("got %d rows, want 3", len(rows)) + if len(rows) != 3 || math.Abs(rows[0].SEKPerKWh-0.050) > 1e-9 { + t.Fatalf("got %d rows, first %g; want 3 rows, first 0.05 EUR/kWh", len(rows), rows[0].SEKPerKWh) + } +} + +// Not every zone publishes in EUR — Poland and Hungary among others price +// in their own currency. Reading currency_Unit.name is what keeps a Polish +// install from converting PLN as though it were EUR. +func TestENTSOEReadsDocumentCurrency(t *testing.T) { + const plnXML = ` + + + PLN + + 2026-06-02T22:00Z2026-06-02T23:00Z + PT60M + 1430.0 + + +` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, plnXML) + })) + defer srv.Close() + // A Polish household wants PLN and the document is already PLN, so no + // rate is consulted — a converter that refuses everything proves it. + p := &ENTSOEProvider{Client: &http.Client{}, APIKey: "k", BaseURL: srv.URL, Currency: "PLN", FX: fxStub{}} + day, _ := time.Parse("2006-01-02", "2026-06-03") + rows, err := p.Fetch(context.Background(), "PL", day) + if err != nil { + t.Fatalf("fetch: %v", err) } - // 50 EUR/MWh → 0.05 EUR/kWh × 11.5 ≈ 0.575 SEK/kWh - if math.Abs(rows[0].SEKPerKWh-0.575) > 1e-9 { - t.Errorf("fallback price: got %g, want 0.575", rows[0].SEKPerKWh) + if len(rows) != 1 || math.Abs(rows[0].SEKPerKWh-0.430) > 1e-9 { + t.Fatalf("got %d rows, first %g; want 1 row at 0.43 PLN/kWh", len(rows), rows[0].SEKPerKWh) } } diff --git a/go/internal/prices/zones.go b/go/internal/prices/zones.go new file mode 100644 index 00000000..b3c051f2 --- /dev/null +++ b/go/internal/prices/zones.go @@ -0,0 +1,124 @@ +package prices + +import ( + "sort" + "strings" +) + +// Zone is one day-ahead bidding zone: what to ask a provider for, and what +// the household in it pays with. +// +// Code is the area code both the Sourceful API and our config use. EIC is +// the ENTSO-E energy identification code for the same area, so the direct +// ENTSO-E provider needs no second table. Currency is the local currency of +// the country, which is what a new install should default to — the API +// itself only serves EUR and SEK, so anything else is converted here. +type Zone struct { + Code string + Country string + Currency string + EIC string + // Label distinguishes zones whose code doesn't say where it is + // (IT-SARDINIA, NO2NSL). Empty when the code speaks for itself. + Label string +} + +// Name is what a picker shows: country plus the zone's own label when the +// country has more than one zone. +func (z Zone) Name() string { + if z.Label != "" { + return z.Country + " — " + z.Label + } + return z.Country +} + +// zones lists every area the Sourceful price API publishes, generated from +// its /areas endpoint. ENTSO-E covers more areas than this; a zone that +// isn't here can still be reached with provider=entsoe and a hand-written +// config, but it won't appear in the picker. +// +// Serbia and Ukraine default to EUR: the ECB publishes no reference rate for +// RSD or UAH, so a local-currency price would be a guess. The price is real, +// only the currency isn't theirs. +var zones = []Zone{ + {Code: "AT", Country: "Austria", Currency: "EUR", EIC: "10YAT-APG------L", Label: ""}, + {Code: "BE", Country: "Belgium", Currency: "EUR", EIC: "10YBE----------2", Label: ""}, + {Code: "BG", Country: "Bulgaria", Currency: "EUR", EIC: "10YCA-BULGARIA-R", Label: ""}, + {Code: "HR", Country: "Croatia", Currency: "EUR", EIC: "10YHR-HEP------M", Label: ""}, + {Code: "CZ", Country: "Czech Republic", Currency: "CZK", EIC: "10YCZ-CEPS-----N", Label: ""}, + {Code: "DK1", Country: "Denmark", Currency: "DKK", EIC: "10YDK-1--------W", Label: "West"}, + {Code: "DK2", Country: "Denmark", Currency: "DKK", EIC: "10YDK-2--------M", Label: "East"}, + {Code: "EE", Country: "Estonia", Currency: "EUR", EIC: "10Y1001A1001A39I", Label: ""}, + {Code: "FI", Country: "Finland", Currency: "EUR", EIC: "10YFI-1--------U", Label: ""}, + {Code: "FR", Country: "France", Currency: "EUR", EIC: "10YFR-RTE------C", Label: ""}, + {Code: "DE", Country: "Germany", Currency: "EUR", EIC: "10Y1001A1001A82H", Label: ""}, + {Code: "GR", Country: "Greece", Currency: "EUR", EIC: "10YGR-HTSO-----Y", Label: ""}, + {Code: "HU", Country: "Hungary", Currency: "HUF", EIC: "10YHU-MAVIR----U", Label: ""}, + {Code: "IT-CALABRIA", Country: "Italy", Currency: "EUR", EIC: "10Y1001C--00096J", Label: "Calabria"}, + {Code: "IT-CENTRE-NORTH", Country: "Italy", Currency: "EUR", EIC: "10Y1001A1001A70O", Label: "Centre-North"}, + {Code: "IT-CENTRE-SOUTH", Country: "Italy", Currency: "EUR", EIC: "10Y1001A1001A71M", Label: "Centre-South"}, + {Code: "IT-NORTH", Country: "Italy", Currency: "EUR", EIC: "10Y1001A1001A73I", Label: "North"}, + {Code: "IT-SACOAC", Country: "Italy", Currency: "EUR", EIC: "10Y1001A1001A885", Label: "SACOAC"}, + {Code: "IT-SACODC", Country: "Italy", Currency: "EUR", EIC: "10Y1001A1001A893", Label: "SACODC"}, + {Code: "IT-SARDINIA", Country: "Italy", Currency: "EUR", EIC: "10Y1001A1001A74G", Label: "Sardinia"}, + {Code: "IT-SICILY", Country: "Italy", Currency: "EUR", EIC: "10Y1001A1001A75E", Label: "Sicily"}, + {Code: "IT-SOUTH", Country: "Italy", Currency: "EUR", EIC: "10Y1001A1001A788", Label: "South"}, + {Code: "LV", Country: "Latvia", Currency: "EUR", EIC: "10YLV-1001A00074", Label: ""}, + {Code: "LT", Country: "Lithuania", Currency: "EUR", EIC: "10YLT-1001A0008Q", Label: ""}, + {Code: "LU", Country: "Luxembourg", Currency: "EUR", EIC: "10Y1001A1001A82H", Label: ""}, + {Code: "ME", Country: "Montenegro", Currency: "EUR", EIC: "10YCS-CG-TSO---S", Label: ""}, + {Code: "NL", Country: "Netherlands", Currency: "EUR", EIC: "10YNL----------L", Label: ""}, + {Code: "NO1", Country: "Norway", Currency: "NOK", EIC: "10YNO-1--------2", Label: "Oslo"}, + {Code: "NO2", Country: "Norway", Currency: "NOK", EIC: "10YNO-2--------T", Label: "Kristiansand"}, + {Code: "NO2NSL", Country: "Norway", Currency: "NOK", EIC: "50Y0JVU59B4JWQCU", Label: "North Sea Link"}, + {Code: "NO3", Country: "Norway", Currency: "NOK", EIC: "10YNO-3--------J", Label: "Trondheim"}, + {Code: "NO4", Country: "Norway", Currency: "NOK", EIC: "10YNO-4--------9", Label: "Tromsø"}, + {Code: "NO5", Country: "Norway", Currency: "NOK", EIC: "10Y1001A1001A48H", Label: "Bergen"}, + {Code: "PL", Country: "Poland", Currency: "PLN", EIC: "10YPL-AREA-----S", Label: ""}, + {Code: "PT", Country: "Portugal", Currency: "EUR", EIC: "10YPT-REN------W", Label: ""}, + {Code: "RO", Country: "Romania", Currency: "RON", EIC: "10YRO-TEL------P", Label: ""}, + {Code: "RS", Country: "Serbia", Currency: "EUR", EIC: "10YCS-SERBIATSOV", Label: ""}, + {Code: "SK", Country: "Slovakia", Currency: "EUR", EIC: "10YSK-SEPS-----K", Label: ""}, + {Code: "SI", Country: "Slovenia", Currency: "EUR", EIC: "10YSI-ELES-----O", Label: ""}, + {Code: "ES", Country: "Spain", Currency: "EUR", EIC: "10YES-REE------0", Label: ""}, + {Code: "SE1", Country: "Sweden", Currency: "SEK", EIC: "10Y1001A1001A44P", Label: "Luleå"}, + {Code: "SE2", Country: "Sweden", Currency: "SEK", EIC: "10Y1001A1001A45N", Label: "Sundsvall"}, + {Code: "SE3", Country: "Sweden", Currency: "SEK", EIC: "10Y1001A1001A46L", Label: "Stockholm"}, + {Code: "SE4", Country: "Sweden", Currency: "SEK", EIC: "10Y1001A1001A47J", Label: "Malmö"}, + {Code: "CH", Country: "Switzerland", Currency: "CHF", EIC: "10YCH-SWISSGRIDZ", Label: ""}, + {Code: "UA-IPS", Country: "Ukraine", Currency: "EUR", EIC: "10Y1001C--000182", Label: "IPS"}, +} + +// Zones returns every known bidding zone, sorted by country then code, which +// is the order a picker wants. +func Zones() []Zone { + out := make([]Zone, len(zones)) + copy(out, zones) + sort.Slice(out, func(i, j int) bool { + if out[i].Country != out[j].Country { + return out[i].Country < out[j].Country + } + return out[i].Code < out[j].Code + }) + return out +} + +// LookupZone finds a zone by area code, case- and space-insensitive. +func LookupZone(code string) (Zone, bool) { + code = strings.ToUpper(strings.TrimSpace(code)) + for _, z := range zones { + if z.Code == code { + return z, true + } + } + return Zone{}, false +} + +// ZoneCurrency returns the local currency for an area code, or "" when the +// code is unknown. Callers treat "" as "keep whatever is configured". +func ZoneCurrency(code string) string { + if z, ok := LookupZone(code); ok { + return z.Currency + } + return "" +} diff --git a/go/internal/prices/zones_test.go b/go/internal/prices/zones_test.go new file mode 100644 index 00000000..b8ffb21a --- /dev/null +++ b/go/internal/prices/zones_test.go @@ -0,0 +1,135 @@ +package prices + +import ( + "path/filepath" + "testing" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/state" +) + +// Every entry has to be complete: a zone missing its EIC breaks the direct +// ENTSO-E provider, and one missing its currency would silently price a +// household in öre. +func TestZoneCatalogIsComplete(t *testing.T) { + seen := map[string]bool{} + for _, z := range Zones() { + switch { + case z.Code == "": + t.Errorf("%+v: empty code", z) + case z.Country == "": + t.Errorf("%s: no country", z.Code) + case z.Currency == "": + t.Errorf("%s: no currency", z.Code) + case len(z.EIC) != 16: + t.Errorf("%s: EIC %q is %d chars, want 16", z.Code, z.EIC, len(z.EIC)) + } + if seen[z.Code] { + t.Errorf("%s: duplicate zone code", z.Code) + } + seen[z.Code] = true + } + // Sanity: the zones people actually ask about are all there. + for _, code := range []string{"SE3", "BE", "NL", "DE", "FR", "ES", "PL", "IT-NORTH", "NO5"} { + if !seen[code] { + t.Errorf("%s missing from the catalog", code) + } + } +} + +func TestLookupZoneIgnoresCaseAndSpace(t *testing.T) { + for _, in := range []string{"be", " BE ", "Be"} { + z, ok := LookupZone(in) + if !ok || z.Code != "BE" { + t.Errorf("LookupZone(%q) = %+v, %v; want BE", in, z, ok) + } + } + if _, ok := LookupZone("ZZ9"); ok { + t.Error("unknown zone should not resolve") + } +} + +func TestZoneNameDistinguishesMultiZoneCountries(t *testing.T) { + be, _ := LookupZone("BE") + if be.Name() != "Belgium" { + t.Errorf("single-zone country: %q", be.Name()) + } + se3, _ := LookupZone("SE3") + if se3.Name() != "Sweden — Stockholm" { + t.Errorf("multi-zone country: %q", se3.Name()) + } +} + +// The whole point of the zone picker: choosing Belgium must not leave the +// household paying in Swedish öre. +func TestFromConfigTakesCurrencyFromZone(t *testing.T) { + st, _ := state.Open(filepath.Join(t.TempDir(), "t.db")) + defer st.Close() + + for _, tc := range []struct { + zone, configured, want string + }{ + {"BE", "", "EUR"}, + {"NO1", "", "NOK"}, + {"SE3", "", "SEK"}, + {"", "", "SEK"}, // no zone → old default + {"BE", "SEK", "SEK"}, // an explicit choice wins + {"XX9", "", "SEK"}, // unknown zone → old default + } { + s := FromConfig(&config.Price{Provider: "sourceful", Zone: tc.zone, Currency: tc.configured}, st, nil) + if s == nil { + t.Fatalf("zone %q: expected a service", tc.zone) + } + if s.Currency != tc.want { + t.Errorf("zone %q currency %q: got %s, want %s", tc.zone, tc.configured, s.Currency, tc.want) + } + if sp, ok := s.Provider.(*SourcefulProvider); ok && sp.Currency != tc.want { + t.Errorf("zone %q: provider currency %s, want %s", tc.zone, sp.Currency, tc.want) + } + } +} + +// Cached rows are minor units with no currency attached, so switching +// currency has to empty them — otherwise cost history adds öre to cent. +func TestCurrencyChangeClearsCachedPrices(t *testing.T) { + st, err := state.Open(filepath.Join(t.TempDir(), "t.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + rows := []state.PricePoint{{ + Zone: "SE3", SlotTsMs: 1_700_000_000_000, SlotLenMin: 60, + SpotOreKwh: 80, TotalOreKwh: 190, Source: "sourceful", FetchedAtMs: 1, + }} + if err := st.SavePrices(rows); err != nil { + t.Fatal(err) + } + stored := func() int { + got, err := st.LoadPrices("SE3", 0, 1_800_000_000_000) + if err != nil { + t.Fatal(err) + } + return len(got) + } + + // First boot records the currency and keeps what's there. + (&Service{Store: st, Zone: "SE3", Currency: "SEK"}).syncCachedCurrency() + if stored() != 1 { + t.Fatal("first boot should not clear the cache") + } + // Same currency again: still untouched. + (&Service{Store: st, Zone: "SE3", Currency: "SEK"}).syncCachedCurrency() + if stored() != 1 { + t.Fatal("unchanged currency should not clear the cache") + } + // Switching to EUR drops the öre rows. + (&Service{Store: st, Zone: "SE3", Currency: "EUR"}).syncCachedCurrency() + if n := stored(); n != 0 { + t.Fatalf("currency change left %d rows, want 0", n) + } + // And the new currency is what's remembered. + if got, _ := st.LoadConfig(priceCurrencyKey); got != "EUR" { + t.Errorf("recorded currency %q, want EUR", got) + } +} diff --git a/go/internal/state/store.go b/go/internal/state/store.go index 1aea5115..3c6c7694 100644 --- a/go/internal/state/store.go +++ b/go/internal/state/store.go @@ -1781,6 +1781,25 @@ func (s *Store) LoadPrices(zone string, sinceMs, untilMs int64) ([]PricePoint, e return out, rows.Err() } +// ClearPrices drops every cached price row and returns how many went. +// +// Price rows carry no currency of their own — they are minor units of +// whichever currency was configured when they were fetched. Changing the +// currency therefore has to empty the cache, or cost history would sum +// öre and cent as if they were the same thing. Prices live in the +// disposable cache DB and the next fetch refills today and tomorrow. +func (s *Store) ClearPrices() (int64, error) { + res, err := s.cache.Exec(`DELETE FROM prices`) + if err != nil { + return 0, err + } + n, err := res.RowsAffected() + if err != nil { + return 0, nil // deletion succeeded; the count is a nicety + } + return n, nil +} + // ---- Forecasts ---- // ForecastPoint is one slot's weather + derived PV estimate. diff --git a/web/app.js b/web/app.js index fa689506..eeaba837 100644 --- a/web/app.js +++ b/web/app.js @@ -16,6 +16,15 @@ return w / 1000; } function isFlowIdle(kw) { return Math.abs(kw) <= flowIdleKw(); } + // Prices arrive as minor units per kWh; what to call them depends on the + // configured currency. window.FTWUnits is set when + // components/price-units.js loads — same read-at-use-time pattern as + // flowIdleKw above, with öre as the no-modules fallback. + function fmtPricePerKwh(minorPerKwh) { + const u = typeof window !== "undefined" && window.FTWUnits; + if (!u) return minorPerKwh.toFixed(0) + " öre/kWh"; + return u.formatPricePerKwh(minorPerKwh, u.activeCurrency()); + } const CHART_POINTS = 360; // up to 30 min of points (server pushes every ~5s) const CHART_RANGE_MS = { // visible time window per range option "5m": 5 * 60 * 1000, @@ -1720,7 +1729,7 @@ { name: "Battery", val: a.battery_w, color: "#f59e0b", showSign: true }, { name: "Grid", val: a.grid_w, color: "#ef4444", showSign: true }, { name: "SoC", val: a.soc_pct + "%", color: "#60a5fa", literal: true }, - { name: "Price", val: a.price_ore.toFixed(0) + " öre/kWh", color: "#fbbf24", literal: true }, + { name: "Price", val: fmtPricePerKwh(a.price_ore), color: "#fbbf24", literal: true }, ]; var lineHeight = 16; diff --git a/web/components/ftw-price-chart.js b/web/components/ftw-price-chart.js index ae9399de..9cfe6717 100644 --- a/web/components/ftw-price-chart.js +++ b/web/components/ftw-price-chart.js @@ -30,6 +30,7 @@ import { formatPriceSlotLabel, } from "./price-summary.js"; import { bestBlock, consumerTotalOre, priceParts } from "./price-math.js"; +import { setActiveCurrency, toDisplay, unitFor } from "./price-units.js"; import { buildPriceStrip } from "./price-strip.js"; class FtwPriceChart extends FtwElement { @@ -429,7 +430,8 @@ class FtwPriceChart extends FtwElement { this._refreshTimer = null; this._hover = null; // { idx, x, y } during hover this._vatPct = 25; // fallback; overwritten from /api/config - this._gridTariff = 0; // öre/kWh excl. VAT; from /api/config + this._gridTariff = 0; // minor units/kWh excl. VAT; from /api/config + this._currency = "SEK"; // what those minor units are; from /api/prices this._geom = null; // { padL, plotW, n, W } — set in _renderChart this._isTouching = false; // suppresses synthesized mouse events after touch } @@ -524,6 +526,10 @@ class FtwPriceChart extends FtwElement { spot: Number(it.spot_ore_kwh) || 0, })).sort((a, b) => a.tsMs - b.tsMs); this._data = { zone: j.zone || "", items }; + // The response says which currency the stored minor units are in; + // it decides the label, not the arithmetic. Sharing it saves the + // views that show a price without fetching one their own request. + if (j.currency) this._currency = setActiveCurrency(j.currency); this._priceState = "ready"; } this.update(); @@ -533,14 +539,14 @@ class FtwPriceChart extends FtwElement { } } - // Resolved öre/kWh per slot for the active toggle. "Total" is what the - // slot actually costs to import: (spot + grid tariff) × (1 + VAT/100). + // Resolved minor units/kWh per slot for the active toggle. "Total" is + // what the slot actually costs to import: (spot + grid tariff) × (1 + VAT/100). _priceFor(item) { if (!this._totalOn) return item.spot; return consumerTotalOre(item.spot, this._gridTariff, this._vatPct); } - // Breakdown of the consumer total for one slot, in öre/kWh. + // Breakdown of the consumer total for one slot, in minor units/kWh. _partsFor(item) { return priceParts(item.spot, this._gridTariff, this._vatPct); } @@ -606,7 +612,8 @@ class FtwPriceChart extends FtwElement { const lo = Math.min(...prices); const hi = Math.max(...prices); const avg = prices.reduce((a, b) => a + b, 0) / prices.length; - const fmt = v => (v == null ? "—" : v.toFixed(1) + " öre"); + const unit = unitFor(this._currency); + const fmt = v => (v == null ? "—" : toDisplay(v, this._currency).toFixed(unit.decimals) + " " + unit.label); statsHtml = `
now ${fmt(cur)} @@ -673,10 +680,12 @@ class FtwPriceChart extends FtwElement { } const summary = view.summary; + const unit = unitFor(this._currency); const formatOre = (value) => { if (!Number.isFinite(value)) return "—"; - const digits = Math.abs(value) >= 100 ? 0 : 1; - return Number(value).toFixed(digits); + const shown = toDisplay(value, this._currency); + const digits = Math.abs(shown) >= 100 ? 0 : unit.decimals; + return shown.toFixed(digits); }; const currentValue = summary.current ? formatOre(summary.current.ore) : "—"; // The cheapest contiguous 2 h ahead, not the cheapest single slot: a @@ -699,19 +708,19 @@ class FtwPriceChart extends FtwElement { ? `Last update failed` : ""; const accessible = summary.current - ? `Current electricity price ${currentValue} öre per kilowatt-hour. Cheapest two hours ${lowValue} öre at ${lowTime}.` - : `No current electricity price slot. Cheapest two hours ${lowValue} öre at ${lowTime}.`; + ? `Current electricity price ${currentValue} ${unit.label} per kilowatt-hour. Cheapest two hours ${lowValue} ${unit.label} at ${lowTime}.` + : `No current electricity price slot. Cheapest two hours ${lowValue} ${unit.label} at ${lowTime}.`; return `${head}
${currentValue} - öre/kWh + ${unit.perKwh} ${escapeXml(data.zone || "—")} · ${vatLabel}
Cheapest 2 h - ${lowValue} öre + ${lowValue} ${unit.label} ${escapeXml(lowTime)}
@@ -731,6 +740,8 @@ class FtwPriceChart extends FtwElement { if (!strip) { return `
No prices published ahead yet.
`; } + const unit = unitFor(this._currency); + const mean = roundOre(toDisplay(strip.mean, this._currency)); const bars = strip.bars.map((b) => { const cls = [b.side === "up" ? "is-dear" : b.side === "down" ? "is-cheap" : "is-flat", b.current ? "is-current" : ""].filter(Boolean).join(" "); @@ -739,11 +750,11 @@ class FtwPriceChart extends FtwElement { }).join(""); return ` + role="img" aria-label="Price against today's average of ${mean} ${unit.label} per kWh. Bars above the line are dearer, below are cheaper."> ${bars} -
vs the average ahead, ${roundOre(strip.mean)} öre
+
vs the average ahead, ${mean} ${unit.label}
`; } @@ -787,7 +798,7 @@ class FtwPriceChart extends FtwElement { // were readable but the NOW marker felt thin and crowded against // the bars. +50 % on axes, +33 % on NOW + thicker stroke so the // current hour reads at-a-glance from across a room. - const fsAxis = small ? 27 : 10; // y-axis öre + x-axis time + const fsAxis = small ? 27 : 10; // y-axis price + x-axis time const fsNow = small ? 24 : 10; // NOW label const fsMark = small ? 26 : 11; // peak/low ▼▲ glyphs const nowStrokeW = small ? 3 : 1.5; @@ -875,10 +886,12 @@ class FtwPriceChart extends FtwElement { } } // Y-axis labels — min / mean / max. + const axisUnit = unitFor(this._currency); + const axisTick = (v) => roundOre(toDisplay(v, this._currency)) + " " + axisUnit.axis; const yLabels = [ - { y: yToPx(yMax), text: roundOre(yMax) + " ö" }, - { y: meanY, text: roundOre(meanP) + " ö" }, - { y: yToPx(yMin), text: roundOre(yMin) + " ö" }, + { y: yToPx(yMax), text: axisTick(yMax) }, + { y: meanY, text: axisTick(meanP) }, + { y: yToPx(yMin), text: axisTick(yMin) }, ].map((l) => `${l.text}`).join(""); @@ -1111,7 +1124,8 @@ class FtwPriceChart extends FtwElement { tip.querySelector("[data-tip-time]").textContent = `${fmtClock(item.tsMs)}–${fmtClock(tEnd)}`; const priceEl = tip.querySelector("[data-tip-price]"); - priceEl.textContent = `${roundOre(price)} öre`; + const shown = (v) => roundOre(toDisplay(v, this._currency)); + priceEl.textContent = `${shown(price)} ${unitFor(this._currency).label}`; // Breakdown line — only in Total mode, and only when there is // something beyond spot to break out. const partsEl = tip.querySelector("[data-tip-parts]"); @@ -1120,7 +1134,7 @@ class FtwPriceChart extends FtwElement { if (showParts) { const p = this._partsFor(item); partsEl.textContent = - `spot ${roundOre(p.spot)} + grid ${roundOre(p.grid)} + VAT ${roundOre(p.vat)}`; + `spot ${shown(p.spot)} + grid ${shown(p.grid)} + VAT ${shown(p.vat)}`; partsEl.hidden = false; } else { partsEl.hidden = true; @@ -1249,6 +1263,9 @@ function fmtClock(tsMs) { d.getMinutes().toString().padStart(2, "0"); } +// Significant-figure rounding for a display-unit value: three digits when +// the number is large, two decimals when it's small. Works the same for 234 +// öre and for 2.34 Kč, which is why it takes the already-scaled value. function roundOre(v) { if (Math.abs(v) >= 100) return v.toFixed(0); if (Math.abs(v) >= 10) return v.toFixed(1); diff --git a/web/components/ftw-savings-card.js b/web/components/ftw-savings-card.js index 084762e8..dc0ad06c 100644 --- a/web/components/ftw-savings-card.js +++ b/web/components/ftw-savings-card.js @@ -16,6 +16,7 @@ import { FtwElement, ftwDebugDelay } from "./ftw-element.js"; import { apiFetch } from "./api-fetch.js"; +import { activeCurrency } from "./price-units.js"; class FtwSavingsCard extends FtwElement { static styles = ` @@ -525,7 +526,10 @@ class FtwSavingsCard extends FtwElement { return v.toFixed(2); }; - totalEl.textContent = `${sign(savedSek)}${fmtSek(savedSek)} SEK ${savedSek >= 0 ? "saved" : "lost"}`; + // Savings are the same minor units the prices are in, so the headline + // carries the household's currency rather than a hardcoded SEK. + const cur = activeCurrency(); + totalEl.textContent = `${sign(savedSek)}${fmtSek(savedSek)} ${cur} ${savedSek >= 0 ? "saved" : "lost"}`; if (Math.abs(pct) >= 0.5) { pctEl.textContent = `(${sign(pct)}${Math.abs(pct).toFixed(0)}%)`; } else { @@ -545,7 +549,7 @@ class FtwSavingsCard extends FtwElement { const actualSek = actualOre / 100; const baselineSek = baselineOre / 100; subEl.innerHTML = - `Actual ${fmtSek(actualSek)} SEK, no PV/battery ${fmtSek(baselineSek)} SEK`; + `Actual ${fmtSek(actualSek)} ${cur}, no PV/battery ${fmtSek(baselineSek)} ${cur}`; // ---- Sparkline ----------------------------------------------------- // Bars on a zero baseline, full height split 50/50 above/below. @@ -572,7 +576,7 @@ class FtwSavingsCard extends FtwElement { } const axisAbs = maxAbs; if (maxAbs === 0) maxAbs = 1; - if (axisMaxEl) axisMaxEl.textContent = axisAbs > 0 ? '+' + fmtSekOre(axisAbs) : '0 SEK'; + if (axisMaxEl) axisMaxEl.textContent = axisAbs > 0 ? '+' + fmtSekOre(axisAbs) : '0 ' + activeCurrency(); if (axisMinEl) axisMinEl.textContent = hasNegative ? '−' + fmtSekOre(axisAbs) : ''; const maxBarH = baselineY - 4; // 4 px headroom @@ -592,7 +596,7 @@ class FtwSavingsCard extends FtwElement { hh = h; cls = "bar-neg"; } - const title = `${d.day}: ${sign(v / 100)}${fmtSek(Math.abs(v) / 100)} SEK`; + const title = `${d.day}: ${sign(v / 100)}${fmtSek(Math.abs(v) / 100)} ${cur}`; parts.push( `` + @@ -618,11 +622,13 @@ function fmtDayShort(iso) { const d = new Date(+parts[0], +parts[1] - 1, +parts[2]); return d.toLocaleDateString(undefined, { weekday: "short", day: "numeric" }); } +// Minor units in, major units out, labelled with the household's currency. function fmtSekOre(ore) { + const cur = activeCurrency(); const sek = Math.abs(Number(ore) || 0) / 100; - if (sek >= 100) return sek.toFixed(0) + " SEK"; - if (sek >= 10) return sek.toFixed(1) + " SEK"; - return sek.toFixed(2) + " SEK"; + if (sek >= 100) return sek.toFixed(0) + " " + cur; + if (sek >= 10) return sek.toFixed(1) + " " + cur; + return sek.toFixed(2) + " " + cur; } function fmtSavedSekOre(ore) { const v = Number(ore) || 0; diff --git a/web/components/index.js b/web/components/index.js index ee2f11fe..f806e63c 100644 --- a/web/components/index.js +++ b/web/components/index.js @@ -4,6 +4,12 @@ // components get picked up by adding one line below. import "./ftw-element.js"; +// Price units land on window.FTWUnits for the classic scripts that can't +// import them; the import has to happen whether or not a component uses it. +import { bootstrapCurrency } from "./price-units.js"; +// One request, so a view that shows prices without fetching them still +// labels them in the household's own currency. +bootstrapCurrency(); // Foundations — registered as each lands. import "./ftw-modal.js"; import "./ftw-progress-bar.js"; @@ -14,11 +20,11 @@ import "./ftw-legend.js"; import "./ftw-energy-flow.js"; import "./ftw-battery-control.js?v=apifetch1"; import "./ftw-pv-control.js?v=apifetch1"; -import "./ftw-price-chart.js?v=apiread3"; +import "./ftw-price-chart.js?v=zones1"; import "./ftw-energy-cake.js"; import "./ftw-bar-chart.js"; import "./ftw-history-card.js?v=apiread2"; -import "./ftw-savings-card.js?v=apiread1"; +import "./ftw-savings-card.js?v=zones1"; import "./ftw-update-check.js?v=apifetch1"; import "./ftw-notif-status.js?v=apifetch1"; import "./ftw-notif-test-button.js"; diff --git a/web/components/price-units.js b/web/components/price-units.js new file mode 100644 index 00000000..3bd60984 --- /dev/null +++ b/web/components/price-units.js @@ -0,0 +1,116 @@ +// What to call the number on a price. Every price the API returns is in +// minor units per kWh — öre for SEK, cent for EUR, øre for NOK — because +// go/internal/prices stores spot × 100 whatever the currency. Only the +// label and the sensible number of decimals differ, so they live here +// rather than being spelled "öre" in ten places. +// +// Pure data + pure functions: components import it, and it also lands on +// window.FTWUnits for the classic scripts (app.js, diagnose.js, +// loadpoints.js, the settings tabs) that can't import. + +// scale is what a stored minor unit is multiplied by for display. 1 keeps +// minor units (öre, cent); 0.01 shows the major unit instead, which is how +// koruna, forint and leu are actually quoted — 4 Kč/kWh, not 400 haléř. +// +// axis is the cramped form for chart tick labels, where a three-digit price +// and its unit share about six characters. +const UNITS = { + SEK: { label: "öre", perKwh: "öre/kWh", axis: "ö", scale: 1, decimals: 1 }, + NOK: { label: "øre", perKwh: "øre/kWh", axis: "ø", scale: 1, decimals: 1 }, + DKK: { label: "øre", perKwh: "øre/kWh", axis: "ø", scale: 1, decimals: 1 }, + EUR: { label: "cent", perKwh: "cent/kWh", axis: "c", scale: 1, decimals: 1 }, + PLN: { label: "gr", perKwh: "gr/kWh", axis: "gr", scale: 1, decimals: 1 }, + CHF: { label: "Rp.", perKwh: "Rp./kWh", axis: "Rp", scale: 1, decimals: 1 }, + 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 }, +}; + +// A currency with no entry above is shown in its major unit under its ISO +// code — never wrong, just less familiar than "öre". +function fallback(code) { + const c = String(code || "").toUpperCase(); + return { label: c, perKwh: c + "/kWh", axis: c, scale: 0.01, decimals: 3 }; +} + +// unitFor returns { label, perKwh, scale, decimals } for a currency code. +// An empty or unknown code falls back to SEK, which is what installs +// predating the currency setting are in. +export function unitFor(currency) { + const code = String(currency || "SEK").toUpperCase(); + return UNITS[code] || fallback(code); +} + +// The unit on its own: "öre", "cent", "Kč". +export function unitLabel(currency) { + return unitFor(currency).label; +} + +// The unit per kWh: "öre/kWh", "cent/kWh". +export function unitPerKwh(currency) { + return unitFor(currency).perKwh; +} + +// A stored minor-unit value in display units, unrounded. +export function toDisplay(minorPerKwh, currency) { + return (minorPerKwh || 0) * unitFor(currency).scale; +} + +// A stored minor-unit value as text with its unit: "17.4 öre". +// decimals overrides the currency's own default when a surface wants +// whole numbers. +export function formatPrice(minorPerKwh, currency, decimals) { + const u = unitFor(currency); + const d = decimals == null ? u.decimals : decimals; + return toDisplay(minorPerKwh, currency).toFixed(d) + " " + u.label; +} + +// Same, but with the /kWh suffix: "17.4 öre/kWh". +export function formatPricePerKwh(minorPerKwh, currency, decimals) { + const u = unitFor(currency); + const d = decimals == null ? u.decimals : decimals; + return toDisplay(minorPerKwh, currency).toFixed(d) + " " + u.perKwh; +} + +// ---- The install's own currency ---- +// +// Surfaces that show a price but never fetch one — the plan tooltip in +// app.js, the diagnose timeline, the loadpoint schedule — read it from +// here instead of each asking the API. Whoever reads /api/prices sets it; +// bootstrapCurrency covers a page that opens straight onto one of those +// views. Until it resolves the answer is SEK, which is what every install +// predating the currency setting is in. +let active = "SEK"; + +export function activeCurrency() { + return active; +} + +export function setActiveCurrency(code) { + if (code) active = String(code).toUpperCase(); + return active; +} + +// Asks the price API for the currency alone — an empty time window, so the +// answer carries no price rows. Failure leaves the SEK default in place. +export function bootstrapCurrency(fetchImpl) { + const f = fetchImpl || (typeof fetch === "function" ? fetch : null); + if (!f) return Promise.resolve(active); + return f("/api/prices?since_ms=0&until_ms=0") + .then((r) => r.json()) + .then((j) => setActiveCurrency(j && j.currency)) + .catch(() => active); +} + +if (typeof window !== "undefined") { + window.FTWUnits = { + unitFor, + unitLabel, + unitPerKwh, + toDisplay, + formatPrice, + formatPricePerKwh, + activeCurrency, + setActiveCurrency, + }; +} diff --git a/web/components/price-units.test.mjs b/web/components/price-units.test.mjs new file mode 100644 index 00000000..bb10f57c --- /dev/null +++ b/web/components/price-units.test.mjs @@ -0,0 +1,63 @@ +// node --test web/components/price-units.test.mjs + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + activeCurrency, + formatPrice, + formatPricePerKwh, + setActiveCurrency, + toDisplay, + unitFor, + unitLabel, +} from "./price-units.js"; + +describe("price units", () => { + it("keeps minor units where households quote them", () => { + for (const [code, label] of [["SEK", "öre"], ["EUR", "cent"], ["NOK", "øre"], ["DKK", "øre"]]) { + const u = unitFor(code); + assert.equal(u.label, label); + assert.equal(u.scale, 1, `${code} should stay in minor units`); + } + // 17.4 öre stays 17.4; 17.4 cent stays 17.4. + assert.equal(formatPrice(17.4, "SEK"), "17.4 öre"); + assert.equal(formatPrice(17.4, "EUR"), "17.4 cent"); + }); + + it("shows the major unit where the minor one is out of circulation", () => { + // 430 haléř is not how anyone quotes power in Czechia — 4.30 Kč is. + assert.equal(formatPrice(430, "CZK"), "4.30 Kč"); + assert.equal(formatPricePerKwh(4200, "HUF"), "42.0 Ft/kWh"); + assert.equal(formatPrice(85, "RON"), "0.85 lei"); + }); + + it("falls back to the ISO code rather than guessing a unit name", () => { + const u = unitFor("ISK"); + assert.equal(u.label, "ISK"); + assert.equal(u.perKwh, "ISK/kWh"); + assert.equal(u.scale, 0.01); + }); + + it("treats a missing currency as SEK, which is what old installs are in", () => { + assert.equal(unitLabel(""), "öre"); + assert.equal(unitLabel(undefined), "öre"); + assert.equal(unitLabel("sek"), "öre"); + }); + + it("scales values for display without touching the stored number", () => { + assert.equal(toDisplay(250, "SEK"), 250); + assert.equal(toDisplay(250, "EUR"), 250); + assert.equal(Math.round(toDisplay(250, "CZK") * 100) / 100, 2.5); + }); + + it("shares one active currency so every view agrees", () => { + assert.equal(activeCurrency(), "SEK"); + assert.equal(setActiveCurrency("eur"), "EUR"); + assert.equal(activeCurrency(), "EUR"); + // An empty answer must not wipe a known currency. + setActiveCurrency(""); + assert.equal(activeCurrency(), "EUR"); + setActiveCurrency("SEK"); + }); +}); diff --git a/web/diagnose.js b/web/diagnose.js index 6aeeef76..937bde85 100644 --- a/web/diagnose.js +++ b/web/diagnose.js @@ -19,6 +19,17 @@ return fetch(path, opts); } + // Costs come from the planner as minor units per kWh in the configured + // currency; window.FTWUnits (components/price-units.js) knows what to + // call them. Falls back to öre, which is what installs without the + // currency setting are in. + function fmtCost(minor) { + const u = typeof window !== 'undefined' && window.FTWUnits; + if (!u) return Math.round(minor) + ' öre'; + const cur = u.activeCurrency(); + return u.formatPrice(minor, cur, u.unitFor(cur).scale === 1 ? 0 : 2); + } + function canvasColors() { return window.ftwThemeColors ? window.ftwThemeColors.palette() @@ -213,7 +224,7 @@ return ``; }).join(''); el.innerHTML = rows; @@ -293,7 +304,7 @@ ${escapeHtml(s.reason)} zone ${escapeHtml(s.zone)} · ${s.horizon_slots} slots · - expected ${Math.round(s.total_cost_ore)} öre · + expected ${fmtCost(s.total_cost_ore)} · ${ageMin} min ago
diff --git a/web/index.html b/web/index.html index b671183b..567aa68a 100644 --- a/web/index.html +++ b/web/index.html @@ -50,7 +50,7 @@ } - +
@@ -930,13 +930,13 @@

Price bars (top of the chart)

- + - + @@ -948,12 +948,12 @@

Price bars (top of the chart)

- + - + - + - + diff --git a/web/setup.js b/web/setup.js index 5a828178..93d5445d 100644 --- a/web/setup.js +++ b/web/setup.js @@ -761,6 +761,8 @@ provider: priceProv, zone: zone }; + var cur = currencyForZone(zone); + if (cur) cfg.price.currency = cur; } // EV Charger — shape the block to match the provider's transport @@ -809,6 +811,62 @@ return d.innerHTML; } + // --- Price zones --- + // + // The country and zone lists come from /api/prices/zones — the same + // table the price fetchers use — so the wizard can't offer a zone the + // providers don't know. A failed request leaves the Swedish options in + // setup.html standing. + var priceZones = []; + + function loadPriceZones() { + fetch('/api/prices/zones') + .then(function (r) { return r.json(); }) + .then(function (j) { + if (!j || !Array.isArray(j.zones) || !j.zones.length) return; + priceZones = j.zones; + var countryEl = document.getElementById('price-country'); + countryEl.innerHTML = ''; + var seen = {}; + priceZones.forEach(function (z) { + if (seen[z.country]) return; + seen[z.country] = true; + var o = document.createElement('option'); + o.value = z.country; + o.textContent = z.country; + if (z.country === 'Sweden') o.selected = true; + countryEl.appendChild(o); + }); + countryEl.addEventListener('change', fillZonesForCountry); + fillZonesForCountry(); + }) + .catch(function () { /* the Swedish fallback list stays */ }); + } + + function fillZonesForCountry() { + var country = document.getElementById('price-country').value; + var zoneEl = document.getElementById('price-zone'); + zoneEl.innerHTML = ''; + priceZones.filter(function (z) { return z.country === country; }) + .forEach(function (z, i) { + var sub = z.name && z.name.indexOf(' — ') > 0 ? z.name.split(' — ')[1] : ''; + var o = document.createElement('option'); + o.value = z.code; + o.textContent = sub ? z.code + ' · ' + sub : z.code; + if (i === 0 || z.code === 'SE3') o.selected = true; + zoneEl.appendChild(o); + }); + } + + // The currency the picked zone bills in, so a Belgian install doesn't + // start out priced in Swedish öre. + function currencyForZone(code) { + for (var i = 0; i < priceZones.length; i++) { + if (priceZones[i].code === code) return priceZones[i].currency; + } + return ''; + } + // --- Init --- // Honor a `?step=N` deep-link (the dashboard links to /setup?step=3 from // its "no devices" prompt). Clamp into the valid 1..TOTAL_STEPS range so a @@ -830,5 +888,6 @@ } renderDots(); + loadPriceZones(); goStep(initialStep()); })(); From 7c605f9cd3596d1b7ccdd5f7c4e5368ea8d74bce Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 31 Jul 2026 16:24:15 +0200 Subject: [PATCH 2/2] test(web): stop pinning plan.js's cache-bust token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assertion matched the whole script tag including `?v=dashboard1`, so bumping the token — which every change to plan.js has to do, or a returning browser keeps the old file — failed a test about module loading. Match the token loosely and keep the point: the page loads plan.js as a module. Co-Authored-By: Claude Opus 5 --- web/dashboard-simplification.test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/dashboard-simplification.test.mjs b/web/dashboard-simplification.test.mjs index 70fc9aaf..0780a090 100644 --- a/web/dashboard-simplification.test.mjs +++ b/web/dashboard-simplification.test.mjs @@ -155,7 +155,9 @@ describe("simplified dashboard overview", () => { }); it("renders Overview and Plan from the sole plan polling path", () => { - assert.match(html, /