diff --git a/.changeset/planned-charging-periods.md b/.changeset/planned-charging-periods.md new file mode 100644 index 00000000..8b34c457 --- /dev/null +++ b/.changeset/planned-charging-periods.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Plan charging in periods at allowed current steps, using fresh charging state across replans. Favor runs of at least five minutes after departure energy and safety needs, allow a short final top-up, and avoid extra starts for tiny savings. Update the bundled Energyplan worker with joint EV cost decisions and faster tariff planning. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index abde971e..bafe6a07 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -1761,6 +1761,7 @@ func main() { ctrlMu.Lock() noBatteryToEV := !(ctrl.BatteryCoversEV || boostActive) ctrlMu.Unlock() + charging, chargeDuration := lpMgr.ChargingPeriod(st.ID) specs = append(specs, &mpc.LoadpointSpec{ ID: st.ID, CapacityWh: capWh, @@ -1777,6 +1778,7 @@ func main() { ChargeEfficiency: loadpoint.DefaultChargeEfficiency, SurplusOnly: loadpoint.PlannerTreatsLoadpointAsSurplusOnly(st.SurplusOnly, deferGridPlan), NoBatteryToEV: noBatteryToEV, + Charging: mpc.DefaultChargingPeriods(charging, chargeDuration.Seconds()), }) } return specs diff --git a/go/internal/loadpoint/charging_period.go b/go/internal/loadpoint/charging_period.go new file mode 100644 index 00000000..16d5a83b --- /dev/null +++ b/go/internal/loadpoint/charging_period.go @@ -0,0 +1,46 @@ +package loadpoint + +import "time" + +// ChargingPeriod returns fresh observed charging, with the duration of the +// current uninterrupted run. Commanded power is not evidence of charging. +func (m *Manager) ChargingPeriod(id string) (bool, time.Duration) { + m.mu.RLock() + defer m.mu.RUnlock() + lp, ok := m.byID[id] + if !ok { + return false, 0 + } + now := m.now() + window := lp.powerWindow + if window <= 0 { + window = (EVSample{}).PowerWindow() + } + if !lp.pluggedIn || lp.powerUnavailable || lp.currentPowerW < steadyChargeFloorW || + lp.powerAt.IsZero() || lp.powerAt.After(now.Add(time.Second)) || now.Sub(lp.powerAt) > window || + lp.chargingPeriodSince.IsZero() || lp.chargingPeriodSince.After(now) { + return false, 0 + } + return true, now.Sub(lp.chargingPeriodSince) +} + +// An observation gap cannot prove uninterrupted charging. Keep this planning +// history separate from the manager's interruption notification hysteresis. +func observeChargingPeriod(lp *loadpointRuntime, sample EVSample, now time.Time) { + at := sample.PowerAt + if at.IsZero() { + at = now + } + window := lp.powerWindow + if window <= 0 { + window = (EVSample{}).PowerWindow() + } + if !sample.Connected || sample.ConnectionUnknown || sample.PowerUnavailable || !finite(sample.PowerW) || sample.PowerW < steadyChargeFloorW || + at.After(now.Add(time.Second)) || now.Sub(at) > sample.PowerWindow() { + lp.chargingPeriodSince = time.Time{} + return + } + if lp.chargingPeriodSince.IsZero() || !lp.pluggedIn || at.Before(lp.powerAt) || at.Sub(lp.powerAt) > window { + lp.chargingPeriodSince = now + } +} diff --git a/go/internal/loadpoint/charging_period_test.go b/go/internal/loadpoint/charging_period_test.go new file mode 100644 index 00000000..4fba8d8c --- /dev/null +++ b/go/internal/loadpoint/charging_period_test.go @@ -0,0 +1,102 @@ +package loadpoint + +import ( + "testing" + "time" +) + +func TestChargingPeriodUsesFreshObservedSession(t *testing.T) { + m := NewManager() + now := time.Unix(1700000000, 0) + m.SetNowFn(func() time.Time { return now }) + cfg := []Config{{ID: "car", DriverName: "test", MaxChargeW: 11000}} + m.Load(cfg) + check := func(want bool, duration time.Duration) { + t.Helper() + got, elapsed := m.ChargingPeriod("car") + if got != want || elapsed != duration { + t.Fatalf("charging=%v elapsed=%s, want %v/%s", got, elapsed, want, duration) + } + } + check(false, 0) + m.Observe("car", true, 4140, 0, true) + now = now.Add(20 * time.Second) + m.Observe("car", true, 4140, 23, true) + check(true, 20*time.Second) + m.Load(cfg) + check(true, 20*time.Second) + now = now.Add(31 * time.Second) + check(false, 0) + m.Observe("car", true, 4140, 23, true) + check(true, 0) // the observation gap cannot prove continuous charging + m.Observe("car", true, 0, 23, true) + check(false, 0) + now = now.Add(time.Minute) + m.Observe("car", true, 4140, 23, true) + check(true, 0) + m.ObserveSample("car", EVSample{Connected: true, PowerUnavailable: true, PowerAt: now}) + check(false, 0) + m.Observe("car", false, 0, 0, false) + check(false, 0) + m.Observe("car", true, 4140, 0, true) + check(true, 0) +} + +func TestChargingPeriodHonorsSourceCadence(t *testing.T) { + m := NewManager() + now := time.Unix(1700000000, 0) + m.SetNowFn(func() time.Time { return now }) + m.Load([]Config{{ID: "car"}}) + m.ObserveSample("car", EVSample{Connected: true, PowerW: 4140, PowerAt: now, PowerMaxAge: 2 * time.Minute}) + now = now.Add(time.Minute) + if on, _ := m.ChargingPeriod("car"); !on { + t.Fatal("fresh slow source lost") + } + now = now.Add(61 * time.Second) + if on, _ := m.ChargingPeriod("car"); on { + t.Fatal("stale source credited") + } +} + +func TestChargingPeriodResetsOnLostConnectionProof(t *testing.T) { + for _, tc := range []struct { + name string + identified bool + generation uint64 + unknown bool + }{ + {"unknown identified connection", true, 1, true}, + {"unknown unidentified connection", false, 1, true}, + {"new connection", true, 2, false}, + } { + t.Run(tc.name, func(t *testing.T) { + m := NewManager() + now := time.Unix(1700000000, 0) + m.SetNowFn(func() time.Time { return now }) + m.Load([]Config{{ID: "car"}}) + m.observeConnectionProof("car", 1, false) + m.Observe("car", true, 4140, 0, true) + if tc.identified { + m.byID["car"].sessionDeviceID = "charger" + m.byID["car"].sessionID = "session" + } + now = now.Add(10 * time.Second) + if on, elapsed := m.ChargingPeriod("car"); !on || elapsed != 10*time.Second { + t.Fatalf("before connection loss: charging=%v elapsed=%s", on, elapsed) + } + m.observeConnectionProof("car", tc.generation, tc.unknown) + if on, elapsed := m.ChargingPeriod("car"); on || elapsed != 0 { + t.Fatalf("cached power survived connection loss: charging=%v elapsed=%s", on, elapsed) + } + m.observeConnectionProof("car", tc.generation, false) + if on, _ := m.ChargingPeriod("car"); on { + t.Fatal("connection proof alone restored charging") + } + now = now.Add(time.Second) + m.Observe("car", true, 4140, 0, true) + if on, elapsed := m.ChargingPeriod("car"); !on || elapsed != 0 { + t.Fatalf("fresh observation retained pre-gap duration: charging=%v elapsed=%s", on, elapsed) + } + }) + } +} diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 2352faa7..fb324dd0 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -347,6 +347,8 @@ type loadpointRuntime struct { manualSavePending bool energy *sessionEnergy powerAt time.Time + powerWindow time.Duration + chargingPeriodSince time.Time powerUnavailable bool lastSavedEnergyWh float64 lastSavedEnergyAt time.Time @@ -568,6 +570,8 @@ func (m *Manager) Load(cfgs []Config) { lp.deliveredWhSession = existing.deliveredWhSession lp.energy = existing.energy lp.powerAt = existing.powerAt + lp.powerWindow = existing.powerWindow + lp.chargingPeriodSince = existing.chargingPeriodSince lp.powerUnavailable = existing.powerUnavailable lp.lastSavedEnergyWh = existing.lastSavedEnergyWh lp.lastSavedEnergyAt = existing.lastSavedEnergyAt diff --git a/go/internal/loadpoint/session_state.go b/go/internal/loadpoint/session_state.go index 9b7e3a4e..3dcd81b1 100644 --- a/go/internal/loadpoint/session_state.go +++ b/go/internal/loadpoint/session_state.go @@ -133,6 +133,11 @@ func (m *Manager) ObserveSample(id string, sample EVSample) { baseline := lp.energy.counterWh - lp.energy.integralAt(lp.energy.counterAt) lp.sessionPluginSoC -= baseline * DefaultChargeEfficiency / lp.VehicleCapacityWh } + if changed { + lp.chargingPeriodSince = time.Time{} + } + observeChargingPeriod(lp, sample, m.now()) + lp.powerWindow = sample.PowerWindow() lp.powerAt = sample.PowerAt if lp.powerAt.IsZero() { lp.powerAt = m.now() @@ -311,6 +316,11 @@ func (m *Manager) observeConnectionProof(id string, generation uint64, unknown b if generation != 0 { lp.connectionGeneration = generation } + if unknown || changed { + // A fresh cached power sample cannot prove charging across a lost + // connection. Reset this even when no session identity is known. + lp.chargingPeriodSince = time.Time{} + } if (!unknown && !changed) || (!changed && lp.sessionDeviceID == "" && lp.sessionID == "") { m.mu.Unlock() return diff --git a/go/internal/mpc/charging_periods.go b/go/internal/mpc/charging_periods.go new file mode 100644 index 00000000..8033edbc --- /dev/null +++ b/go/internal/mpc/charging_periods.go @@ -0,0 +1,51 @@ +package mpc + +import "context" + +// ChargingPeriods describes a preference, after deadline energy and physical +// reserves. It never delays Core safety, manual stop, or charge completion. +// StartCostOre is part of the optimizer preference, not the electricity bill. +type ChargingPeriods struct { + MinChargeSeconds float64 `json:"min_charge_seconds"` + StartCostOre float64 `json:"start_cost_ore"` + InitialCharging bool `json:"initial_charging"` + InitialChargeSeconds float64 `json:"initial_charge_seconds"` +} + +// DefaultChargingPeriods favors runs of at least five minutes and avoids an +// extra start for less than five ore. A final top-up may be shorter. +func DefaultChargingPeriods(charging bool, seconds float64) ChargingPeriods { + return ChargingPeriods{MinChargeSeconds: 300, StartCostOre: 5, + InitialCharging: charging, InitialChargeSeconds: min(300, max(0, seconds))} +} + +func (o *EnergyplanOptimizer) addChargingPeriods(ctx context.Context, request *externalRequest, p Params) error { + loads := p.activeLoadpoints() + needed := false + for _, lp := range loads { + needed = needed || lp.Charging.MinChargeSeconds > 0 || lp.Charging.StartCostOre > 0 + } + if !needed { + return nil + } + // Negotiate against the running worker on every request. Restart or a + // binary rollback must not inherit a feature learned from another process. + probeCtx, cancel := context.WithTimeout(ctx, o.cfg.Timeout) + defer cancel() + info, err := o.Health(probeCtx) + if err != nil { + return err + } + if !optimizerHasFeature(info, "charging_periods") { + return nil + } + byID := make(map[string]ChargingPeriods, len(loads)) + for _, lp := range loads { + byID[lp.ID] = lp.Charging + } + for i := range request.FlexLoads { + c := byID[request.FlexLoads[i].ID] + request.FlexLoads[i].Charging = &c + } + return nil +} diff --git a/go/internal/mpc/charging_periods_test.go b/go/internal/mpc/charging_periods_test.go new file mode 100644 index 00000000..128d058f --- /dev/null +++ b/go/internal/mpc/charging_periods_test.go @@ -0,0 +1,99 @@ +package mpc + +import ( + "context" + "encoding/json" + "fmt" + "math" + "testing" + "time" +) + +type chargingTransport struct{ feature bool } + +func (c *chargingTransport) RoundTrip(context.Context, []byte) ([]byte, error) { + features := []string{"champion", "ev_duty"} + if c.feature { + features = append(features, "charging_periods") + } + return json.Marshal(map[string]any{"name": "ftw-solver", "version": "test", "protocol_version": 1, "features": features}) +} +func (*chargingTransport) Health(context.Context) (OptimizerRuntimeInfo, error) { + return OptimizerRuntimeInfo{}, fmt.Errorf("native handshake required") +} +func (*chargingTransport) Close() error { return nil } + +func TestChargingPeriodsNegotiatesEachWorkerAndKeepsLegacyWire(t *testing.T) { + slots, p := externalTestFixture() + p.Loadpoint = &LoadpointSpec{ID: "car", PluggedIn: true, CapacityWh: 60000, Levels: 11, SoCMax: 1, MaxChargeW: 11000, Charging: DefaultChargingPeriods(true, 120)} + transport := &chargingTransport{feature: true} + external, err := NewExternalOptimizer(ExternalOptimizerConfig{Transport: transport}) + if err != nil { + t.Fatal(err) + } + o := &EnergyplanOptimizer{ExternalOptimizer: external} + for _, supported := range []bool{true, false, true} { + transport.feature = supported + r := external.buildRequest(slots, p) + if r.FlexLoads[0].Charging != nil { + t.Fatal("generic sidecar received an unnegotiated field") + } + if err := o.addChargingPeriods(context.Background(), &r, p); err != nil { + t.Fatal(err) + } + c := r.FlexLoads[0].Charging + if (c != nil) != supported { + t.Fatalf("supported %v, charging %+v", supported, c) + } + if supported && (c.MinChargeSeconds != 300 || c.StartCostOre != 5 || !c.InitialCharging || c.InitialChargeSeconds != 120) { + t.Fatalf("state lost: %+v", c) + } + } +} + +func TestChargingPeriodsRejectsInvalidPreference(t *testing.T) { + for _, bad := range []float64{-1, math.Inf(1), math.NaN(), 86401} { + lp := &LoadpointSpec{ID: "car", PluggedIn: true, CapacityWh: 60000, Levels: 11, SoCMax: 1, MaxChargeW: 11000, Charging: ChargingPeriods{MinChargeSeconds: bad}} + if err := validateLoadpointSpecs([]*LoadpointSpec{lp}, map[string]string{}); err == nil { + t.Fatalf("accepted %v", bad) + } + } +} + +func TestNativeChargingPeriodsContinueMeasuredRun(t *testing.T) { + template := nativeWorker(t, 500*time.Millisecond) + defer template.Close() + o, err := NewEnergyplanOptimizer(template.cfg.Command[0]) + if err != nil { + t.Fatal(err) + } + defer o.Close() + start := time.Now().Add(time.Minute).Truncate(time.Minute) + slots := make([]Slot, 12) + for i := range slots { + slots[i] = Slot{StartMs: start.Add(time.Duration(i) * time.Minute).UnixMilli(), LenMin: 1, Confidence: 1, PriceOre: 100, Limits: PowerLimits{MaxImportW: 1000}} + } + p := Params{Mode: ModeArbitrage, CapacityWh: 10000, SoCMin: .1, SoCMax: .9, InitialSoC: .5, ChargeEfficiency: 1, DischargeEfficiency: 1, + Loadpoint: &LoadpointSpec{ID: "car", CapacityWh: 1000, Levels: 11, SoCMax: .1, TargetSoC: .1, TargetSlotIdx: 11, PluggedIn: true, + ChargeEfficiency: 1, MaxChargeW: 1000, AllowedStepsW: []float64{0, 1000}, Charging: DefaultChargingPeriods(true, 180)}} + plan, err := o.Optimize(context.Background(), slots, p) + if err != nil { + t.Fatal(err) + } + if err := ValidatePlan(slots, p, &plan); err != nil { + t.Fatal(err) + } + var req externalRequest + if err := json.Unmarshal(plan.OptimizerInput, &req); err != nil { + t.Fatal(err) + } + if len(req.FlexLoads) != 1 || req.FlexLoads[0].Charging == nil || !req.FlexLoads[0].Charging.InitialCharging { + t.Fatal("measured run missing from request") + } + if plan.Actions[0].LoadpointPowerW["car"] <= 0 { + t.Fatal("current charging was postponed") + } + if math.Abs(plan.TotalCostOre-10) > 1e-5 { + t.Fatalf("preference changed the bill: %g", plan.TotalCostOre) + } +} diff --git a/go/internal/mpc/energyplan.go b/go/internal/mpc/energyplan.go index 93a05a80..ab6b888b 100644 --- a/go/internal/mpc/energyplan.go +++ b/go/internal/mpc/energyplan.go @@ -23,7 +23,9 @@ func NewEnergyplanOptimizer(binary string) (*EnergyplanOptimizer, error) { return nil, err } external.timeBudget = energyplanTimeBudget - return &EnergyplanOptimizer{ExternalOptimizer: external}, nil + o := &EnergyplanOptimizer{ExternalOptimizer: external} + external.prepareRequest = o.addChargingPeriods + return o, nil } const ( diff --git a/go/internal/mpc/energyplan_fault_test.go b/go/internal/mpc/energyplan_fault_test.go index e4178cd8..14dce288 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.4.6" { + if err != nil || health.Version != "0.4.8" { 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 355c0113..4005c53e 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.4.6" { + if err != nil || info.Name != "ftw-solver" || info.Version != "0.4.8" { t.Fatalf("bundled worker health: %+v %v", info, err) } start := time.Now().UTC().Truncate(time.Hour) diff --git a/go/internal/mpc/external_optimizer.go b/go/internal/mpc/external_optimizer.go index eaf0cd90..a98ebb4e 100644 --- a/go/internal/mpc/external_optimizer.go +++ b/go/internal/mpc/external_optimizer.go @@ -83,9 +83,10 @@ type ExternalOptimizerConfig struct { // serialized to keep request and response ownership unambiguous. An optional idle timeout releases the worker's solver memory // between planning bursts. type ExternalOptimizer struct { - cfg ExternalOptimizerConfig - transport OptimizerTransport - timeBudget func([]Slot, Params) time.Duration + cfg ExternalOptimizerConfig + transport OptimizerTransport + timeBudget func([]Slot, Params) time.Duration + prepareRequest func(context.Context, *externalRequest, Params) error } func NewExternalOptimizer(cfg ExternalOptimizerConfig) (*ExternalOptimizer, error) { @@ -285,17 +286,18 @@ type externalDemandHour struct { } type externalFlexLoad struct { - ID string `json:"id"` - CapacityWh float64 `json:"capacity_wh"` - InitialEnergyWh float64 `json:"initial_energy_wh"` - MaxEnergyWh float64 `json:"max_energy_wh"` - TargetEnergyWh float64 `json:"target_energy_wh"` - TargetSlot int `json:"target_slot"` - ChargeEfficiency float64 `json:"charge_efficiency"` - MaxChargeW float64 `json:"max_charge_w"` - AllowedStepsW []float64 `json:"allowed_steps_w"` - SurplusOnly bool `json:"surplus_only"` - NoStorageToLoad bool `json:"no_storage_to_load"` + ID string `json:"id"` + CapacityWh float64 `json:"capacity_wh"` + InitialEnergyWh float64 `json:"initial_energy_wh"` + MaxEnergyWh float64 `json:"max_energy_wh"` + TargetEnergyWh float64 `json:"target_energy_wh"` + TargetSlot int `json:"target_slot"` + ChargeEfficiency float64 `json:"charge_efficiency"` + MaxChargeW float64 `json:"max_charge_w"` + AllowedStepsW []float64 `json:"allowed_steps_w"` + SurplusOnly bool `json:"surplus_only"` + NoStorageToLoad bool `json:"no_storage_to_load"` + Charging *ChargingPeriods `json:"charging,omitempty"` } type externalResponse struct { @@ -389,6 +391,11 @@ func (o *ExternalOptimizer) optimize(ctx context.Context, slots []Slot, p Params request.Settings.PHRho = ms.PHRho request.Settings.PHToleranceW = ms.PHToleranceW } + if o.prepareRequest != nil { + if err := o.prepareRequest(ctx, &request, p); err != nil { + return Plan{}, fmt.Errorf("prepare optimizer request: %w", err) + } + } payload, err := json.Marshal(request) if err != nil { return Plan{}, fmt.Errorf("encode optimizer request: %w", err) diff --git a/go/internal/mpc/loadpoint_spec.go b/go/internal/mpc/loadpoint_spec.go index a7b1a31f..9ac5146b 100644 --- a/go/internal/mpc/loadpoint_spec.go +++ b/go/internal/mpc/loadpoint_spec.go @@ -73,6 +73,10 @@ type LoadpointSpec struct { // is the same conservation rule, not this helper — dispatch.go is // owned by a separate PV-export PR. NoBatteryToEV bool + + // Optional native-worker preference; the Go emergency fallback keeps its + // electrical checks and deadline behavior. + Charging ChargingPeriods } func (l *LoadpointSpec) blocksBatteryToEV() bool { diff --git a/go/internal/mpc/params_validation.go b/go/internal/mpc/params_validation.go index 8d3c1f80..96a0c88d 100644 --- a/go/internal/mpc/params_validation.go +++ b/go/internal/mpc/params_validation.go @@ -453,6 +453,18 @@ func validateLoadpointSpecs(loadpoints []*LoadpointSpec, assetIDs map[string]str if err := requirePlanningEfficiency(field+".charge_efficiency", loadpoint.ChargeEfficiency, true); err != nil { return err } + for _, value := range []struct { + name string + v, max float64 + }{ + {"min_charge_seconds", loadpoint.Charging.MinChargeSeconds, 86400}, + {"start_cost_ore", loadpoint.Charging.StartCostOre, 1e6}, + {"initial_charge_seconds", loadpoint.Charging.InitialChargeSeconds, 31536000}, + } { + if !finite(value.v) || value.v < 0 || value.v > value.max { + return fmt.Errorf("%s.charging.%s is outside its finite bounds", field, value.name) + } + } for stepIdx, stepW := range loadpoint.AllowedStepsW { if !finite(stepW) || stepW < 0 { return fmt.Errorf("%s.allowed_steps_w[%d] must be finite and non-negative", field, stepIdx) diff --git a/optimizer/native/README.md b/optimizer/native/README.md index 09d8c383..ce7df9be 100644 --- a/optimizer/native/README.md +++ b/optimizer/native/README.md @@ -4,7 +4,7 @@ This directory contains the optional proprietary Sourceful Energyplan worker, its license and third-party notices, and public integration checks. Rust source, source tests and builds live in the private `srcfl/energyplan` repository. -Energyplan 0.4.6 uses the Home Use Binary License in `bundle/LICENSE.txt`. It +Energyplan 0.4.8 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 @@ -69,6 +69,15 @@ violation; after recovery the plan must stay within the configured limits. Core independently checks that recovery and validates fallback plans too. The compiled worker updates with Core. +Core negotiates `charging_periods` before sending the current observed charging +state and the charging preference. The default favors periods of at least five +minutes after departure energy and physical reserves, and prices an extra start +at five ore in the objective. A final top-up can be shorter. These preferences +never override safety or prevent a partial plan when a target is unreachable. +They do not enter the reported electricity bill. Missing or stale observations +cannot prove a continuous run. Earlier workers receive the existing request. + + Supported requests can contain zero, one or several batteries and EVs, with each device's own physical limits and EV deadline. The worker supports the four existing modes, negative tariffs and shared scenarios with CVaR. Request diff --git a/optimizer/native/bundle/ftw-solver-darwin-arm64 b/optimizer/native/bundle/ftw-solver-darwin-arm64 index 86dca766..ce5c0c2b 100755 Binary files a/optimizer/native/bundle/ftw-solver-darwin-arm64 and b/optimizer/native/bundle/ftw-solver-darwin-arm64 differ diff --git a/optimizer/native/bundle/ftw-solver-linux-amd64 b/optimizer/native/bundle/ftw-solver-linux-amd64 index d7d80466..4949d27c 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 f40a3b73..03215220 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 61db7ff0..0ce2abda 100644 --- a/optimizer/native/bundle/manifest.json +++ b/optimizer/native/bundle/manifest.json @@ -1,9 +1,9 @@ { "schema_version": 1, "product": "energyplan", - "version": "0.4.6", + "version": "0.4.8", "source_repository": "srcfl/energyplan", - "source_commit": "fbcd2e31e4f3779ff6439ca69a8544d3d3dd9a73", + "source_commit": "c544d89277fc6a4e013a0b09867582e39b6115c0", "rustc": "rustc 1.95.0 (59807616e 2026-04-14)", "protocol_version": 1, "forecast_protocol_version": 1, @@ -39,16 +39,16 @@ "bytes": 15377 }, "ftw-solver-darwin-arm64": { - "sha256": "a2b754823da4499160101c58e51ec5517720b0b393394797cac55e78c87d7785", - "bytes": 1273344 + "sha256": "30a76eac89deac95f8f63b053101a9cf5ded8d3bc95f0208da3fbd5ead4bbcc0", + "bytes": 1339568 }, "ftw-solver-linux-amd64": { - "sha256": "5f1c772d003c4be961b1d783eded9fdf8354c413e00245bed0eab629afe0d128", - "bytes": 1636136 + "sha256": "ee6b76898ef76a65b1d17a1e49466ac5ae37a64cb41e472151fc263be597f38e", + "bytes": 1715048 }, "ftw-solver-linux-arm64": { - "sha256": "f48643173083b4f61bc81b3236a0204e246125eb08c7ffcbe5d14b4d8385ec43", - "bytes": 1320968 + "sha256": "62ea3d240615015b6290f25418e6f2d35272a8113f4df06215b44226234b288a", + "bytes": 1378688 }, "rust-runtime/COPYRIGHT-library.html": { "sha256": "90567e2718bf7fd65a71a3a43c5596488e80e5f51ed02bfea6fec54458b5f3d1",