feat(prices): static / time-of-use tariff so planning works outside Europe - #1051
feat(prices): static / time-of-use tariff so planning works outside Europe#1051frahlg wants to merge 2 commits into
Conversation
… chart 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.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f750ad92c5
ℹ️ 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".
| if haveSite { | ||
| resp["latitude"], resp["longitude"] = lat, lon |
There was a problem hiding this comment.
Protect configured site coordinates
On deployments reachable through a public/FQDN host, this returns the configured latitude and longitude without authentication because /api/data-sources is omitted from protectedReadPath and is explicitly treated as an ordinary read in security_test.go. These same coordinates are otherwise available only through the protected /api/config route, so the new endpoint exposes a household's precise location; protect the route or avoid returning configured coordinates.
Useful? React with 👍 / 👎.
| 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} |
There was a problem hiding this comment.
Treat advisory bounding-box matches as uncertain
For locations inside these generous rectangles but outside the supported markets, Covers returns true even though the provider cannot serve them—for example, Algiers is inside europeDomain, while Copenhagen and Oslo are inside swedenDomain. The UI converts that advisory result into a ✓ under “Data sources available here” and suppresses the Price-tab warning, so users in unsupported countries receive the positive availability claim this feature was intended to prevent; use supported geography or render true as uncertain rather than available.
Useful? React with 👍 / 👎.
| if v := r.URL.Query().Get("lat"); v != "" { | ||
| if f, err := strconv.ParseFloat(v, 64); err == nil { | ||
| lat, haveSite = f, true | ||
| } |
There was a problem hiding this comment.
Reject non-finite coordinate query values
strconv.ParseFloat accepts values such as NaN and +Inf, so a request like ?lat=NaN&lon=151.21 sets haveSite and later places a non-finite float in the response. encoding/json rejects that value, but writeJSON has already emitted status 200 and discards the encoding error, leaving the client with an invalid or truncated JSON response; validate finiteness and coordinate ranges before accepting the override.
Useful? React with 👍 / 👎.
miravoss26
left a comment
There was a problem hiding this comment.
Adds GET /api/data-sources plus a coverage registry that answers, per site, whether each weather/price source actually reaches it — surfaced on both the Weather and Price settings tabs so a non-European site gets an explanation instead of an empty chart.
- Correctness: solid.
coversis correctly omitted (not defaulted) when no site is known; query-param lat/lon correctly overrides the configured site for live pin-dragging; the frontend guards against out-of-order responses with a sequence counter on drag. Test coverage is thorough on both the Go registry (bbox inclusivity, no antimeridian wrap, zone-table sync check) and the JS side (Sydney vs Stockholm, missing-site case). - Security: new route is read-only, registered under the existing
Readpermission tier and added to the open-routes allowlist insecurity_test.go— same pattern as/api/prices. No new dependencies, no secrets, frontend renders viaesc()so no injection surface even though the source labels are server-controlled, not user input..catch()on both fetches means a coverage-check failure never breaks the tab. - Docs are in good shape (
data-coverage.md+ architecture.md pointer), and the changeset is accurate.
Safe to merge from my read.
|
I have a fix for this in #734 - just testing this now. |
|
Parking this behind #734 rather than merging both. They both add Please take the pieces this PR got right into #734 before we close this:
Once those are on #734 I will close this so we do not ship two registries. |
…urope 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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit b8ecc20. Configure here.
| ep.FX = fx | ||
| p = ep | ||
| case "static": | ||
| p = NewStatic(cfg) |
There was a problem hiding this comment.
Zero VAT treated as Swedish default
High Severity
FromConfig treats vat_percent: 0 as unset and substitutes 25%. The new static path documents and targets non-EU tariffs with no VAT, so every stored TotalOreKwh is 25% too high and the planner prices grid actions wrong.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit b8ecc20. Configure here.
| SEKPerKWh: ore / 100, | ||
| }) | ||
| } | ||
| return out, nil |
There was a problem hiding this comment.
DST days emit the wrong slots
Medium Severity
Fetch always emits 96 duration steps from local midnight, not the civil day’s quarter-hours. On fall-back the last local hour has no rows; on spring-forward slots spill into the next calendar day.
Reviewed by Cursor Bugbot for commit b8ecc20. Configure here.
| } else { | ||
| var cur = currencyForZone(zone); | ||
| if (cur) cfg.price.currency = cur; | ||
| } |
There was a problem hiding this comment.
Wizard omits static currency
Medium Severity
The static setup branch writes the rate and STATIC but never a currency. FromConfig then falls through ZoneCurrency("STATIC") to SEK, so a non-European first-run install is labeled in öre until Settings is fixed.
Reviewed by Cursor Bugbot for commit b8ecc20. Configure here.
There was a problem hiding this comment.
Left a non-blocking comment: Cursor Bugbot did not complete successfully (skipped) and reported three potential issues on the current head, so this is not approved. Human review is needed; two reviewers were already assigned, so none were added.
Sent by Cursor Approval Agent: Pull Request Router and Approver
miravoss26
left a comment
There was a problem hiding this comment.
Static/TOU price provider — read the full diff (config, static.go, static_test.go, UI).
What it does: adds price.provider: static, a flat-rate + optional time-of-use tariff for markets with no day-ahead feed. 96 quarter-hour slots synthesized locally per day; grid tariff + VAT still apply on top. New Settings tab + setup wizard support.
Correctness: overnight-wrap window math (mins >= start || mins < end) checks out against the 22:00–06:00 test case. 24:00 end-of-day is handled as 1440. Zero-as-real-price and weekday-filtering are both covered by tests I traced by hand.
One soft spot: Price.Validate() only checks that TOU start/end are non-empty at config-load time — a malformed clock string ("25:99") doesn't error until Fetch() runs at request time, not at config parse. Not a correctness bug (the error does propagate cleanly, no panic), but a bad config surfaces as a runtime price-fetch failure rather than an immediate config validate rejection. Worth tightening for fail-fast if you want it, not blocking.
No new network calls, no secrets, tests are thorough (overnight wrap, weekday filter, zero price, currency units). Safe to merge from my read.




Part of #726. The first version of this PR built a coverage registry and a settings panel that told you Europe-only prices do not cover Sydney. That is documentation of a missing feature, not the feature. #734 already owns the coverage UI. This PR is the missing feature: a price source you can type.
What changed
price.provider: static. A flat rate in the install currency, plus optional local-time windows that override peak hours. Overnight windows wrap (22:00–06:00). An optionaldayslist limits a window to weekdays. Hours they miss keep the flat rate. Zero is a real price.Grid tariff and VAT still apply on top — same
Applierpath as Sourceful and ENTSO-E. Nothing is fetched. 96 quarter-hour slots are synthesised for today and tomorrow so the planner sees the same resolution it does in the Nordics.Settings and setup. The Price tab offers
staticnext to Sourceful. The zone picker hides; currency includes USD/GBP/AUD/CAD and labels cents/pence. Time-of-use windows are a details block. The wizard can set a flat rate; TOU is added later in Settings.Docs.
docs/data-coverage.mdis the operator-facing table. Live day-ahead remains Europe-only; static is the worldwide stand-in.What this is not
Overlap
#1044 (
fix/day-ahead-nordpool) also editsFromConfiginprices.go— wrapping sourceful/elprisetjustnu. This PR only adds astaticcase. If #1044 merges first, rebase; the conflict is one switch arm.Tests
go test ./internal/prices ./internal/config ./internal/apiVisual review
A human should open Settings → Price, pick
static, enter a rate, add a 07:00–23:00 window, save, and check the price chart is a step, not empty. Then a narrow viewport. I could not drive a browser here.