Skip to content

feat(prices): static / time-of-use tariff so planning works outside Europe - #1051

Open
frahlg wants to merge 2 commits into
masterfrom
emdash/docs-coverage-table-provider-product-api-718-lqdfr
Open

feat(prices): static / time-of-use tariff so planning works outside Europe#1051
frahlg wants to merge 2 commits into
masterfrom
emdash/docs-coverage-table-provider-product-api-718-lqdfr

Conversation

@frahlg

@frahlg frahlg commented Sep 2, 2026

Copy link
Copy Markdown
Member

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:0006:00). An optional days list 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 Applier path 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 static next 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.md is 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 edits FromConfig in prices.go — wrapping sourceful/elprisetjustnu. This PR only adds a static case. If #1044 merges first, rebase; the conflict is one switch arm.

Tests

  • go test ./internal/prices ./internal/config ./internal/api
  • Flat rate, TOU peak/off-peak, overnight wrap, weekend vs weekday, zero as a real price, YAML parse, empty-window reject.
  • Settings/setup/price-units tests.

Visual 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.

… 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.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-02T14:07:33.118362Z f750ad9 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Comment thread go/internal/api/api.go Outdated

@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: 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".

Comment thread go/internal/api/api.go Outdated
Comment on lines +2495 to +2496
if haveSite {
resp["latitude"], resp["longitude"] = lat, lon

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 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 👍 / 👎.

Comment thread go/internal/coverage/coverage.go Outdated
Comment on lines +76 to +79
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}

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 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 👍 / 👎.

Comment thread go/internal/api/api.go Outdated
Comment on lines +2457 to +2460
if v := r.URL.Query().Get("lat"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
lat, haveSite = f, true
}

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 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 👍 / 👎.

Comment thread web/settings/tabs/weather.js Outdated

@cursor cursor 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.

Stale comment

Left a non-blocking comment: Cursor Bugbot did not complete successfully (skipped) and reported one unresolved finding, so this is not approved. Human review is needed; reviewers were assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

@cursor
cursor Bot requested review from Leitet and erikarenhill September 2, 2026 14:09

@miravoss26 miravoss26 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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. covers is 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 Read permission tier and added to the open-routes allowlist in security_test.go — same pattern as /api/prices. No new dependencies, no secrets, frontend renders via esc() 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.

@HuggeK

HuggeK commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

I have a fix for this in #734 - just testing this now.

@frahlg

frahlg commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Parking this behind #734 rather than merging both.

They both add go/internal/coverage and GET /api/data-sources (7 overlapping files). Hugo's note here was that the coverage fix lives in #734.

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.
@frahlg frahlg changed the title feat(coverage): say where each data source works, instead of an empty chart feat(prices): static / time-of-use tariff so planning works outside Europe Sep 3, 2026

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b8ecc20. Configure here.

SEKPerKWh: ore / 100,
})
}
return out, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b8ecc20. Configure here.

Comment thread web/setup.js
} else {
var cur = currencyForZone(zone);
if (cur) cfg.price.currency = cur;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b8ecc20. Configure here.

@cursor cursor 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.

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.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

@miravoss26 miravoss26 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

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.

3 participants