Skip to content

feat(prices): every European bidding zone, billed in its own currency - #748

Open
frahlg wants to merge 2 commits into
masterfrom
feat/european-price-zones
Open

feat(prices): every European bidding zone, billed in its own currency#748
frahlg wants to merge 2 commits into
masterfrom
feat/european-price-zones

Conversation

@frahlg

@frahlg frahlg commented Jul 31, 2026

Copy link
Copy Markdown
Member

A question from a user in Belgium: can they pick their own price zone?

They could not. The zone picker offered twelve Nordic codes typed into two
<select> elements, and the Go side knew twelve EIC codes — even though the
Sourceful price API has served all 46 ENTSO-E areas all along.

What changed

One zone table. go/internal/prices/zones.go is generated from the price
API's /areas endpoint and carries area code, country, EIC and local
currency. It replaces the hand-written entsoeZoneEIC map and is served to
the UI at GET /api/prices/zones, so the picker cannot offer a zone the
fetchers don't know.

Country first, zone second, in both Settings → Price and the setup
wizard. Everyone knows they live in Italy; nobody knows their area code is
IT-CENTRE-NORTH. elprisetjustnu is held to SE1–SE4, the only zones it can
serve.

Currency follows the zone. The price API converts to EUR and SEK and
quietly answers any other currency code with EUR — no error — so it is only
asked for those two, and everything else is converted here from EUR with the
ECB rates go/internal/currency already caches. A missing rate now fails the
fetch instead of falling back to a ballpark 11.5 SEK/EUR: a price wrong by an
exchange rate is a price the planner spends money on.

Units follow the currency across every price surface — öre, cent, øre,
grosz, Rappen, and the major unit where the minor one is out of circulation
(4.30 Kč/kWh, not 430 haléř). The savings card and the plan summary printed a
hardcoded SEK over whatever the numbers really were; they don't now.

Two faults found 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
    reads currency_Unit.name and converts from that.
  • Its EIC code for Germany was the country code (10Y1001A1001A83F) rather
    than the DE-LU bidding zone (10Y1001A1001A82H), and NO5 was missing
    outright while the UI offered it.

Cache

Stored prices are minor units per kWh with no currency attached, so changing
the currency clears the price cache — otherwise cost history would add öre to
cent. Prices live in the disposable cache DB and the next fetch refills today
and tomorrow. A price-config change already requires a restart, which is when
the check runs.

Verification

Run against the live API with zone: BE, currency unset:

price currency changed — cached prices cleared from=SEK to=EUR rows=192
prices fetched zone=BE day=2026-07-31 count=96 source=sourceful

192 quarter-hour slots, spot 17.73 cent/kWh → total 32.34 with a 9 cent grid
tariff and 21 % VAT. The dashboard renders 23.4 cent/kWh · BE, the chart
y-axis ticks in c, and the savings card in EUR. Switching country in
Settings moves the zone list, the currency and every unit label with it;
switching the provider to elprisetjustnu pulls a foreign zone back into
Sweden rather than emptying the select.

Cache-busting ?v= tokens are bumped for every changed script — without that
a returning browser keeps the old file, which is exactly how the stale
SEK label showed up during testing.

Compatibility

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.

make verify clean, 269 web tests pass.

🤖 Generated with Claude Code

The zone picker offered twelve Nordic codes typed into two <select>
elements and the Go side knew twelve EIC codes, so a household in Belgium
could not choose its own zone — even though the Sourceful price API has
served all 46 ENTSO-E areas all along.

Both lists now come from one table, 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: everyone knows they live in Italy,
nobody knows their area code is IT-CENTRE-NORTH.

Currency follows the zone. The price API converts to EUR and SEK and
quietly answers anything else with EUR, so it is only asked for those
two; the rest is converted here from EUR with the ECB rates the currency
service already caches. A missing rate now fails the fetch instead of
falling back to 11.5 SEK/EUR — a price wrong by an exchange rate is one
the planner spends money on.

Two faults surfaced on the way. The direct ENTSO-E provider assumed every
day-ahead document is priced in EUR, but Poland and Hungary among others
publish in their own currency, so it reads currency_Unit.name and converts
from that. Its German EIC was the country code rather than the DE-LU
bidding zone, and NO5 was missing outright.

Stored prices are minor units per kWh with no currency attached, so a
currency change clears the price cache rather than letting cost history
add öre to cent. Every price label in the UI now follows the configured
currency, including 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@frahlg
frahlg requested a review from erikarenhill as a code owner July 31, 2026 14:21
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 <noreply@anthropic.com>
@frahlg

frahlg commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

@erikarenhill — over to you, and yours to merge when you're happy with it.

This started as a user question from Belgium: can they pick their own price
zone? They couldn't. The API had every European zone all along; our picker
and our Go table had stopped in the Nordics.

Most of the diff is web/, so the parts worth your eye:

  • settings/tabs/price.js — country select first, zone select second,
    both built from GET /api/prices/zones. Changing country re-renders the
    tab; captureCurrentTab() runs before the override, or the visible
    selects write the old values straight back. Falls back to a Nordic list if
    the request fails, and pulls a foreign zone back into Sweden when the
    provider is switched to elprisetjustnu.
  • components/price-units.js (new) — one table for what to call a price.
    Every stored price is minor units per kWh, so only the label and the
    decimals change: öre, cent, øre, grosz, Rappen, and the major unit where
    the minor one is out of circulation (4.30 Kč/kWh, not 430 haléř). Modules
    import it; the classic scripts read window.FTWUnits.
  • Cache-bust tokens are bumped for every changed script. Worth knowing:
    a stale SEK label during testing turned out to be a cached index.html,
    not a code bug. A test that pinned plan.js?v=dashboard1 had to be
    loosened — it failed on any token bump, which every change to plan.js
    needs.

Verified against the live API rather than fixtures: zone BE returns 192
quarter-hour slots, 17.73 cent/kWh spot → 32.34 total with a 9 cent grid
tariff and 21 % VAT. The dashboard renders 23.4 cent/kWh · BE, the chart
y-axis ticks in c, and the savings card reads EUR — that last one had a
hardcoded SEK over whatever the numbers really were.

Existing installs are untouched: no zone means SE3, no currency means SEK.
make verify and npm test are clean locally; CI is green except the two
long jobs still running as I write this.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 90de0830a7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 584 to 585
case "elprisetjustnu":
p = NewElpriser()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep elprisetjustnu prices in SEK

When price.currency is anything other than SEK—either selected manually or left as EUR after switching a foreign zone to this Sweden-only provider—ElpriserProvider still returns its SEK_per_kWh field unchanged, while the service exposes and stores the configured currency. The resulting SEK values are therefore labelled as EUR/NOK/etc. and can misprice planner decisions by the exchange-rate factor; force SEK for this provider or convert its output.

Useful? React with 👍 / 👎.

Comment on lines +626 to +630
prev, ok := s.Store.LoadConfig(priceCurrencyKey)
if ok && prev == s.Currency {
return
}
if ok && prev != "" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Invalidate legacy Sourceful rows when adding the currency marker

On the first upgrade, prices/currency is absent, so this condition preserves every existing row and immediately records the new currency. Before this commit Sourceful always stored SEK regardless of zone, but an existing NO/DK/etc. configuration with no explicit currency now infers its local currency; historical SEK rows then remain permanently mixed with newly fetched local-currency rows and corrupt savings and price-forecast calculations. The no-marker migration needs to infer the legacy provider's actual stored currency and clear when it differs.

Useful? React with 👍 / 👎.

Comment on lines +570 to +573
currency := strings.ToUpper(strings.TrimSpace(cfg.Currency))
if currency == "" {
currency = ZoneCurrency(zone)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scale cold-start forecasts to the selected currency

For a newly supported non-SEK zone, storing prices in the zone's local minor units feeds them into the unchanged priceforecast service, whose cold-start model is a Nordic 50–80 öre prior and whose persisted models carry no currency. The MPC extends day-ahead prices with that model, so a fresh Hungarian install, for example, receives roughly 40 fillér/kWh rather than tens of HUF/kWh beyond the published horizon, which can drive incorrect charging and discharging until enough history is refitted. Reset and currency-scale the forecast model when deriving a different currency.

Useful? React with 👍 / 👎.

Comment on lines +177 to +180
if convert {
native, ok := s.FX.Convert(perKWh, apiCur, want)
if !ok {
return nil, fmt.Errorf("sourceful: no %s→%s rate yet", apiCur, want)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry once initial ECB rates become available

On a fresh NOK/DKK/PLN/etc. installation there are no cached ECB rates, and currency.Service.Start fetches them asynchronously while the price service starts its initial fetch immediately. If Sourceful responds before the ECB request, this conversion returns an error for both requested days; prices.Service.loop then waits for its one-hour ticker even if the FX rates arrive moments later, leaving the dashboard and planner without prices for up to an hour. Coordinate the initial fetch with FX readiness or schedule a prompt retry for this transient missing-rate case.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant