Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/energyplan-0-7-0.md
Original file line number Diff line number Diff line change
@@ -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.
53 changes: 45 additions & 8 deletions docs/energyplan-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go/internal/mpc/demand_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion go/internal/mpc/energyplan_fault_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion go/internal/mpc/energyplan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 5 additions & 3 deletions optimizer/native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion optimizer/native/bundle/THIRD-PARTY-NOTICES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.


Expand Down
Binary file removed optimizer/native/bundle/ftw-solver-darwin-arm64
Binary file not shown.
Binary file modified optimizer/native/bundle/ftw-solver-linux-amd64
Binary file not shown.
Binary file modified optimizer/native/bundle/ftw-solver-linux-arm64
Binary file not shown.
24 changes: 8 additions & 16 deletions optimizer/native/bundle/manifest.json
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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": {
Expand All @@ -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",
Expand All @@ -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",
Expand Down
Loading