diff --git a/.changeset/energyplan-0-7-0.md b/.changeset/energyplan-0-7-0.md new file mode 100644 index 000000000..f49e2e889 --- /dev/null +++ b/.changeset/energyplan-0-7-0.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Update the bundled Energyplan planner to 0.7.0. With a peak-power tariff it now plans with the hour already in progress, so each day's peak counts once. A new plan is never worse for the car goals or the bill than the plan the planner already holds, a site whose limits cannot all be met gets a clear reason instead of a failed plan, and the planner needs less memory. The bundle now carries the Linux workers only; a Mac keeps Core's own planner. Core still validates every plan. diff --git a/docs/energyplan-contract.md b/docs/energyplan-contract.md index 857aeaa95..4b56584b0 100644 --- a/docs/energyplan-contract.md +++ b/docs/energyplan-contract.md @@ -37,10 +37,12 @@ The handshake identifies the worker, its version, protocol range and features. Protocol compatibility and supported features govern use, not matching Core and Energyplan release numbers. Current features are `champion`, `forecast_reset`, `partial_slots`, `demand_charges`, `ev_duty`, -`charging_periods`, `published_prices` and `hourly_import_caps`; the worker -also reports `forecast_protocol_version`. +`charging_periods`, `published_prices`, `hourly_import_caps` and +`demand_elapsed_hours`; the worker also reports `forecast_protocol_version`. Core negotiates `charging_periods` before sending its optional fields, and `hourly_import_caps` before sending `hourly_import_caps`. +Core negotiates `demand_elapsed_hours` before sending a demand hour that +starts before the first slot or ended by its execution start. New optional request fields need capability negotiation or a paired update that preserves deployed clients. Unknown optimizer request fields are errors. @@ -84,9 +86,11 @@ Worker model support does not prove Core can execute every supported case. Physical capacity, device power, grid limits, allowed EV steps and mode rules are hard limits. Measured battery energy outside an operating band may recover -without worsening the existing violation. After recovery it must stay inside -the band. Physical zero and capacity remain hard limits throughout. Default -wear cost is zero; charge/discharge efficiency remains in the physical model. +without worsening the existing violation, for each battery and for the +batteries' total, whose band is the sum of their bands (Core applies the same +SoC limits to every battery). After recovery it must stay inside the band. +Physical zero and capacity remain hard limits throughout. Default wear cost +is zero; charge/discharge efficiency remains in the physical model. Multiple batteries keep their own limits. Current execution requires a common charge/discharge direction in each slot. Demand-charge windows use explicit @@ -118,6 +122,27 @@ The cap wins over EV goals, whose miss shows in `plan.hourly_import_caps` as `{start_ms, end_ms, import_wh, max_wh}` on the published forecast, `import_wh` including the elapsed part. +`demand_charges` carries peak-power tariffs as +`{id, price_per_kw, top_n, already_kw, floor_kw, hours}`, each hour +`{start_ms, end_ms, elapsed_import_kwh, weight, group}` with weight 1 by +default. An hour's sample is its weight times its mean import in kW: the +import before the first slot's execution start, `elapsed_import_kwh`, plus the +plan's import per pulse state from then or `start_ms`, whichever is later, to +`end_ms`, over the hour's length. A group is one sample, the largest of its +hours'; an ungrouped hour and each `already_kw` value are one sample each. The +bill is `price_per_kw * max(floor_kw, mean of the top_n largest samples)`, or +of all samples if there are fewer. Hours must end inside the horizon, not +overlap, last 1 minute to 25 hours and number at most 512 per charge. An hour +that ended by execution start must share its group with an hour that overlaps +the plan. Core makes each local day a group. With `demand_elapsed_hours` it +sends the hour in progress from its start, puts the completed peak of each day +the plan reaches in that day's group and sends each other completed day as one +`already_kw` value, so each local day is one sample. Without the feature Core +sends only hours from the first slot on and every completed day in +`already_kw`. The plan reports `plan.demand_cost` and, per charge in +`plan.demand_charges`, `billed_kw`, `cost` and each hour's unweighted +`mean_kw`, in ascending start order. + Thermal, commercial and recourse inputs are unsupported. Protocol v1 has no per-phase electrical model, future connection windows or phase-switch schedule. Total watts cannot certify phase safety. Those extensions need an explicit @@ -193,13 +218,19 @@ For minimization, a reported bound must satisfy `lower_bound <= optimum <= candidate objective` for the same feasible set and objective. Unknown bounds and gaps remain null. When executed pulse cost and the optimized cost model differ, report `feasible` and omit the cost bound. -Core validates physical execution; it does not independently prove optimality. +A plan from a search that left out part of the model reports `feasible` with +a null bound. Core validates physical execution; it does not independently +prove optimality. Core makes no worker call when less than 500 ms of the first slot remain. It allows 500 ms for small plans, 1.5 s with an active car, and 5 s when slots × (3 × batteries + 2 × active cars) is at least 965, or when the request -carries PV curtailment, demand charges or hourly import caps. The request's -`time_limit_s` is the smaller of that budget and 5.6 s; Core starts the worker +carries PV curtailment, demand charges or hourly import caps. Each call's +`time_limit_s` is the smallest of that budget, 5.6 s and the time left before +that call's deadline once its request is prepared, less a reply margin of +max(5% of that time, 30 ms). A later call of the same planning attempt, such as +the forecast-caution call after the point call, can get less, and Core sends +no request with less than 450 ms before its deadline. Core starts the worker with `--time-limit=5s`. The transport timeout is 7 s. Near the first slot's end Core keeps 50 ms for validation and publication and a reply margin of max(5% of the remaining time after that, 30 ms). @@ -216,6 +247,12 @@ preferences and cost. `solver.warm_start_valid`, when present, means a cached candidate passed this check; it does not mean the final plan used it. Restart may discard this optional cache without losing stored user goals. +Replies may carry optional diagnostics. `solver.source`, `solver.fallbacks`, +`error.fallbacks` and `error.model_variables` are worker-defined, opaque +numeric codes; each `fallbacks` is an array of them. `solver.model_variables` +is an integer; Core rejects a reply whose value is not one. Core never +accepts, rejects or ranks a plan by the values of these diagnostics. + On failure Core may use its validated fallback only where it can represent the site. Otherwise an old plan may remain as diagnostic history; retention does not grant permission to execute stale or unvalidated actions. Restart diff --git a/go/internal/mpc/demand_test.go b/go/internal/mpc/demand_test.go index beae712e1..3831191bb 100644 --- a/go/internal/mpc/demand_test.go +++ b/go/internal/mpc/demand_test.go @@ -545,7 +545,7 @@ func TestNativeDemandChargesOnWeekdayHour(t *testing.T) { // A worker that announces demand_elapsed_hours takes the hour in progress // that began before slot 0 and today's completed peak in today's group, and -// Core accepts its plan. The bundled worker skips until its pin takes them. +// Core accepts its plan. The bundled worker must announce the feature. func TestNativeEnergyplanTakesElapsedDemandHours(t *testing.T) { binary := os.Getenv("FTW_NATIVE_SOLVER") if binary == "" { @@ -563,7 +563,7 @@ func TestNativeEnergyplanTakesElapsedDemandHours(t *testing.T) { t.Fatal(err) } if !optimizerHasFeature(info, "demand_elapsed_hours") { - t.Skip("this worker does not take demand_elapsed_hours") + t.Fatal("the bundled worker does not announce demand_elapsed_hours") } slots, importWh, now := weekdayEvening(t, true) evening := time.Date(2026, 9, 9, 20, 0, 0, 0, time.UTC) diff --git a/go/internal/mpc/energyplan_fault_test.go b/go/internal/mpc/energyplan_fault_test.go index ea728d01a..7b341ec41 100644 --- a/go/internal/mpc/energyplan_fault_test.go +++ b/go/internal/mpc/energyplan_fault_test.go @@ -57,7 +57,7 @@ func TestNativeEnergyplanEVRecoveryFallbackFaults(t *testing.T) { t.Cleanup(func() { _ = external.Close() }) wrapper := &EnergyplanOptimizer{ExternalOptimizer: external} health, err := wrapper.Health(context.Background()) - if err != nil || health.Version != "0.6.0" { + if err != nil || health.Version != "0.7.0" { t.Fatalf("bundle health=%+v err=%v", health, err) } svc := shadowTestService(t) diff --git a/go/internal/mpc/energyplan_test.go b/go/internal/mpc/energyplan_test.go index 1f8d6da93..df5bfffa4 100644 --- a/go/internal/mpc/energyplan_test.go +++ b/go/internal/mpc/energyplan_test.go @@ -95,7 +95,7 @@ func TestNativeEnergyplanDownsideAndAsyncShadow(t *testing.T) { svc := shadowTestService(t) svc.Optimizer = o info, err := svc.Optimizer.(*EnergyplanOptimizer).Health(context.Background()) - if err != nil || info.Name != "ftw-solver" || info.Version != "0.6.0" { + if err != nil || info.Name != "ftw-solver" || info.Version != "0.7.0" { t.Fatalf("bundled worker health: %+v %v", info, err) } start := time.Now().UTC().Truncate(time.Hour) diff --git a/optimizer/native/README.md b/optimizer/native/README.md index f45bdef74..def86c45d 100644 --- a/optimizer/native/README.md +++ b/optimizer/native/README.md @@ -8,7 +8,7 @@ The [Energyplan contract](../../docs/energyplan-contract.md) defines the shared planning and forecast boundary, result meanings, units and failure rules. Core owns it; Energyplan keeps an identical copy. -Energyplan 0.6.0 uses the Home Use Binary License in `bundle/LICENSE.txt`. It +Energyplan 0.7.0 uses the Home Use Binary License in `bundle/LICENSE.txt`. It permits private household use with FTW and free noncommercial redistribution for that use. Commercial use, OEM bundles, paid installation and services need a separate written license from Sourceful Labs AB. FTW's AGPL code has a @@ -58,8 +58,10 @@ explicitly, or `core` to select Core DP. Stable and development builds keep Core as the unset default; Windows and macOS have no required bundled worker. -Energyplan uses the same downside PV forecast as Core. Small requests get a -500 ms solve budget; larger fleets and PV-control requests get 5 s. +Energyplan uses the same downside PV forecast as Core. Core's call budgets +(500 ms, 1.5 s with an active car, 5 s for larger fleets and for PV-control, +demand-charge and import-cap requests, each fitted to its call's deadline) are +in the contract. Core applies PV uncertainty once when forming the downside horizon. This does not add worker scenarios or extend the solve budget by itself. The transport timeout is 7 s. After Core validates and publishes a plan, one diff --git a/optimizer/native/bundle/THIRD-PARTY-NOTICES.txt b/optimizer/native/bundle/THIRD-PARTY-NOTICES.txt index 83850bef4..6b3662c7d 100644 --- a/optimizer/native/bundle/THIRD-PARTY-NOTICES.txt +++ b/optimizer/native/bundle/THIRD-PARTY-NOTICES.txt @@ -530,7 +530,7 @@ Upstream: https://github.com/Specy/microlp Vendored from crates.io microlp 0.6.0. Upstream source commit: 22f625e4112967946e9dcd2bc87378424b2d265e Crate SHA256: a8f19803f918039a07f3c32b23716824d8f073543417bdac5b1b8619817e3674 -Sourceful modification: simplex deadline polling every 16 instead of 1000 pivots; opt-in diagnostic phase spans; a continuous-relaxation clone and fixed-variable bounds; direct adoption of complete warm starts after full bounds/domain/constraint validation; read access to a problem's variables and constraints; a time limit for later edits of a solution. +Sourceful modification: simplex deadline polling every 16 instead of 1000 pivots; opt-in diagnostic phase spans; a continuous-relaxation clone and fixed-variable bounds; direct adoption of complete warm starts after full bounds/domain/constraint validation; read access to a problem's variables and constraints; a time limit for later edits of a solution; read-only solve statistics; smaller branch & bound node storage. The upstream Apache-2.0 license and copyright notices remain in this package. diff --git a/optimizer/native/bundle/ftw-solver-darwin-arm64 b/optimizer/native/bundle/ftw-solver-darwin-arm64 deleted file mode 100755 index ec5746f08..000000000 Binary files a/optimizer/native/bundle/ftw-solver-darwin-arm64 and /dev/null differ diff --git a/optimizer/native/bundle/ftw-solver-linux-amd64 b/optimizer/native/bundle/ftw-solver-linux-amd64 index fd4a2f1d6..9ef3f2dc9 100755 Binary files a/optimizer/native/bundle/ftw-solver-linux-amd64 and b/optimizer/native/bundle/ftw-solver-linux-amd64 differ diff --git a/optimizer/native/bundle/ftw-solver-linux-arm64 b/optimizer/native/bundle/ftw-solver-linux-arm64 index ba8a43c1a..42cdb5607 100755 Binary files a/optimizer/native/bundle/ftw-solver-linux-arm64 and b/optimizer/native/bundle/ftw-solver-linux-arm64 differ diff --git a/optimizer/native/bundle/manifest.json b/optimizer/native/bundle/manifest.json index 7068d9c8d..c504ea838 100644 --- a/optimizer/native/bundle/manifest.json +++ b/optimizer/native/bundle/manifest.json @@ -1,9 +1,9 @@ { "schema_version": 1, "product": "energyplan", - "version": "0.6.0", + "version": "0.7.0", "source_repository": "srcfl/energyplan", - "source_commit": "e975bb946234d9a5c889e4957991d54737201ccf", + "source_commit": "847936a15de6efed4192c2ec2d67f31f6f98ce64", "rustc": "rustc 1.95.0 (59807616e 2026-04-14)", "protocol_version": 1, "forecast_protocol_version": 1, @@ -15,10 +15,6 @@ "linux-amd64": { "path": "ftw-solver-linux-amd64", "target": "x86_64-unknown-linux-musl" - }, - "darwin-arm64": { - "path": "ftw-solver-darwin-arm64", - "target": "aarch64-apple-darwin" } }, "files": { @@ -27,8 +23,8 @@ "bytes": 4296 }, "THIRD-PARTY-NOTICES.txt": { - "sha256": "9ee090bbf54b50407b9150c4e4311f6baff76f68c738b9b17fa3f3afbc2693a7", - "bytes": 55987 + "sha256": "d6d5fa4eea968c3b480bce0078f26a62690d24732263d5f98fcc9b488dacf149", + "bytes": 56052 }, "forecast-v1.response.schema.json": { "sha256": "b7f06c00679b3798cbedd458f8b9b6bfd556e96ac442bb3820f9696c6c7bdd99", @@ -38,17 +34,13 @@ "sha256": "6b374bd19d351e568761e1050016202d78aea2fc2e6783a2e9245aa23ffb2d3f", "bytes": 15377 }, - "ftw-solver-darwin-arm64": { - "sha256": "9dc25999ce96247593adbe02c29cc368f8d03e91bd6e18be8f52acc8a5c82336", - "bytes": 1522000 - }, "ftw-solver-linux-amd64": { - "sha256": "82a6a6d9414589b704f658117cca8c41d10db83c5eb909016dfd8d2aae1c4395", - "bytes": 1937544 + "sha256": "67ff308aa4f30134a066898a1638f2f0436eff0a299d661af8de3a04cadb2bee", + "bytes": 2102880 }, "ftw-solver-linux-arm64": { - "sha256": "00875b4d243bd1e32a028084552559603f11f155cb5391ab9d8d0f1e7fcc4b4c", - "bytes": 1549032 + "sha256": "a6f1609d61c38d6a8fb7b00ec695ed6bdd962c657b9fe544833c4acde0d4338b", + "bytes": 1674152 }, "rust-runtime/COPYRIGHT-library.html": { "sha256": "90567e2718bf7fd65a71a3a43c5596488e80e5f51ed02bfea6fec54458b5f3d1",