diff --git a/.changeset/static-price-provider.md b/.changeset/static-price-provider.md new file mode 100644 index 000000000..fdfc6fc92 --- /dev/null +++ b/.changeset/static-price-provider.md @@ -0,0 +1,7 @@ +--- +"ftw": minor +--- + +A static / time-of-use price provider, so price-driven planning works outside Europe. + +`price.provider: static` takes a flat rate in the install currency, plus optional local-time windows that override peak hours (overnight wrap, optional weekdays). Grid tariff and VAT still apply. Live day-ahead spot remains Europe-only; this is the stand-in for every other market. Settings and the setup wizard offer it next to Sourceful and ENTSO-E. diff --git a/README.md b/README.md index 8a045ac62..176ddc882 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,7 @@ metadata are the detailed reference. - [Product roadmap](docs/roadmap.md) - [Power sign convention](docs/site-convention.md) - [Safety invariants](docs/safety.md) +- [Geographic coverage of external data](docs/data-coverage.md) - [Operations and recovery](docs/operations.md) - [Full backup and safe restore](docs/backup-and-restore.md) - [Writing a driver](docs/writing-a-driver.md) diff --git a/docs/architecture.md b/docs/architecture.md index d4a7447bf..209a7c1f4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -158,6 +158,12 @@ schema. The handlers registered in [`go/internal/api/api.go`](../go/internal/api/api.go) define the HTTP surface. Driver metadata defines the device catalog. These sources replace manually duplicated reference docs. +External data sources do not all work everywhere. Weather and PV forecasts +are worldwide; live day-ahead spot is European. A site outside ENTSO-E +uses `price.provider: static` — a flat rate or time-of-use schedule — +which is how price-driven planning works without a market feed. See +[data-coverage.md](data-coverage.md). + Some startup bindings cannot be hot-reloaded, including state paths, API listener and selected integration transports. Normal device and control configuration is reloaded through diff --git a/docs/data-coverage.md b/docs/data-coverage.md new file mode 100644 index 000000000..e61cde9fa --- /dev/null +++ b/docs/data-coverage.md @@ -0,0 +1,95 @@ +# Geographic coverage of external data sources + +FTW controls hardware anywhere. The data it plans with does not. + +- **Weather and PV forecasting works worldwide.** +- **Day-ahead spot prices work in Europe only.** +- **A static / time-of-use tariff works anywhere.** That is how a site + outside ENTSO-E (or on a flat retail contract inside it) gets + price-driven planning. + +A site outside Europe still gets monitoring, safety, control, the +dashboard, weather forecasts and the self-learning PV twin. What it did +not have, until `price.provider: static`, was a price source. + +## Spot prices — Europe, or a tariff you type + +Configured under `price.provider`. + +| Provider | Coverage | API key | Notes | +|---|---|---|---| +| `sourceful` | European day-ahead markets | No | Default. Sourceful's cached ENTSO-E API. | +| `elprisetjustnu` | **Sweden only** — zones SE1–SE4 | No | 15-minute PTU since late 2025. | +| `entsoe` | ENTSO-E member markets | Yes | Direct from the Transparency Platform. | +| `static` | **Worldwide** | No | Flat rate or time-of-use schedule the operator types. | +| `none` | — | — | Disables price fetching entirely. | + +There is still no live adapter for CAISO, ERCOT, PJM, AEMO/NEM, JEPX or +the other non-European markets. `static` is the stand-in: a US TOU +tariff, a Japanese flat contract, or any other schedule that can be +written as hours and a rate. + +Prices are stored in **minor units of the configured currency** per kWh +(öre, cent, ¢, pence, …). ENTSO-E figures that arrive in another +currency are converted with **ECB** daily FX rates. Static prices are +already in the install currency; nothing is converted. + +> The Tibber driver (`drivers/tibber.lua`) is telemetry only — it reports +> meter readings, not prices, so it is not a fifth price source. + +```yaml +price: + provider: static + currency: USD + static_ore_kwh: 8 # off-peak, ¢/kWh (minor units) + static_tou: + - { start: "07:00", end: "23:00", ore_kwh: 22 } # peak + grid_tariff_ore_kwh: 4 + vat_percent: 0 +``` + +Windows are local time on the box. First match wins; hours they miss +keep `static_ore_kwh`. Overnight windows wrap (`22:00`–`06:00`). An +optional `days` list (`mon`…`sun`) limits a window to those weekdays. + +The zone picker is served from `GET /api/prices/zones` and is hidden +when the provider is `static`. See +[`go/internal/prices/zones.go`](../go/internal/prices/zones.go) and +[`go/internal/prices/static.go`](../go/internal/prices/static.go). + +## Weather and PV forecasts — worldwide + +Configured under `weather.provider`. All four work at any latitude/longitude. + +| Provider | Coverage | API key | Signal quality | +|---|---|---|---| +| `met_no` | Global | No | Cloud cover only — weakest PV signal. | +| `openweather` | Global | Yes | Cloud cover only. | +| `open_meteo` | Global | No | Shortwave radiation (GHI) — good. | +| `forecast_solar` | Global | No (free tier) | Site-calibrated watts from panel geometry — best. | + +Prefer `open_meteo` or `forecast_solar` when the site has array +geometry: they carry an irradiance signal, which is what the +orientation-aware plane-of-array model needs. + +## What is not in this tree yet + +| Capability | Planned source | Coverage when it lands | +|---|---|---| +| PV performance scoring / forecast calibration | SMHI STRÅNG ([#734](https://github.com/srcfl/ftw/pull/734)) | Nordic region only | +| Automatic roof geometry | Lantmäteriet via STAC ([#735](https://github.com/srcfl/ftw/pull/735)) | Sweden by default; any conformant STAC catalog | + +Until those land, array geometry stays a manual entry on the Weather +tab, and the PV twin still calibrates from measured production. + +## What a non-European site has today + +| Capability | Works outside Europe? | +|---|---| +| Device control, safety, dispatch | Yes | +| Telemetry, history, dashboard | Yes | +| Weather + PV forecasting | Yes | +| Self-learning PV twin | Yes | +| Price-driven planning | **Yes, via `static`** | +| Live day-ahead spot | **No** — Europe only | +| Manual array geometry | Yes | diff --git a/go/internal/config/config.go b/go/internal/config/config.go index ab95bd361..d43dcf0f6 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -1411,7 +1411,7 @@ type StateConf struct { // Price is the spot-price source config. type Price struct { - Provider string `yaml:"provider" json:"provider"` // sourceful | elprisetjustnu | entsoe | none + Provider string `yaml:"provider" json:"provider"` // sourceful | elprisetjustnu | entsoe | static | none Zone string `yaml:"zone,omitempty" json:"zone,omitempty"` GridTariffOreKwh float64 `yaml:"grid_tariff_ore_kwh,omitempty" json:"grid_tariff_ore_kwh,omitempty"` VATPercent float64 `yaml:"vat_percent,omitempty" json:"vat_percent,omitempty"` @@ -1438,6 +1438,37 @@ type Price struct { // most Swedish customer agreements pass through. Set to a pointer // to 0.0 if you have a guaranteed-zero-floor agreement. ExportFloorOreKwh *float64 `yaml:"export_floor_ore_kwh,omitempty" json:"export_floor_ore_kwh,omitempty"` + + // StaticOreKwh is the default energy price in minor units per kWh when + // provider=static. Time-of-use windows below override matching hours; + // hours they miss keep this rate. Zero is a real price (free energy). + StaticOreKwh float64 `yaml:"static_ore_kwh,omitempty" json:"static_ore_kwh,omitempty"` + // StaticTOU is an optional time-of-use schedule in the box's local + // time. First matching window wins. An empty Days list means every day. + StaticTOU []TOUWindow `yaml:"static_tou,omitempty" json:"static_tou,omitempty"` +} + +// TOUWindow is one time-of-use period for provider=static. +type TOUWindow struct { + Start string `yaml:"start" json:"start"` // "HH:MM" local, inclusive + End string `yaml:"end" json:"end"` // "HH:MM" local, exclusive; "24:00" allowed + OreKwh float64 `yaml:"ore_kwh" json:"ore_kwh"` + Days []string `yaml:"days,omitempty" json:"days,omitempty"` // mon..sun; empty = every day +} + +// Validate checks a static tariff's windows. Other providers are accepted +// as-is: an unknown name currently disables fetching rather than failing +// config load, and that behaviour stays. +func (p *Price) Validate() error { + if p == nil || !strings.EqualFold(p.Provider, "static") { + return nil + } + for i, w := range p.StaticTOU { + if strings.TrimSpace(w.Start) == "" || strings.TrimSpace(w.End) == "" { + return fmt.Errorf("price.static_tou[%d]: start and end are required (HH:MM)", i) + } + } + return nil } // Weather is the weather-forecast source config. @@ -1562,6 +1593,18 @@ func (c Config) MaskSecrets() Config { if out.Price != nil { cp := *out.Price cp.APIKey = "" + if len(cp.StaticTOU) > 0 { + tou := make([]TOUWindow, len(cp.StaticTOU)) + copy(tou, cp.StaticTOU) + for i := range tou { + if len(tou[i].Days) > 0 { + days := make([]string, len(tou[i].Days)) + copy(days, tou[i].Days) + tou[i].Days = days + } + } + cp.StaticTOU = tou + } out.Price = &cp } if out.Weather != nil { @@ -2170,6 +2213,11 @@ func (c *Config) Validate() error { if err := c.Assistant.Validate(); err != nil { return err } + if c.Price != nil { + if err := c.Price.Validate(); err != nil { + return err + } + } if err := c.validateVehicles(); err != nil { return err } diff --git a/go/internal/config/config_test.go b/go/internal/config/config_test.go index d5a4658d1..eec42c553 100644 --- a/go/internal/config/config_test.go +++ b/go/internal/config/config_test.go @@ -628,6 +628,40 @@ batteries: } } +func TestStaticPriceParsesTOU(t *testing.T) { + yaml := minimalYAML + ` +price: + provider: static + currency: USD + static_ore_kwh: 8 + static_tou: + - start: "07:00" + end: "23:00" + ore_kwh: 22 + days: [mon, tue, wed, thu, fri] +` + c, err := Parse([]byte(yaml), ".") + if err != nil { + t.Fatal(err) + } + if c.Price.Provider != "static" || c.Price.StaticOreKwh != 8 { + t.Fatalf("flat = %+v", c.Price) + } + if len(c.Price.StaticTOU) != 1 || c.Price.StaticTOU[0].Start != "07:00" { + t.Fatalf("tou = %+v", c.Price.StaticTOU) + } + if err := c.Validate(); err != nil { + t.Fatal(err) + } +} + +func TestStaticPriceRejectsEmptyTOUWindow(t *testing.T) { + p := &Price{Provider: "static", StaticTOU: []TOUWindow{{Start: "", End: "23:00"}}} + if err := p.Validate(); err == nil { + t.Fatal("expected error") + } +} + func TestPVArrayGeometryDistinguishesMissingFromZero(t *testing.T) { yaml := minimalYAML + ` weather: diff --git a/go/internal/prices/prices.go b/go/internal/prices/prices.go index 3defa4963..ae3b276b8 100644 --- a/go/internal/prices/prices.go +++ b/go/internal/prices/prices.go @@ -11,6 +11,8 @@ // hourly if the provider returns that. // - entsoe — All EU, needs ENTSO-E transparency platform API key. // Resolution varies per bidding zone (15m or 60m). +// - static — Operator-supplied flat rate or time-of-use schedule. No +// network. Works anywhere, including markets with no day-ahead feed. // // Consumer price = (spot + grid_tariff) × (1 + VAT/100). We store both // pure spot AND the consumer total so the UI can surface either. @@ -563,7 +565,11 @@ func FromConfig(cfg *config.Price, st *state.Store, fx FXConverter) *Service { } zone := cfg.Zone if zone == "" { - zone = "SE3" + if cfg.Provider == "static" { + zone = "STATIC" + } else { + zone = "SE3" + } } // An unset currency follows the zone: picking BE should not leave a // Belgian household paying in öre. Unknown zones keep the old default. @@ -588,6 +594,8 @@ func FromConfig(cfg *config.Price, st *state.Store, fx FXConverter) *Service { ep.Currency = currency ep.FX = fx p = ep + case "static": + p = NewStatic(cfg) default: return nil } diff --git a/go/internal/prices/static.go b/go/internal/prices/static.go new file mode 100644 index 000000000..88cf1b303 --- /dev/null +++ b/go/internal/prices/static.go @@ -0,0 +1,160 @@ +package prices + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/srcfl/ftw/go/internal/config" +) + +// StaticProvider synthesises a day of prices from a configured flat rate +// or time-of-use schedule. It is the worldwide stand-in for markets that +// have no day-ahead feed: a US TOU tariff, a Japanese flat contract, or +// any other schedule the operator can type. +// +// Rates are minor units per kWh of the install currency (the same unit as +// grid_tariff_ore_kwh). Fetch converts them to major units so Apply can +// turn them back — the same path every other provider uses. +type StaticProvider struct { + OreKwh float64 + TOU []config.TOUWindow + // Loc is the timezone windows are interpreted in. Nil means the box's + // local zone, which is what a household tariff is billed in. + Loc *time.Location +} + +func NewStatic(cfg *config.Price) *StaticProvider { + if cfg == nil { + return &StaticProvider{} + } + tou := append([]config.TOUWindow(nil), cfg.StaticTOU...) + return &StaticProvider{OreKwh: cfg.StaticOreKwh, TOU: tou} +} + +func (s *StaticProvider) Name() string { return "static" } + +func (s *StaticProvider) loc() *time.Location { + if s != nil && s.Loc != nil { + return s.Loc + } + return time.Local +} + +// Fetch returns 96 quarter-hour slots for the civil day of `day` in the +// provider's location. Zone is ignored: a static tariff is not a bidding +// zone. An empty result is never returned — a configured static source +// always has a price, including zero. +func (s *StaticProvider) Fetch(_ context.Context, _ string, day time.Time) ([]RawPrice, error) { + loc := s.loc() + start := time.Date(day.In(loc).Year(), day.In(loc).Month(), day.In(loc).Day(), 0, 0, 0, 0, loc) + const slotMin = 15 + out := make([]RawPrice, 0, 96) + for i := 0; i < 96; i++ { + t := start.Add(time.Duration(i*slotMin) * time.Minute) + ore, err := s.rateAt(t) + if err != nil { + return nil, err + } + out = append(out, RawPrice{ + SlotStart: t, + SlotLenMin: slotMin, + SEKPerKWh: ore / 100, + }) + } + return out, nil +} + +func (s *StaticProvider) rateAt(t time.Time) (float64, error) { + t = t.In(s.loc()) + mins := t.Hour()*60 + t.Minute() + dow := strings.ToLower(t.Weekday().String()[:3]) // sun, mon, … + for _, w := range s.TOU { + ok, err := windowMatches(w, mins, dow) + if err != nil { + return 0, err + } + if ok { + return w.OreKwh, nil + } + } + return s.OreKwh, nil +} + +func windowMatches(w config.TOUWindow, mins int, dow string) (bool, error) { + if !daysMatch(w.Days, dow) { + return false, nil + } + start, err := parseClockMinutes(w.Start) + if err != nil { + return false, fmt.Errorf("static TOU start %q: %w", w.Start, err) + } + end, err := parseClockMinutes(w.End) + if err != nil { + return false, fmt.Errorf("static TOU end %q: %w", w.End, err) + } + if start == end { + return false, fmt.Errorf("static TOU window %s–%s is empty", w.Start, w.End) + } + if start < end { + return mins >= start && mins < end, nil + } + // Overnight: 22:00–06:00. + return mins >= start || mins < end, nil +} + +func daysMatch(days []string, dow string) bool { + if len(days) == 0 { + return true + } + for _, d := range days { + if dayToken(d) == dow { + return true + } + } + return false +} + +func dayToken(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + switch s { + case "sunday", "sun": + return "sun" + case "monday", "mon": + return "mon" + case "tuesday", "tue", "tues": + return "tue" + case "wednesday", "wed": + return "wed" + case "thursday", "thu", "thur", "thurs": + return "thu" + case "friday", "fri": + return "fri" + case "saturday", "sat": + return "sat" + default: + if len(s) >= 3 { + return s[:3] + } + return s + } +} + +// parseClockMinutes reads "HH:MM" or "H:MM". "24:00" is midnight at the +// end of the day (1440), so a window can close on the civil day boundary. +func parseClockMinutes(s string) (int, error) { + s = strings.TrimSpace(s) + var h, m int + n, err := fmt.Sscanf(s, "%d:%d", &h, &m) + if err != nil || n != 2 { + return 0, fmt.Errorf("want HH:MM") + } + if h == 24 && m == 0 { + return 24 * 60, nil + } + if h < 0 || h > 23 || m < 0 || m > 59 { + return 0, fmt.Errorf("hour 0–23 and minute 0–59, or 24:00") + } + return h*60 + m, nil +} diff --git a/go/internal/prices/static_test.go b/go/internal/prices/static_test.go new file mode 100644 index 000000000..abdf5b991 --- /dev/null +++ b/go/internal/prices/static_test.go @@ -0,0 +1,186 @@ +package prices + +import ( + "context" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/config" +) + +func TestStaticFlatRateFillsADay(t *testing.T) { + loc := time.FixedZone("test", 2*3600) + p := &StaticProvider{OreKwh: 12.5, Loc: loc} + day := time.Date(2026, 9, 3, 15, 0, 0, 0, loc) + rows, err := p.Fetch(context.Background(), "ignored", day) + if err != nil { + t.Fatal(err) + } + if len(rows) != 96 { + t.Fatalf("slots = %d, want 96", len(rows)) + } + if rows[0].SlotStart.Hour() != 0 || rows[0].SlotStart.Minute() != 0 { + t.Errorf("first slot = %s, want local midnight", rows[0].SlotStart) + } + if rows[0].SlotLenMin != 15 { + t.Errorf("slot = %d", rows[0].SlotLenMin) + } + // 12.5 minor units → 0.125 major, the unit Apply multiplies by 100. + if rows[0].SEKPerKWh != 0.125 { + t.Errorf("major = %g, want 0.125", rows[0].SEKPerKWh) + } + last := rows[len(rows)-1] + if last.SlotStart.Hour() != 23 || last.SlotStart.Minute() != 45 { + t.Errorf("last slot = %s", last.SlotStart) + } + if last.SEKPerKWh != rows[0].SEKPerKWh { + t.Errorf("flat rate drifted: first %g last %g", rows[0].SEKPerKWh, last.SEKPerKWh) + } +} + +func TestStaticTOUOverridesFlat(t *testing.T) { + loc := time.FixedZone("test", 0) + p := &StaticProvider{ + OreKwh: 8, + Loc: loc, + TOU: []config.TOUWindow{ + {Start: "07:00", End: "23:00", OreKwh: 22}, + }, + } + day := time.Date(2026, 9, 3, 0, 0, 0, 0, loc) // Thursday + rows, err := p.Fetch(context.Background(), "", day) + if err != nil { + t.Fatal(err) + } + at := func(h, m int) float64 { + t.Helper() + want := time.Date(2026, 9, 3, h, m, 0, 0, loc) + for _, r := range rows { + if r.SlotStart.Equal(want) { + return r.SEKPerKWh * 100 + } + } + t.Fatalf("missing slot %02d:%02d", h, m) + return 0 + } + if got := at(6, 45); got != 8 { + t.Errorf("06:45 = %g, want off-peak 8", got) + } + if got := at(7, 0); got != 22 { + t.Errorf("07:00 = %g, want peak 22", got) + } + if got := at(22, 45); got != 22 { + t.Errorf("22:45 = %g, want peak 22", got) + } + if got := at(23, 0); got != 8 { + t.Errorf("23:00 = %g, want off-peak 8", got) + } +} + +func TestStaticOvernightWindow(t *testing.T) { + loc := time.UTC + p := &StaticProvider{ + OreKwh: 20, + Loc: loc, + TOU: []config.TOUWindow{ + {Start: "22:00", End: "06:00", OreKwh: 5}, + }, + } + day := time.Date(2026, 9, 3, 12, 0, 0, 0, loc) + rows, err := p.Fetch(context.Background(), "", day) + if err != nil { + t.Fatal(err) + } + ore := func(h int) float64 { + t.Helper() + want := time.Date(2026, 9, 3, h, 0, 0, 0, loc) + for _, r := range rows { + if r.SlotStart.Equal(want) { + return r.SEKPerKWh * 100 + } + } + t.Fatalf("missing hour %d", h) + return 0 + } + if ore(22) != 5 || ore(5) != 5 { + t.Errorf("overnight: 22h=%g 5h=%g, want 5", ore(22), ore(5)) + } + if ore(6) != 20 || ore(12) != 20 { + t.Errorf("daytime: 6h=%g 12h=%g, want 20", ore(6), ore(12)) + } +} + +func TestStaticWeekdayWindows(t *testing.T) { + loc := time.UTC + p := &StaticProvider{ + OreKwh: 10, + Loc: loc, + TOU: []config.TOUWindow{ + {Start: "00:00", End: "24:00", OreKwh: 30, Days: []string{"sat", "sunday"}}, + }, + } + thu := time.Date(2026, 9, 3, 0, 0, 0, 0, loc) // Thursday + sat := time.Date(2026, 9, 5, 0, 0, 0, 0, loc) + thuRows, err := p.Fetch(context.Background(), "", thu) + if err != nil { + t.Fatal(err) + } + satRows, err := p.Fetch(context.Background(), "", sat) + if err != nil { + t.Fatal(err) + } + if thuRows[48].SEKPerKWh*100 != 10 { + t.Errorf("Thursday noon = %g, want weekday 10", thuRows[48].SEKPerKWh*100) + } + if satRows[48].SEKPerKWh*100 != 30 { + t.Errorf("Saturday noon = %g, want weekend 30", satRows[48].SEKPerKWh*100) + } +} + +func TestStaticZeroIsARealPrice(t *testing.T) { + p := &StaticProvider{OreKwh: 0, Loc: time.UTC} + rows, err := p.Fetch(context.Background(), "", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + if len(rows) != 96 { + t.Fatalf("slots = %d", len(rows)) + } + if rows[0].SEKPerKWh != 0 { + t.Errorf("got %g", rows[0].SEKPerKWh) + } +} + +func TestFromConfigStatic(t *testing.T) { + s := FromConfig(&config.Price{ + Provider: "static", + Currency: "USD", + StaticOreKwh: 14, + }, nil, nil) + if s == nil { + t.Fatal("expected service") + } + if s.Provider.Name() != "static" { + t.Errorf("name = %s", s.Provider.Name()) + } + if s.Zone != "STATIC" { + t.Errorf("zone = %s, want STATIC when none is set", s.Zone) + } + if s.Currency != "USD" { + t.Errorf("currency = %s", s.Currency) + } +} + +func TestParseClockMinutes(t *testing.T) { + got, err := parseClockMinutes("7:00") + if err != nil || got != 7*60 { + t.Errorf("7:00 → %d %v", got, err) + } + got, err = parseClockMinutes("24:00") + if err != nil || got != 24*60 { + t.Errorf("24:00 → %d %v", got, err) + } + if _, err := parseClockMinutes("25:00"); err == nil { + t.Error("25:00 should fail") + } +} diff --git a/web/components/price-units.js b/web/components/price-units.js index 3bd609840..c8d3386bb 100644 --- a/web/components/price-units.js +++ b/web/components/price-units.js @@ -24,6 +24,10 @@ const UNITS = { CZK: { label: "Kč", perKwh: "Kč/kWh", axis: "Kč", scale: 0.01, decimals: 2 }, HUF: { label: "Ft", perKwh: "Ft/kWh", axis: "Ft", scale: 0.01, decimals: 1 }, RON: { label: "lei", perKwh: "lei/kWh", axis: "lei", scale: 0.01, decimals: 2 }, + USD: { label: "¢", perKwh: "¢/kWh", axis: "¢", scale: 1, decimals: 1 }, + GBP: { label: "p", perKwh: "p/kWh", axis: "p", scale: 1, decimals: 1 }, + AUD: { label: "c", perKwh: "c/kWh", axis: "c", scale: 1, decimals: 1 }, + CAD: { label: "¢", perKwh: "¢/kWh", axis: "¢", scale: 1, decimals: 1 }, }; // A currency with no entry above is shown in its major unit under its ISO diff --git a/web/components/price-units.test.mjs b/web/components/price-units.test.mjs index bb10f57c9..4bf10677e 100644 --- a/web/components/price-units.test.mjs +++ b/web/components/price-units.test.mjs @@ -15,7 +15,7 @@ import { describe("price units", () => { it("keeps minor units where households quote them", () => { - for (const [code, label] of [["SEK", "öre"], ["EUR", "cent"], ["NOK", "øre"], ["DKK", "øre"]]) { + for (const [code, label] of [["SEK", "öre"], ["EUR", "cent"], ["NOK", "øre"], ["DKK", "øre"], ["USD", "¢"], ["GBP", "p"]]) { const u = unitFor(code); assert.equal(u.label, label); assert.equal(u.scale, 1, `${code} should stay in minor units`); diff --git a/web/price-zone-picker.test.mjs b/web/price-zone-picker.test.mjs index 32ce707a1..716aeae49 100644 --- a/web/price-zone-picker.test.mjs +++ b/web/price-zone-picker.test.mjs @@ -57,6 +57,13 @@ describe("price zone picker", () => { assert.match(priceTab, /FALLBACK/); assert.match(setupHtml, /'; } + function touEditor(ctx, unit) { + var escHtml = ctx.escHtml; + var windows = (ctx.config.price && ctx.config.price.static_tou) || []; + var rows = windows.map(function (w, i) { + return '
' + + '
' + + '' + + '
' + + '
' + + '' + + '
' + + '
' + + '' + + '
' + + '
' + + '' + + '
' + + '' + + '
'; + }).join(""); + return '
' + + 'Time-of-use windows — leave empty for a flat rate.' + + '

' + + 'Local time on the box. First matching window wins; hours they miss keep the flat rate. ' + + 'Overnight windows wrap (22:00–06:00). Days are weekday names; blank means every day.

' + + '
' + rows + '
' + + '' + + '
'; + } + + function bindTOU(ctx) { + var host = document.getElementById("static-tou-list"); + if (host) { + host.oninput = function (e) { + var idx = e.target && e.target.dataset && e.target.dataset.tou; + if (idx == null || idx === "") return; + var fieldName = e.target.dataset.field; + var arr = ctx.config.price.static_tou; + if (!arr || !arr[idx]) return; + if (fieldName === "ore_kwh") { + var v = parseFloat(e.target.value); + if (!isNaN(v)) arr[idx][fieldName] = v; + } else if (fieldName === "days") { + arr[idx].days = e.target.value.split(/[,\s]+/).filter(Boolean); + } else { + arr[idx][fieldName] = e.target.value; + } + }; + host.onclick = function (e) { + var idx = e.target && e.target.dataset && e.target.dataset.touRemove; + if (idx == null || idx === "") return; + ctx.config.price.static_tou.splice(parseInt(idx, 10), 1); + ctx.captureCurrentTab(); + ctx.renderTab("price"); + }; + } + var add = document.getElementById("static-tou-add"); + if (add) add.addEventListener("click", function () { + if (!ctx.config.price.static_tou) ctx.config.price.static_tou = []; + ctx.config.price.static_tou.push({ start: "07:00", end: "23:00", ore_kwh: 0, days: [] }); + ctx.captureCurrentTab(); + ctx.renderTab("price"); + }); + } + S.tabs.price = { render: function (ctx) { var field = ctx.field, selectField = ctx.selectField, escHtml = ctx.escHtml; if (!ctx.config.price) ctx.config.price = {}; var cfg = ctx.config.price; var provider = cfg.provider || "sourceful"; - var zoneCode = cfg.zone || "SE3"; + var isStatic = provider === "static"; + var zoneCode = cfg.zone || (isStatic ? "STATIC" : "SE3"); // Switching to a Sweden-only provider while sitting on a foreign // zone would leave both selects empty, so the zone follows the - // provider back into range. + // provider back into range. STATIC is not a bidding zone. var available = zonesFor(provider); - if (!available.some(function (z) { return z.code === zoneCode; }) && available.length) { - zoneCode = available[0].code; - ctx.setByPath(ctx.config, "price.zone", zoneCode); + if (!isStatic) { + if (zoneCode === "STATIC" || !available.some(function (z) { return z.code === zoneCode; })) { + if (available.length) { + zoneCode = available[0].code; + ctx.setByPath(ctx.config, "price.zone", zoneCode); + } + } + } else if (!cfg.zone) { + ctx.setByPath(ctx.config, "price.zone", "STATIC"); } var zone = zoneByCode(zoneCode); var country = zone ? zone.country : "Sweden"; - var currency = cfg.currency || (zone ? zone.currency : "SEK"); + var currency = cfg.currency || (isStatic ? "SEK" : (zone ? zone.currency : "SEK")); var unit = window.FTWUnits ? window.FTWUnits.unitLabel(currency) : "öre"; var countryOpts = countriesFor(provider).map(function (c) { @@ -74,22 +153,32 @@ }).join(""); var inCountry = zonesFor(provider).filter(function (z) { return z.country === country; }); var zoneOpts = inCountry.map(function (z) { - // "Sweden — Stockholm" under the Sweden heading reads as - // "SE3 · Stockholm"; a country with one zone is just its code. var sub = z.name && z.name.indexOf(" — ") > 0 ? z.name.split(" — ")[1] : ""; return opt(escHtml(z.code), escHtml(sub ? z.code + " · " + sub : z.code), z.code === zoneCode); }).join(""); - return '
Spot price' + - selectField("Provider", "price.provider", ["sourceful", "elprisetjustnu", "entsoe", "none"], "sourceful") + + var zoneFields = isStatic ? "" : '' + '' + '' + - '' + - selectField("Currency", "price.currency", - ["EUR", "SEK", "NOK", "DKK", "PLN", "CZK", "HUF", "RON", "CHF"], currency, - "Prices are converted to this currency with ECB daily rates. It follows the country you pick; " + - "change it only if you are billed in something else. Switching it clears cached prices.") + + ''; + + var staticFields = !isStatic ? "" : + field("Energy price (" + escHtml(unit) + "/kWh)", "price.static_ore_kwh", "number", 0, + "The commodity price your retailer charges, in the same minor units as the grid tariff. " + + "Time-of-use windows below override matching hours.") + + touEditor(ctx, unit); + + var currencyHelp = isStatic + ? "The currency your bill is in. Static prices are already in this currency — nothing is converted." + : "Prices are converted to this currency with ECB daily rates. It follows the country you pick; " + + "change it only if you are billed in something else. Switching it clears cached prices."; + + return '
Spot price' + + selectField("Provider", "price.provider", ["sourceful", "elprisetjustnu", "entsoe", "static", "none"], "sourceful") + + zoneFields + + selectField("Currency", "price.currency", CURRENCIES, currency, currencyHelp) + + staticFields + '
' + field("Grid tariff excl. VAT (" + escHtml(unit) + "/kWh)", "price.grid_tariff_ore_kwh", "number", 60, "Per-kWh network/distribution fee from your DSO, excluding VAT. This is the cost of moving " + @@ -109,20 +198,22 @@ '' + - field("API key (ENTSO-E only)", "price.api_key", "text", "") + + (isStatic ? "" : field("API key (ENTSO-E only)", "price.api_key", "text", "")) + '
' + '

' + - 'Sourceful is the keyless default and covers every European bidding zone ENTSO-E publishes. ' + - 'elprisetjustnu.se remains a Sweden-only alternative. Direct ENTSO-E access needs an API key. ' + - 'FX rates come from ECB daily.' + + (isStatic + ? "Static is a flat rate or time-of-use schedule you type yourself. It is the way to run " + + "price-driven planning outside Europe, or on any contract that is not day-ahead spot. " + + "Grid tariff and VAT still apply on top." + : "Sourceful is the keyless default and covers every European bidding zone ENTSO-E publishes. " + + "elprisetjustnu.se remains a Sweden-only alternative. Direct ENTSO-E access needs an API key. " + + "Outside Europe, pick static and enter your tariff. FX rates come from ECB daily.") + '

'; }, after: function (ctx) { var bodyEl = ctx.bodyEl; - // Load the catalog once, then re-render so the picker shows every - // zone rather than the Nordic fallback. if (!zones) { ctx.apiFetch("/api/prices/zones") .then(function (r) { return r.json(); }) @@ -140,11 +231,6 @@ var providerEl = bodyEl.querySelector('[data-path="price.provider"]'); var currencyEl = bodyEl.querySelector('[data-path="price.currency"]'); - // Changing country or provider changes which zones exist, so both - // re-render the tab rather than patching the options in place. - // Capture first, then override: captureCurrentTab reads the visible - // selects, so anything set before it would be written straight back - // to the old value. function rerender(override) { ctx.captureCurrentTab(); if (override) override(); @@ -159,8 +245,6 @@ rerender(function () { if (!first) return; ctx.setByPath(ctx.config, "price.zone", first.code); - // The currency follows the country — picking Belgium should - // not leave the household paying in öre. ctx.setByPath(ctx.config, "price.currency", first.currency); }); }); @@ -173,18 +257,27 @@ }); }); } - if (providerEl) providerEl.addEventListener("change", function () { rerender(); }); + if (providerEl) { + providerEl.addEventListener("change", function () { + rerender(function () { + if (providerEl.value === "static") { + ctx.setByPath(ctx.config, "price.zone", "STATIC"); + } + }); + }); + } if (currencyEl) currencyEl.addEventListener("change", function () { rerender(); }); - // The 60 öre floor is a Swedish number. Outside SEK we can't name a - // threshold without inventing one, so the note replaces the warning. + bindTOU(ctx); + var input = bodyEl.querySelector('[data-path="price.grid_tariff_ore_kwh"]'); var warn = bodyEl.querySelector("#tariff-warning"); var note = bodyEl.querySelector("#country-note"); var currency = (ctx.config.price && ctx.config.price.currency) || "SEK"; var vat = ctx.getByPath(ctx.config, "price.vat_percent", 25); + var provider = (ctx.config.price && ctx.config.price.provider) || "sourceful"; if (note) { - note.style.display = currency !== "SEK" && Number(vat) === 25 ? "block" : "none"; + note.style.display = (provider === "static" || currency !== "SEK") && Number(vat) === 25 ? "block" : "none"; } if (!input || !warn) return; function check() { diff --git a/web/setup.html b/web/setup.html index 8c6348579..8c417ee09 100644 --- a/web/setup.html +++ b/web/setup.html @@ -809,6 +809,7 @@

Price provider

+ @@ -816,6 +817,11 @@

Price provider

+
diff --git a/web/setup.js b/web/setup.js index 28f75388d..2596fb885 100644 --- a/web/setup.js +++ b/web/setup.js @@ -638,16 +638,26 @@ } } + function syncPriceProviderFields() { + var prov = document.getElementById('price-provider').value; + var staticEl = document.getElementById('price-static-rate'); + var zoneEl = document.getElementById('price-zone-display'); + if (staticEl) staticEl.style.display = prov === 'static' ? 'block' : 'none'; + if (zoneEl) zoneEl.style.display = prov === 'static' ? 'none' : 'block'; + } + function prepareIntegrations() { var zone = document.getElementById('price-zone').value; document.getElementById('price-zone-readonly').value = zone; populateEVProviders(); syncEVFields(); + syncPriceProviderFields(); if (!integrationListenersBound) { integrationListenersBound = true; document.getElementById('ev-provider').addEventListener('change', syncEVFields); + document.getElementById('price-provider').addEventListener('change', syncPriceProviderFields); document.getElementById('ha-enabled').addEventListener('change', function () { document.getElementById('ha-fields').style.display = this.checked ? 'block' : 'none'; }); @@ -790,7 +800,11 @@ var priceProv = document.getElementById('price-provider').value; if (priceProv) { html += '

Price

'; - html += esc(priceProv) + ' / ' + esc(zone); + if (priceProv === 'static') { + html += 'static / ' + esc(document.getElementById('price-static-ore').value); + } else { + html += esc(priceProv) + ' / ' + esc(zone); + } html += '
'; } @@ -872,10 +886,15 @@ if (priceProv) { cfg.price = { provider: priceProv, - zone: zone + zone: priceProv === 'static' ? 'STATIC' : zone }; - var cur = currencyForZone(zone); - if (cur) cfg.price.currency = cur; + if (priceProv === 'static') { + var rate = parseFloat(document.getElementById('price-static-ore').value); + if (!isNaN(rate)) cfg.price.static_ore_kwh = rate; + } else { + var cur = currencyForZone(zone); + if (cur) cfg.price.currency = cur; + } } // EV Charger — shape the block to match the provider's transport diff --git a/web/setup.test.mjs b/web/setup.test.mjs index bfddf70df..d32099d34 100644 --- a/web/setup.test.mjs +++ b/web/setup.test.mjs @@ -176,7 +176,7 @@ describe("price provider defaults", () => { }); it("uses Sourceful as the Settings default while retaining alternatives", () => { - assert.match(PRICE_JS, /\["sourceful", "elprisetjustnu", "entsoe", "none"\], "sourceful"/); + assert.match(PRICE_JS, /\["sourceful", "elprisetjustnu", "entsoe", "static", "none"\], "sourceful"/); }); });