diff --git a/.changeset/physical-planner-topologies.md b/.changeset/physical-planner-topologies.md new file mode 100644 index 00000000..e7f010ad --- /dev/null +++ b/.changeset/physical-planner-topologies.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +Plan sites with no home battery, several batteries or several EVs with the bundled Energyplan worker. Keep each asset's limits and energy target, validate worker identities and deadlines, and carry each battery's energy budget through Core control. Only count verified PV generation control, and stop using its plan when the driver or its health changes. Keep the previous plan for diagnosis when the fallback cannot represent the site. diff --git a/go/cmd/ftw/energyplan_test.go b/go/cmd/ftw/energyplan_test.go index 0652f16e..374228a5 100644 --- a/go/cmd/ftw/energyplan_test.go +++ b/go/cmd/ftw/energyplan_test.go @@ -43,3 +43,15 @@ func TestBuildMPCBetaStartsBundledEnergyplan(t *testing.T) { } t.Cleanup(func() { svc.Optimizer.Close() }) } + +func TestBuildMPCWithoutHomeBattery(t *testing.T) { + cfg, _ := plannerEngineConfig(&config.Planner{Enabled: true, Engine: "energyplan"}) + cfg.Drivers = nil + svc := buildMPC(cfg, nil, nil, nil) + if svc == nil || svc.Defaults.CapacityWh != 0 || svc.Defaults.InitialSoC != 0 || svc.Defaults.MaxChargeW != 0 || len(svc.BatteryFleet) != 0 { + t.Fatalf("batteryless site invented storage: %+v", svc) + } + if svc.Optimizer != nil { + defer svc.Optimizer.Close() + } +} diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 4b002580..f98b76f8 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -1443,6 +1443,20 @@ func main() { // ---- Start MPC planner (optional) ---- mpcSvc = buildMPC(cfg, st, tel, capacities) if mpcSvc != nil { + pvProofLookup := func(name string) mpc.PVCurtailment { + proof := reg.PVGenerationLimit(name) + return mpc.PVCurtailment{Driver: name, Proof: proof.Token, MinW: proof.MinW, MaxW: proof.MaxW} + } + ctrl.PVGenerationLimit = pvProofLookup + mpcSvc.PVExecutionAllowed = func(proof mpc.PVCurtailment) bool { + return control.PVGenerationProofValid(tel, time.Now(), proof, pvProofLookup) + } + mpcSvc.PVCurtailmentProbe = func() mpc.PVCurtailment { + options := forecastSettings.Snapshot().Options + ctrlMu.Lock() + defer ctrlMu.Unlock() + return control.PlanningPVCurtailment(ctrl, tel, options) + } // Plumb the site fuse so the DP joint-plans battery + EV under // the fuse from the start (instead of producing plans that // dispatch later has to scale via the joint allocator). @@ -1708,7 +1722,8 @@ func main() { } // SlotDirectiveFromMPC lives in package control so tests // and main share the plan→EMS field map. - return control.SlotDirectiveFromMPC(d), true + dir := control.SlotDirectiveFromMPC(d) + return dir, control.PlanningPVDirectiveValid(ctrl, tel, dir) } // Default to the energy-allocation path. The plan is a // scheduler (decides WHEN each strategy applies); the EMS is @@ -3679,7 +3694,7 @@ func aggregateBatteryFleetLimits(cfg *config.Config, fleet []mpc.BatteryFleetMem } // buildMPC constructs a planner from config. Returns nil if disabled, -// if prices aren't configured, or if there are no batteries with capacity. +// or if prices aren't configured. EV planning also works without home storage. // The skip reason is the same vocabulary /api/mpc/diagnose exposes. func buildMPC(cfg *config.Config, st *state.Store, tel *telemetry.Store, capacities map[string]float64) *mpc.Service { plannerOn := cfg.Planner != nil && cfg.Planner.Enabled @@ -3765,6 +3780,9 @@ func buildMPC(cfg *config.Config, st *state.Store, tel *telemetry.Store, capacit DischargeEfficiency: disEff, ExportOrePerKWh: pl.ExportOrePerKWh, } + if totalCap == 0 { + params.InitialSoC = 0 + } svc := mpc.New(st, tel, zone, params) svc.UpdateBatteryFleet(fleet, totalCap, maxChg, maxDis) // Release defaults select the beta worker. An explicit engine wins; diff --git a/go/internal/api/api_mpc_unavailable_test.go b/go/internal/api/api_mpc_unavailable_test.go index d44b8f6d..20ef7137 100644 --- a/go/internal/api/api_mpc_unavailable_test.go +++ b/go/internal/api/api_mpc_unavailable_test.go @@ -33,16 +33,6 @@ func TestMPCDisabledEndpointsNameTheSkipReason(t *testing.T) { }, want: mpc.ReasonNoPriceProvider, }, - { - name: "no battery capacity", - deps: &Deps{ - Cfg: &config.Config{ - Planner: &config.Planner{Enabled: true}, - Price: &config.Price{Provider: "nordpool"}, - }, - }, - want: mpc.ReasonNoBatteryCapacity, - }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 7ce189e3..e9ac7b17 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -122,10 +122,13 @@ type SlotDirective struct { DecisionID string SlotStart time.Time SlotEnd time.Time - BatteryEnergyWh float64 // site-signed: + = charge, − = discharge + BatteryEnergyWh float64 // site-signed: + = charge, − = discharge + StorageEnergyWh map[string]float64 // validated per-storage AC energy budgets SoCTarget float64 Strategy string // echoed for logging / API; mirrors mpc.Mode PVLimitW float64 // 0 = no curtail; > 0 = cap aggregate PV output + PVCurtailActive bool + PVCurtailment mpc.PVCurtailment // PlannedGridW is the plan's forecast of slot-average gridW given the // planned battery / load / PV mix (site-signed: + = import). The @@ -533,6 +536,7 @@ type State struct { // accounting. Reset when the slot rolls over (by SlotStart equality). // Zero-valued until UseEnergyDispatch fires its first cycle. currentDirective SlotDirective + storageDelivery storageSlotDelivery slotDelivered float64 // Wh delivered to batteries since slot start lastTickTs time.Time // for ∫ battery_w dt // controlSlotDecisionID is the accepted plan used to choose the last @@ -592,6 +596,7 @@ type State struct { // Populated from config.Driver.SupportsPVCurtail in main.go; // hot-swappable via the config-reload watcher. SupportsPVCurtail map[string]bool + PVGenerationLimit func(string) mpc.PVCurtailment // SolarFeedDrivers flags drivers whose operator armed an opt-in // `solar_pv` write path (e.g. the NIBE S-series Solar PV surplus @@ -2431,6 +2436,8 @@ func ComputeDispatch( var raw []DispatchTarget if manualHoldActive && manualHold.Driver != "" { raw = distributeScopedManualHold(onlineBats, manualHold.Driver, currentTotal+totalCorrection) + } else if allocated, ok := distributePlannedStorages(state, onlineBats, currentTotal+totalCorrection, manualHoldActive); ok { + raw = allocated } else { switch effectiveMode { case ModeSelfConsumption, ModePeakShaving: @@ -2959,6 +2966,7 @@ func ComputePVCurtail(state *State, store *telemetry.Store) []CurtailTarget { } now := state.now() + var plannedCap *SlotDirective // Operator-installed manual hold takes precedence over the planner // directive. Driver-scoped → cap only that driver. Site-aggregate @@ -2982,7 +2990,7 @@ func ComputePVCurtail(state *State, store *telemetry.Store) []CurtailTarget { // headroom, EVs on PV charging mode). When live absorbable W // covers everything PV can produce, the curtail effectively // suppresses itself. - if dir, ok := state.SlotDirective(now); ok { + if dir, ok := state.SlotDirective(now); ok && PlanningPVDirectiveValid(state, store, dir) { if dir.PVLimitW > 0 { if live, ok := liveCurtailLimitW(state, store); ok { limit = live @@ -2991,6 +2999,10 @@ func ComputePVCurtail(state *State, store *telemetry.Store) []CurtailTarget { // back to the planner's static cap. limit = dir.PVLimitW } + if dir.PVCurtailActive { + limit = dir.PVLimitW + plannedCap = &dir + } } } } @@ -3070,6 +3082,12 @@ func ComputePVCurtail(state *State, store *telemetry.Store) []CurtailTarget { } } + if plannedCap != nil && !holdActive { + if caps, ok := plannedPVCaps(state, store, *plannedCap, limit); ok { + next = caps + } + } + // Release path. A previously-curtailed driver gets an explicit // `curtail_disable` (LimitW: 0) only when one of the following is // true: diff --git a/go/internal/control/pv_plan.go b/go/internal/control/pv_plan.go new file mode 100644 index 00000000..6a7a2fd5 --- /dev/null +++ b/go/internal/control/pv_plan.go @@ -0,0 +1,80 @@ +package control + +import ( + "github.com/srcfl/ftw/go/internal/mpc" + "github.com/srcfl/ftw/go/internal/telemetry" + "math" + "time" +) + +// Aggregate min(PV, cap) requires one verified control domain covering all PV. +// Independent inverter caps need per-source forecasts before they can qualify. +func PlanningPVCurtailment(state *State, store *telemetry.Store, options telemetry.ForecastOptions) mpc.PVCurtailment { + if state == nil || store == nil || state.PVGenerationLimit == nil { + return mpc.PVCurtailment{} + } + if !store.ForecastMeasurement(state.now(), state.SiteMeterDriver, options).PVValid { + return mpc.PVCurtailment{} + } + expected := map[string]bool{} + for _, f := range options.ExpectedFlows { + if f.DerType == telemetry.DerPV { + expected[f.Driver] = true + } + } + if len(expected) != 1 { + return mpc.PVCurtailment{} + } + for id := range expected { + proof := state.PVGenerationLimit(id) + if proof.Driver == id && proof.Valid() && planningPVProofValid(state, store, proof) { + return proof + } + } + return mpc.PVCurtailment{} +} + +func planningPVProofValid(state *State, store *telemetry.Store, proof mpc.PVCurtailment) bool { + if state == nil || store == nil || !proof.Valid() || state.PVGenerationLimit == nil || !state.SupportsPVCurtail[proof.Driver] { + return false + } + return PVGenerationProofValid(store, state.now(), proof, state.PVGenerationLimit) +} + +// PVGenerationProofValid takes a bounded registry lookup and no control-state +// lock. Service uses it after taking one plan/params snapshot, for all consumers. +func PVGenerationProofValid(store *telemetry.Store, now time.Time, proof mpc.PVCurtailment, lookup func(string) mpc.PVCurtailment) bool { + if store == nil || !proof.Valid() || lookup == nil || lookup(proof.Driver) != proof { + return false + } + sources := store.ReadingsByType(telemetry.DerPV) + if len(sources) != 1 || sources[0].Driver != proof.Driver { + return false + } + r := sources[0] + h := store.DriverHealth(proof.Driver) + return h != nil && h.IsOnline() && !r.UpdatedAt.After(now) && now.Sub(r.UpdatedAt) <= 90*time.Second && !math.IsNaN(r.RawW) && !math.IsInf(r.RawW, 0) +} + +// Check the proof before exposing any energy directive from a plan that used +// PV control. Losing the driver/config/generation also revokes battery/EV use. +func PlanningPVDirectiveValid(state *State, store *telemetry.Store, dir SlotDirective) bool { + if dir.PVCurtailment.Proof == "" { + return !dir.PVCurtailActive + } + return planningPVProofValid(state, store, dir.PVCurtailment) +} + +func plannedPVCaps(state *State, store *telemetry.Store, dir SlotDirective, limit float64) (map[string]float64, bool) { + p := dir.PVCurtailment + if !dir.PVCurtailActive || !planningPVProofValid(state, store, p) || math.IsNaN(limit) || math.IsInf(limit, 0) || limit < p.MinW || limit > p.MaxW { + return nil, false + } + // The worker proposes whole watts. A stronger manual/protective ceiling may + // be fractional; rounding down preserves that ceiling and the safe minimum. + limit = math.Floor(limit + 1e-7) + if limit < p.MinW { + return nil, false + } + return map[string]float64{p.Driver: limit}, true +} diff --git a/go/internal/control/pv_plan_test.go b/go/internal/control/pv_plan_test.go new file mode 100644 index 00000000..3b24fe4d --- /dev/null +++ b/go/internal/control/pv_plan_test.go @@ -0,0 +1,68 @@ +package control + +import ( + "github.com/srcfl/ftw/go/internal/mpc" + "github.com/srcfl/ftw/go/internal/telemetry" + "testing" + "time" +) + +func pvProofState(t *testing.T) (*State, *telemetry.Store, mpc.PVCurtailment, telemetry.ForecastOptions) { + t.Helper() + store := telemetry.NewStore() + emitPV(t, store, "pv", -6000) + emitMeter(t, store, "site", -5500) + s := NewState(0, 0, "site") + s.SupportsPVCurtail = map[string]bool{"pv": true} + proof := mpc.PVCurtailment{Driver: "pv", Proof: "loaded-generation-1", MinW: 2, MaxW: 15000} + s.PVGenerationLimit = func(string) mpc.PVCurtailment { return proof } + options := telemetry.ForecastOptions{ExpectedFlows: []telemetry.ForecastFlow{{Driver: "pv", DerType: telemetry.DerPV}}} + return s, store, proof, options +} + +func TestPlanningPVRequiresLoadedGenerationAndCompleteDomain(t *testing.T) { + s, store, proof, options := pvProofState(t) + if got := PlanningPVCurtailment(s, store, options); got != proof { + t.Fatalf("capability=%+v", got) + } + dir := SlotDirective{PVLimitW: 100, PVCurtailActive: true, PVCurtailment: proof} + s.SlotDirective = stubSlotDirective(dir) + if got := findCurtail(ComputePVCurtail(s, store)); len(got) != 1 || got["pv"] != 100 { + t.Fatalf("cap raised by live headroom: %v", got) + } + s.PVGenerationLimit = nil + if PlanningPVCurtailment(s, store, options).Valid() || PlanningPVDirectiveValid(s, store, dir) { + t.Fatal("config opt-in was treated as command proof") + } + s.PVGenerationLimit = func(string) mpc.PVCurtailment { p := proof; p.Proof = "new-generation"; return p } + if PlanningPVDirectiveValid(s, store, dir) { + t.Fatal("old plan survived driver replacement") + } + s.PVGenerationLimit = func(string) mpc.PVCurtailment { return proof } + s.clock = func() time.Time { return time.Now().Add(2 * time.Minute) } + if PlanningPVDirectiveValid(s, store, dir) { + t.Fatal("stale telemetry kept plan credit") + } + s.clock = nil + emitPV(t, store, "second", -1) + options.ExpectedFlows = append(options.ExpectedFlows, telemetry.ForecastFlow{Driver: "second", DerType: telemetry.DerPV}) + if PlanningPVCurtailment(s, store, options).Valid() || PlanningPVDirectiveValid(s, store, dir) { + t.Fatal("independent domains treated as one aggregate actuator") + } +} + +func TestPlannedPVCapKeepsManualAndProtectiveCeilings(t *testing.T) { + s, store, proof, _ := pvProofState(t) + emitBattery(t, store, "battery", 0, .85) + s.SlotDirective = stubSlotDirective(SlotDirective{PVLimitW: 4000, PVCurtailActive: true, PVCurtailment: proof}) + s.DCLinkProtectionEnabled = true + s.DCLinkProtectionSoCThreshold = .8 + s.DCLinkProtectionMarginW = 1000 + if got := findCurtail(ComputePVCurtail(s, store)); got["pv"] != 1500 { + t.Fatalf("protective cap relaxed: %v", got) + } + s.ManualPVHold = PVManualHold{LimitW: 50, ExpiresAt: time.Now().Add(time.Minute)} + if got := findCurtail(ComputePVCurtail(s, store)); got["pv"] != 50 { + t.Fatalf("manual cap relaxed: %v", got) + } +} diff --git a/go/internal/control/slot_directive.go b/go/internal/control/slot_directive.go index 17a80c21..8d21bdc2 100644 --- a/go/internal/control/slot_directive.go +++ b/go/internal/control/slot_directive.go @@ -2,6 +2,7 @@ package control import ( "github.com/srcfl/ftw/go/internal/mpc" + "maps" ) // SlotDirectiveFromMPC is the plan→EMS bridge. main.go and the site @@ -13,12 +14,15 @@ func SlotDirectiveFromMPC(d mpc.SlotDirective) SlotDirective { SlotStart: d.SlotStart, SlotEnd: d.SlotEnd, BatteryEnergyWh: d.BatteryEnergyWh, + StorageEnergyWh: maps.Clone(d.StorageEnergyWh), SoCTarget: d.SoCTarget, Strategy: string(d.Strategy), PVLimitW: d.PVLimitW, + PVCurtailActive: d.PVCurtailActive, + PVCurtailment: d.PVCurtailment, PlannedGridW: d.GridW, HasPlannedGridW: true, LivePVSurplusSoCCap: d.LivePVSurplusSoCCap, - LoadpointEnergyWh: d.LoadpointEnergyWh, + LoadpointEnergyWh: maps.Clone(d.LoadpointEnergyWh), } } diff --git a/go/internal/control/storage_plan.go b/go/internal/control/storage_plan.go new file mode 100644 index 00000000..12daa899 --- /dev/null +++ b/go/internal/control/storage_plan.go @@ -0,0 +1,109 @@ +package control + +import ( + "maps" + "math" + "time" +) + +type storageSlotDelivery struct { + start, end, tick time.Time + decision string + budget, delivered map[string]float64 +} + +// Keep each physical battery's share after Core has computed a live site +// target. A blocked battery's energy is not reassigned to another battery. +// Manual control and missing plans retain the existing local controller. +func distributePlannedStorages(state *State, bats []batteryInfo, desired float64, manual bool) ([]DispatchTarget, bool) { + if manual || !state.Mode.IsPlannerMode() || state.PlanStale { + state.storageDelivery = storageSlotDelivery{} + return nil, false + } + now := state.now() + dir, ok := plannerSelfDirectiveAt(state, now) + if !ok || len(dir.StorageEnergyWh) < 2 { + state.storageDelivery = storageSlotDelivery{} + return nil, false + } + allIdle := true + for _, wh := range dir.StorageEnergyWh { + allIdle = allIdle && math.Abs(wh) < 1e-6 + } + if allIdle { + state.storageDelivery = storageSlotDelivery{} + return nil, false + } + d := &state.storageDelivery + slotS := dir.SlotEnd.Sub(dir.SlotStart).Seconds() + if slotS <= 0 || now.Before(dir.SlotStart) || !now.Before(dir.SlotEnd) { + return nil, false + } + elapsed := math.Max(0, math.Min(1, now.Sub(dir.SlotStart).Seconds()/slotS)) + reset := !d.start.Equal(dir.SlotStart) || d.tick.IsZero() || now.Sub(d.tick) >= 5*time.Minute + if reset { + *d = storageSlotDelivery{start: dir.SlotStart, delivered: make(map[string]float64)} + // Starting partway through a slot must not demand all its energy in + // the remaining seconds. Replanning already starts from measured SoC. + for id, wh := range dir.StorageEnergyWh { + d.delivered[id] = wh * elapsed + } + } else { + dt := now.Sub(d.tick).Hours() + if dt > 0 { + for _, b := range bats { + if _, exists := d.budget[b.driver]; exists { + d.delivered[b.driver] += b.currentW * dt + } + } + } + if d.decision != dir.DecisionID || !maps.Equal(d.budget, dir.StorageEnergyWh) || !d.end.Equal(dir.SlotEnd) { + for id, wh := range dir.StorageEnergyWh { + actual := d.delivered[id] + if _, exists := d.budget[id]; !exists || wh*d.budget[id] < 0 { + actual = wh * elapsed + } else if wh > 0 { + actual = math.Max(actual, wh*elapsed) + } else if wh < 0 { + actual = math.Min(actual, wh*elapsed) + } + d.delivered[id] = actual + } + } + } + d.end, d.tick, d.decision = dir.SlotEnd, now, dir.DecisionID + d.budget = maps.Clone(dir.StorageEnergyWh) + remainingS := dir.SlotEnd.Sub(now).Seconds() + targets := make([]float64, len(bats)) + var sum float64 + for i, b := range bats { + wh := d.budget[b.driver] + remaining := wh - d.delivered[b.driver] + // Completion cannot reverse a slot's intended direction. + if wh > 0 { + remaining = math.Max(0, remaining) + } else if wh < 0 { + remaining = math.Min(0, remaining) + } else { + remaining = 0 + } + w := 0.0 + if remainingS > .5 { + w = remaining * 3600 / remainingS + } + if w*desired <= 0 || (w > 0 && b.chargeBlocked) || (w < 0 && b.dischargeBlocked) { + w = 0 + } + targets[i], _ = clampWithSoC(w, b) + sum += targets[i] + } + scale := 0.0 + if sum*desired > 0 { + scale = math.Min(1, math.Abs(desired/sum)) + } + out := make([]DispatchTarget, 0, len(bats)) + for i, b := range bats { + out = append(out, DispatchTarget{Driver: b.driver, TargetW: targets[i] * scale, Clamped: true}) + } + return out, true +} diff --git a/go/internal/control/storage_plan_test.go b/go/internal/control/storage_plan_test.go new file mode 100644 index 00000000..a678483f --- /dev/null +++ b/go/internal/control/storage_plan_test.go @@ -0,0 +1,108 @@ +package control + +import ( + "encoding/json" + "math" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/mpc" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func TestPhysicalStorageAllocationSurvivesDispatch(t *testing.T) { + now := time.Now() + d := mpc.SlotDirective{DecisionID: "plan", SlotStart: now, SlotEnd: now.Add(15 * time.Minute), BatteryEnergyWh: 500, + StorageEnergyWh: map[string]float64{"a": 400, "b": 100}, Strategy: mpc.ModeArbitrage, GridW: 2500} + dir := SlotDirectiveFromMPC(d) + d.StorageEnergyWh["a"] = 0 + store := seedStore(500, []struct { + name string + currentW, soc float64 + }{{"a", 0, .5}, {"b", 0, .5}}) + s := newStateWithEnergyDispatch(dir, "ferroamp") + s.clock = func() time.Time { return now } + out := ComputeDispatch(store, s, caps(map[string]float64{"a": 10000, "b": 10000}), 11040) + want := map[string]float64{"a": 1600, "b": 400} + if len(out) != 2 { + t.Fatalf("targets=%+v", out) + } + for _, v := range out { + if math.Abs(v.TargetW-want[v.Driver]) > 1e-6 { + t.Fatalf("targets=%+v", out) + } + } +} + +func TestHeterogeneousStorageSharesReachFinalDispatch(t *testing.T) { + for _, blocked := range []bool{false, true} { + now := time.Now() + dir := SlotDirective{SlotStart: now, SlotEnd: now.Add(15 * time.Minute), BatteryEnergyWh: 500, + StorageEnergyWh: map[string]float64{"a": 25, "b": 475}, PlannedGridW: 2500, HasPlannedGridW: true} + store := seedStore(500, []struct { + name string + currentW, soc float64 + }{{"a", 0, .5}, {"b", 0, .5}}) + if blocked { + soc := .5 + store.Update("a", telemetry.DerBattery, 0, &soc, json.RawMessage(`{"charge_capable":false}`)) + } + s := newStateWithEnergyDispatch(dir, "ferroamp") + s.clock = func() time.Time { return now } + out := ComputeDispatch(store, s, caps(map[string]float64{"a": 1000, "b": 19000}), 11040) + if len(out) != 2 { + t.Fatalf("targets=%+v", out) + } + for _, v := range out { + want := 1900. + if v.Driver == "a" { + want = 100 + if blocked { + want = 0 + } + } + if math.Abs(v.TargetW-want) > 1e-6 { + t.Fatalf("blocked=%v targets=%+v", blocked, out) + } + } + } +} + +func TestPhysicalStorageDeliveryAndSiteClamp(t *testing.T) { + start := time.Now() + now := start + dir := SlotDirective{DecisionID: "first", SlotStart: start, SlotEnd: start.Add(time.Hour), BatteryEnergyWh: 2000, StorageEnergyWh: map[string]float64{"a": 1500, "b": 500}} + s := newStateWithEnergyDispatch(dir, "site") + s.SlotDirective = func(time.Time) (SlotDirective, bool) { return dir, true } + s.clock = func() time.Time { return now } + bats := []batteryInfo{{driver: "a", capacityWh: 10000, soc: .5}, {driver: "b", capacityWh: 10000, soc: .5}} + out, ok := distributePlannedStorages(s, bats, 1000, false) + if !ok || out[0].TargetW != 750 || out[1].TargetW != 250 { + t.Fatalf("site clamp=%+v", out) + } + // Only a really delivered energy; b's budget is not credited from a. + bats[0].currentW = 1500 + now = now.Add(time.Minute) + out, _ = distributePlannedStorages(s, bats, 10000, false) + if math.Abs(out[0].TargetW-1500) > 1e-6 || math.Abs(out[1].TargetW-500*60./59) > 1e-6 { + t.Fatalf("measured delivery=%+v", out) + } + bats[1].chargeBlocked = true + out, _ = distributePlannedStorages(s, bats, 10000, false) + if out[0].TargetW != 1500 || out[1].TargetW != 0 { + t.Fatalf("reassigned blocked share=%+v", out) + } + dir.DecisionID = "smaller" + dir.StorageEnergyWh = map[string]float64{"a": 10, "b": 0} + out, _ = distributePlannedStorages(s, bats, 10000, false) + if out[0].TargetW != 0 || out[1].TargetW != 0 { + t.Fatalf("completed budget reversed=%+v", out) + } + if _, ok := distributePlannedStorages(s, bats, 1000, true); ok { + t.Fatal("planner overrode manual hold") + } + s.PlanStale = true + if _, ok := distributePlannedStorages(s, bats, 1000, false); ok { + t.Fatal("stale plan allocated storage") + } +} diff --git a/go/internal/drivers/lua.go b/go/internal/drivers/lua.go index d60f146a..2a0dcd9e 100644 --- a/go/internal/drivers/lua.go +++ b/go/internal/drivers/lua.go @@ -81,6 +81,12 @@ type LuaDriver struct { mu sync.Mutex L *lua.LState + // Proof has its own short lock; readers never wait behind Lua/network I/O. + pvProofMu sync.RWMutex + pvProof PVGenerationLimit + pvProofEpoch uint64 + loadedSourceSHA256 string + restricted bool initConfig map[string]any // sawModbusRead is true only after a poll that successfully read a @@ -120,7 +126,7 @@ func NewLuaDriverWithPolicy(path string, env *HostEnv, policy *RuntimePolicy) (* if restricted { openRestrictedLibraries(L) } - d := &LuaDriver{Env: env, Path: path, L: L, restricted: restricted} + d := &LuaDriver{Env: env, Path: path, L: L, restricted: restricted, loadedSourceSHA256: fmt.Sprintf("%x", sha256.Sum256(src))} registerHost(L, env) var loadCancel context.CancelFunc if restricted { @@ -201,8 +207,13 @@ func driverDeclaresReadOnlyBattery(L *lua.LState) bool { func (d *LuaDriver) Init(ctx context.Context, config map[string]any) error { d.mu.Lock() defer d.mu.Unlock() + d.clearPVGenerationLimit() d.initConfig = cloneStringAnyMap(config) - return d.callInitLocked(ctx) + if err := d.callInitLocked(ctx); err != nil { + return err + } + d.refreshPVGenerationLimit() + return nil } func (d *LuaDriver) callInitLocked(ctx context.Context) error { @@ -312,6 +323,7 @@ func (d *LuaDriver) notePollModbusActivity() { // before the swap: a failed driver_init keeps the previous state so // default-mode still has its locals. func (d *LuaDriver) reprobeLocked(ctx context.Context) error { + d.clearPVGenerationLimit() src, err := os.ReadFile(d.Path) if err != nil { return fmt.Errorf("read %s: %w", d.Path, err) @@ -344,6 +356,8 @@ func (d *LuaDriver) reprobeLocked(ctx context.Context) error { return err } old.Close() + d.loadedSourceSHA256 = fmt.Sprintf("%x", sha256.Sum256(src)) + d.refreshPVGenerationLimit() d.Env.requiresFreshModbusRead = driverRequiresFreshModbusRead(L, d.Env.Modbus != nil) if driverDeclaresReadOnlyBattery(L) { d.Env.BatteryTelemetryOnly = true @@ -675,6 +689,7 @@ func (d *LuaDriver) Cleanup() { // before closing the state. The no-argument Cleanup method remains for tests // and direct embedders that do not have a lifecycle context. func (d *LuaDriver) CleanupContext(ctx context.Context) { + d.clearPVGenerationLimit() _ = d.call(ctx, "driver_cleanup") d.mu.Lock() d.L.Close() diff --git a/go/internal/drivers/pv_generation_limit.go b/go/internal/drivers/pv_generation_limit.go new file mode 100644 index 00000000..3d1dd169 --- /dev/null +++ b/go/internal/drivers/pv_generation_limit.go @@ -0,0 +1,66 @@ +package drivers + +import ( + "fmt" + "math" + "strconv" +) + +// PVGenerationLimit proves a generation ceiling in integer watts, with a +// configured release ceiling. It does not describe export or inverter AC caps. +type PVGenerationLimit struct { + Token string + MinW, MaxW float64 +} + +func (d *LuaDriver) clearPVGenerationLimit() { + d.pvProofMu.Lock() + defer d.pvProofMu.Unlock() + d.pvProofEpoch++ + d.pvProof = PVGenerationLimit{} +} + +// Caller holds the VM lock after successful init. The digest comes from the +// same bytes passed to DoString, never a second read of the driver path. +func (d *LuaDriver) refreshPVGenerationLimit() { + d.pvProofMu.Lock() + defer d.pvProofMu.Unlock() + const reviewedFerroamp = "c04d137d595ba50b8c6178c82d917b115dbe9a7cbd2cf671ef2660e871f96de3" + if d.loadedSourceSHA256 != reviewedFerroamp || d.Env.MQTT == nil || d.initConfig["_supports_pv_curtail"] != true { + return + } + w, err := strconv.ParseFloat(fmt.Sprint(d.initConfig["pplim_release_w"]), 64) + if err != nil || math.IsNaN(w) || math.IsInf(w, 0) || w < 2 || w > math.MaxInt32 { + return + } + w = math.Floor(w) + d.pvProof = PVGenerationLimit{Token: fmt.Sprintf("%s/%d/%.0f", reviewedFerroamp, d.pvProofEpoch, w), MinW: 2, MaxW: w} +} + +func (d *LuaDriver) PVGenerationLimit() PVGenerationLimit { + d.pvProofMu.RLock() + defer d.pvProofMu.RUnlock() + return d.pvProof +} + +func (r *Registry) PVGenerationLimit(name string) PVGenerationLimit { + r.mu.Lock() + defer r.mu.Unlock() + rd := r.rec[name] + if rd == nil || rd.cfg.Disabled || rd.cfg.ObserveOnly || rd.cfg.BatteryTelemetryOnly || !rd.cfg.SupportsPVCurtail { + return PVGenerationLimit{} + } + s := rd.controlStatus() + if s.Blocked || s.RecoveryPending { + return PVGenerationLimit{} + } + l, ok := rd.driver.(*luaRuntime) + if !ok { + return PVGenerationLimit{} + } + proof := l.PVGenerationLimit() + if proof.Token != "" { + proof.Token = fmt.Sprintf("%d/%s", s.Generation, proof.Token) + } + return proof +} diff --git a/go/internal/drivers/pv_generation_limit_test.go b/go/internal/drivers/pv_generation_limit_test.go new file mode 100644 index 00000000..a5d6b44d --- /dev/null +++ b/go/internal/drivers/pv_generation_limit_test.go @@ -0,0 +1,169 @@ +package drivers + +import ( + "context" + "encoding/json" + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/control" + "github.com/srcfl/ftw/go/internal/mpc" + "github.com/srcfl/ftw/go/internal/telemetry" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestPlannerPVThroughLoadedFerroampAndRelease(t *testing.T) { + tel := telemetry.NewStore() + mqtt := &fakeMQTT{} + d := newFerroampDriverWithConfig(t, tel, mqtt, map[string]any{"pplim_release_w": 15000, "_supports_pv_curtail": true}) + reg := NewRegistry(tel) + reg.rec["ferroamp"] = &runningDriver{driver: &luaRuntime{d}, cfg: config.Driver{SupportsPVCurtail: true}, generation: 7} + proof := reg.PVGenerationLimit("ferroamp") + if proof.Token == "" || proof.MinW != 2 || proof.MaxW != 15000 { + t.Fatalf("proof=%+v", proof) + } + tel.DriverHealthMut("ferroamp").RecordSuccess() + tel.Update("ferroamp", telemetry.DerPV, -6000, nil, nil) + tel.Update("ferroamp", telemetry.DerMeter, -5500, nil, nil) + state := control.NewState(0, 0, "ferroamp") + state.SupportsPVCurtail = map[string]bool{"ferroamp": true} + state.PVGenerationLimit = func(name string) mpc.PVCurtailment { + p := reg.PVGenerationLimit(name) + return mpc.PVCurtailment{Driver: name, Proof: p.Token, MinW: p.MinW, MaxW: p.MaxW} + } + capability := control.PlanningPVCurtailment(state, tel, telemetry.ForecastOptions{ExpectedFlows: []telemetry.ForecastFlow{{Driver: "ferroamp", DerType: telemetry.DerPV}}}) + if !capability.Valid() { + t.Fatalf("missing Core capability: %+v", capability) + } + dir := control.SlotDirectiveFromMPC(mpc.SlotDirective{PVLimitW: 1234, PVCurtailActive: true, PVCurtailment: capability}) + state.SlotDirective = func(time.Time) (control.SlotDirective, bool) { return dir, true } + send := func(targets []control.CurtailTarget) { + t.Helper() + if len(targets) != 1 { + t.Fatalf("targets=%+v", targets) + } + cmd := map[string]any{"action": "curtail", "power_w": targets[0].LimitW} + if targets[0].LimitW == 0 { + cmd["action"] = "curtail_disable" + } + b, _ := json.Marshal(cmd) + if err := d.Command(context.Background(), b); err != nil { + t.Fatal(err) + } + } + mark := len(mqtt.Published()) + send(control.ComputePVCurtail(state, tel)) + if p := publishedSinceMark(mqtt, mark); len(p) != 1 || !strings.Contains(p[0], `"pplim","arg":1234`) { + t.Fatalf("wrong executed generation cap: %v", p) + } + state.SlotDirective = func(time.Time) (control.SlotDirective, bool) { return control.SlotDirective{}, false } + mark = len(mqtt.Published()) + send(control.ComputePVCurtail(state, tel)) + if p := publishedSinceMark(mqtt, mark); len(p) != 1 || !strings.Contains(p[0], `"pplim","arg":15000`) { + t.Fatalf("wrong release: %v", p) + } + reg.rec["ferroamp"].generation++ + if control.PlanningPVDirectiveValid(state, tel, dir) { + t.Fatal("old plan survived registry replacement") + } +} + +func TestPVProofUsesLoadedBytesAndEffectiveInit(t *testing.T) { + mqtt := &fakeMQTT{} + d := newFerroampDriverWithConfig(t, telemetry.NewStore(), mqtt, map[string]any{"pplim_release_w": 15000, "_supports_pv_curtail": true}) + before := d.PVGenerationLimit() + original, err := os.ReadFile(d.Path) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "arbitrary-name.lua") + if err := os.WriteFile(path, original, 0600); err != nil { + t.Fatal(err) + } + d.Path = path + if d.PVGenerationLimit() != before { + t.Fatal("path name changed loaded proof") + } + if err := os.WriteFile(path, append(original, []byte("\n-- edited source\n")...), 0600); err != nil { + t.Fatal(err) + } + if d.PVGenerationLimit() != before { + t.Fatal("disk changes were mistaken for loaded bytes") + } + d.mu.Lock() + err = d.reprobeLocked(context.Background()) + d.mu.Unlock() + if err != nil { + t.Fatal(err) + } + if d.PVGenerationLimit().Token != "" { + t.Fatal("edited VM inherited old proof") + } + // The known file with absent, zero, invalid, or unapproved config never qualifies. + for _, cfg := range []map[string]any{nil, {"pplim_release_w": 15000}, {"pplim_release_w": 0, "_supports_pv_curtail": true}, {"pplim_release_w": "invalid", "_supports_pv_curtail": true}} { + q := newFerroampDriverWithConfig(t, telemetry.NewStore(), &fakeMQTT{}, cfg) + if q.PVGenerationLimit().Token != "" { + t.Fatalf("unsupported effective config: %v", cfg) + } + } + q := newFerroampDriverWithConfig(t, telemetry.NewStore(), &fakeMQTT{}, map[string]any{"pplim_release_w": 15000, "_supports_pv_curtail": true}) + old := q.PVGenerationLimit() + if err := q.Init(context.Background(), map[string]any{"pplim_release_w": 12000, "_supports_pv_curtail": true}); err != nil { + t.Fatal(err) + } + if p := q.PVGenerationLimit(); p.Token == old.Token || p.MaxW != 12000 { + t.Fatalf("init proof not replaced: %+v", p) + } +} + +func TestPVRevocationStopsEVAndLegacyPlanConsumers(t *testing.T) { + for _, reason := range []string{"generation", "release", "health", "command fault"} { + t.Run(reason, func(t *testing.T) { + tel := telemetry.NewStore() + d := newFerroampDriverWithConfig(t, tel, &fakeMQTT{}, map[string]any{"pplim_release_w": 15000, "_supports_pv_curtail": true}) + reg := NewRegistry(tel) + reg.rec["pv"] = &runningDriver{driver: &luaRuntime{d}, cfg: config.Driver{SupportsPVCurtail: true}, generation: 7} + tel.DriverHealthMut("pv").RecordSuccess() + tel.Update("pv", telemetry.DerPV, -6000, nil, nil) + lookup := func(name string) mpc.PVCurtailment { + p := reg.PVGenerationLimit(name) + return mpc.PVCurtailment{Driver: name, Proof: p.Token, MinW: p.MinW, MaxW: p.MaxW} + } + proof := lookup("pv") + svc := &mpc.Service{PVExecutionAllowed: func(p mpc.PVCurtailment) bool { return control.PVGenerationProofValid(tel, time.Now(), p, lookup) }} + now := time.Now() + svc.InstallPlan(mpc.Plan{GeneratedAtMs: now.UnixMilli(), Actions: []mpc.Action{{SlotStartMs: now.Add(-time.Minute).UnixMilli(), SlotLenMin: 15, BatteryW: 500, LoadpointPowerW: map[string]float64{"ev": 2000}, PVCurtailActive: true, PVLimitW: 100}}}, mpc.Params{Mode: mpc.ModeArbitrage, PVCurtailment: proof}, "ev") + if dir, ok := svc.SlotDirectiveAt(now); !ok || dir.LoadpointEnergyWh["ev"] != 500 { + t.Fatalf("initial EV directive=%+v %v", dir, ok) + } + if _, _, _, ok := svc.SlotAt(now); !ok { + t.Fatal("initial legacy directive missing") + } + switch reason { + case "generation": + reg.rec["pv"].generation++ + case "release": + if err := d.Init(context.Background(), map[string]any{"pplim_release_w": 12000, "_supports_pv_curtail": true}); err != nil { + t.Fatal(err) + } + case "health": + tel.Update("pv", telemetry.DerPV, 0, nil, nil) + tel.DriverHealthMut("pv").SetOffline() + } + if reason == "command fault" { + tel.DriverHealthMut("pv").SetCommandFault(true, "refused") + } + if _, ok := svc.SlotDirectiveAt(now); ok { + t.Fatal("EV/battery plan kept revoked capability") + } + if _, _, _, ok := svc.SlotAt(now); ok { + t.Fatal("legacy plan kept revoked capability") + } + if snapshot := svc.PlanSnapshot(); !snapshot.Outdated || snapshot.Plan == nil { + t.Fatalf("history/execution distinction lost: %+v", snapshot) + } + }) + } +} diff --git a/go/internal/mpc/core_dp_shadow.go b/go/internal/mpc/core_dp_shadow.go index 8207c369..15c50980 100644 --- a/go/internal/mpc/core_dp_shadow.go +++ b/go/internal/mpc/core_dp_shadow.go @@ -18,6 +18,9 @@ type coreDPShadowRequest struct { // startCoreDPShadow runs at most one bounded comparison, after publication. // Results belong to a decision ID and can never replace the active actions. func (s *Service) startCoreDPShadow(champion Plan, slots []Slot, p Params, reason string, replanAtMs int64) { + if coreDPModelError(p) != nil { + return + } s.mu.Lock() if s.stopping || s.last == nil || s.last.DecisionID != champion.DecisionID { s.mu.Unlock() @@ -103,6 +106,11 @@ func (s *Service) recordCoreDPShadow(champion Plan, slots []Slot, p Params, reas if current { updated := *s.last updated.DPShadow = block + // Preserve existing permission when adding comparison data. A late + // shadow with the same decision ID cannot activate a restored archive. + if s.executionPlan == s.last { + s.executionPlan = &updated + } s.last = &updated } saveDiag, zone := s.SaveDiag, s.Zone diff --git a/go/internal/mpc/diagnose.go b/go/internal/mpc/diagnose.go index 29fa0b60..e5a48ad0 100644 --- a/go/internal/mpc/diagnose.go +++ b/go/internal/mpc/diagnose.go @@ -249,12 +249,10 @@ func buildDiagnostic(plan *Plan, slots []Slot, p Params, zone string, } } -// RestoreDiagnostic promotes a persisted diagnostic snapshot back into -// the active in-memory plan cache. Diagnostics are already the exact -// plan+slot JSON the UI uses for time travel; restoring them avoids a -// restart/update gap where Diagnose can show a valid plan from SQLite -// while dispatch sees nil and falls into missing-plan behaviour until -// the next successful replan. +// RestoreDiagnostic loads a persisted snapshot into the in-memory plan cache. +// Plans with physical device maps or PV control remain visible archives until +// a new solve validates current inputs. Legacy aggregate plans can execute +// while fresh; restore alone never grants a physical plan execution permission. func (s *Service) RestoreDiagnostic(d *Diagnostic, now time.Time, reason string) bool { if s == nil || d == nil || len(d.Slots) == 0 { return false diff --git a/go/internal/mpc/energyplan.go b/go/internal/mpc/energyplan.go index 6b4703cd..0814c7f9 100644 --- a/go/internal/mpc/energyplan.go +++ b/go/internal/mpc/energyplan.go @@ -14,16 +14,29 @@ type EnergyplanOptimizer struct { func NewEnergyplanOptimizer(binary string) (*EnergyplanOptimizer, error) { external, err := NewExternalOptimizer(ExternalOptimizerConfig{ - Command: []string{binary, "--time-limit=500ms"}, - ModuleDir: filepath.Dir(binary), Timeout: 2 * time.Second, + Command: []string{binary, "--time-limit=5s"}, + ModuleDir: filepath.Dir(binary), Timeout: 7 * time.Second, IdleTimeout: 2 * time.Minute, }) if err != nil { return nil, err } + external.timeBudget = energyplanTimeBudget return &EnergyplanOptimizer{ExternalOptimizer: external}, nil } +func energyplanTimeBudget(slots []Slot, p Params) time.Duration { + batteries := len(p.Storages) + if batteries == 0 && p.CapacityWh > 0 { + batteries = 1 + } + assets := 3*batteries + 2*len(p.activeLoadpoints()) + if len(slots)*assets >= 193*6 || p.PVCurtailment.MinW > 0 || p.PVUncertaintyW > 0 || p.PVRelativeUncertainty > 0 { + return 5 * time.Second + } + return 500 * time.Millisecond +} + func (o *EnergyplanOptimizer) Optimize(ctx context.Context, slots []Slot, p Params) (Plan, error) { // Service has already applied the risk margin to these slots. Do not // construct a second scenario model for this deterministic solver. diff --git a/go/internal/mpc/energyplan_fault_test.go b/go/internal/mpc/energyplan_fault_test.go index 82cf698a..8c446226 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.2.2" { + if err != nil || health.Version != "0.3.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 cd5aa599..9d3c2fb0 100644 --- a/go/internal/mpc/energyplan_test.go +++ b/go/internal/mpc/energyplan_test.go @@ -60,7 +60,7 @@ func TestNativeEnergyplanDownsideAndAsyncShadow(t *testing.T) { svc := shadowTestService(t) svc.Optimizer = &EnergyplanOptimizer{ExternalOptimizer: o} info, err := svc.Optimizer.(*EnergyplanOptimizer).Health(context.Background()) - if err != nil || info.Name != "ftw-solver" || info.Version != "0.2.2" { + if err != nil || info.Name != "ftw-solver" || info.Version != "0.3.0" { t.Fatalf("bundled worker health: %+v %v", info, err) } start := time.Now().UTC().Truncate(time.Hour) @@ -230,3 +230,49 @@ func TestCoreDPShadowCancellationPreservesPreviousComparison(t *testing.T) { t.Fatal("cancellation replaced a comparison with rejection") } } + +func TestNativeEnergyplanUsesBoundedFleetBudget(t *testing.T) { + template := nativeWorker(t, time.Second) + defer template.Close() + engine, err := NewEnergyplanOptimizer(template.cfg.Command[0]) + if err != nil { + t.Fatal(err) + } + defer engine.Close() + slots, params := topologyFixture(2, 2) + first := slots[0] + slots = make([]Slot, 193) + for i := range slots { + slots[i] = first + slots[i].StartMs = first.StartMs + int64(i*first.LenMin)*60000 + } + plan, err := engine.Optimize(context.Background(), slots, params) + if err != nil { + t.Fatal(err) + } + var request externalRequest + if err := json.Unmarshal(plan.OptimizerInput, &request); err != nil { + t.Fatal(err) + } + if request.Settings.TimeLimitS != 5 { + t.Fatalf("fleet request has wrong budget: %v", request.Settings.TimeLimitS) + } + if err := ValidatePlan(slots, params, &plan); err != nil { + t.Fatal(err) + } +} + +func TestBatterylessEVBudgetDoesNotInventStorage(t *testing.T) { + slots, p := topologyFixture(0, 2) + horizon := make([]Slot, 193) + for i := range horizon { + horizon[i] = slots[0] + } + if got := energyplanTimeBudget(horizon, p); got != 500*time.Millisecond { + t.Fatalf("batteryless EV budget=%v", got) + } + p.CapacityWh = 20000 + if got := energyplanTimeBudget(horizon, p); got != 5*time.Second { + t.Fatalf("real aggregate battery budget=%v", got) + } +} diff --git a/go/internal/mpc/external_optimizer.go b/go/internal/mpc/external_optimizer.go index b00bd3ea..fe742f5a 100644 --- a/go/internal/mpc/external_optimizer.go +++ b/go/internal/mpc/external_optimizer.go @@ -83,8 +83,9 @@ 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 + cfg ExternalOptimizerConfig + transport OptimizerTransport + timeBudget func([]Slot, Params) time.Duration } func NewExternalOptimizer(cfg ExternalOptimizerConfig) (*ExternalOptimizer, error) { @@ -203,6 +204,8 @@ type externalRequest struct { } type externalSettings struct { + PVCurtailmentMinW *float64 `json:"pv_curtailment_min_w,omitempty"` + PVCurtailmentMaxW *float64 `json:"pv_curtailment_max_w,omitempty"` Mode Mode `json:"mode"` Solver string `json:"solver"` Formulation string `json:"formulation"` @@ -391,6 +394,9 @@ func (o *ExternalOptimizer) optimize(ctx context.Context, slots []Slot, p Params } return Plan{}, fmt.Errorf("optimizer %s: %s", response.Error.Code, response.Error.Message) } + if err := validateExternalAssets(request, response.Plan); err != nil { + return Plan{}, fmt.Errorf("optimizer contract rejected: %w", err) + } plan := response.toPlan(slots, p) plan.OptimizerInput = append(json.RawMessage(nil), payload...) if err := ValidatePlan(slots, p, &plan); err != nil { @@ -422,6 +428,9 @@ func (o *ExternalOptimizer) buildRequest(slots []Slot, p Params) externalRequest FlexLoads: []externalFlexLoad{}, ThermalLoads: []map[string]any{}, } + if o.timeBudget != nil { + req.Settings.TimeLimitS = math.Min(req.Settings.TimeLimitS, o.timeBudget(slots, p).Seconds()) + } for i, slot := range slots { req.Slots[i] = externalSlot{ StartMs: slot.StartMs, LenMin: slot.LenMin, @@ -430,6 +439,12 @@ func (o *ExternalOptimizer) buildRequest(slots []Slot, p Params) externalRequest MaxImportW: slot.Limits.MaxImportW, MaxExportW: slot.Limits.MaxExportW, } } + if p.PVCurtailment.Covers(slots) { + value := p.PVCurtailment.MinW + req.Settings.PVCurtailmentMinW = &value + maxValue := p.PVCurtailment.MaxW + req.Settings.PVCurtailmentMaxW = &maxValue + } if len(p.Storages) > 0 { for _, storage := range p.Storages { req.Storages = append(req.Storages, externalStorage{ @@ -530,7 +545,7 @@ func (r externalResponse) toPlan(slots []Slot, p Params) Plan { } slot := slots[i] action := Action{ - SlotStartMs: slot.StartMs, SlotLenMin: slot.LenMin, + SlotStartMs: candidate.SlotStartMs, SlotLenMin: candidate.SlotLenMin, PriceOre: slot.PriceOre, SpotOre: slot.SpotOre, PVW: slot.PVW, LoadW: slot.LoadW, Confidence: slot.Confidence, BatteryW: candidate.BatteryW, GridW: candidate.GridW, @@ -610,6 +625,9 @@ func ValidatePlan(slots []Slot, p Params, plan *Plan) error { totalCost := 0.0 for i, slot := range slots { a := plan.Actions[i] + if err := validateAssetMaps(p, a); err != nil { + return fmt.Errorf("slot %d: %w", i, err) + } values := []float64{a.BatteryW, a.GridW, a.SoC, a.CostOre, a.LoadpointW, a.LoadpointSoC, a.PVLimitW} for _, value := range values { if math.IsNaN(value) || math.IsInf(value, 0) { @@ -658,6 +676,9 @@ func ValidatePlan(slots []Slot, p Params, plan *Plan) error { return fmt.Errorf("slot %d aggregate battery_w %.3f, want %.3f", i, a.BatteryW, totalPowerW) } soc = totalEnergyWh / p.CapacityWh + } else if p.CapacityWh == 0 { + // No storage is a real site topology. Its energy and power stay zero. + soc = 0 } else { // Go DP publishes an aggregate trajectory. Replay that fleet // as one battery; per-storage maps are required only when present. @@ -710,7 +731,7 @@ func ValidatePlan(slots []Slot, p Params, plan *Plan) error { if a.PVCurtailActive && a.PVLimitW == 0 { return fmt.Errorf("slot %d active zero PV cap cannot be dispatched", i) } - if a.PVLimitW < 0 || (a.PVLimitW > 0 && a.PVLimitW > -slot.PVW+2) { + if a.PVLimitW < 0 || (!a.PVCurtailActive && a.PVLimitW > 0 && a.PVLimitW > -slot.PVW+2) { return fmt.Errorf("slot %d pv_limit_w %.3f exceeds forecast generation %.3f", i, a.PVLimitW, -slot.PVW) } effectivePVW := slot.PVW @@ -738,11 +759,15 @@ func ValidatePlan(slots []Slot, p Params, plan *Plan) error { } } } + totalSurplusOnlyW := 0.0 for lpIdx, lp := range activeLoadpoints { powerW := a.LoadpointPowerW[lp.ID] if len(a.LoadpointPowerW) == 0 && lpIdx == 0 { powerW = a.LoadpointW } + if lp.SurplusOnly { + totalSurplusOnlyW += powerW + } if lp.SurplusOnly && surplusOnlyExceedsHousePV(powerW, slot.LoadW, effectivePVW) { return fmt.Errorf("slot %d surplus-only loadpoint %s exceeds PV leftover after house load", i, lp.ID) } @@ -753,6 +778,9 @@ func ValidatePlan(slots []Slot, p Params, plan *Plan) error { return fmt.Errorf("slot %d battery discharge feeds loadpoint %s", i, lp.ID) } } + if surplusOnlyExceedsHousePV(totalSurplusOnlyW, slot.LoadW, effectivePVW) { + return fmt.Errorf("slot %d surplus-only EVs exceed shared leftover PV", i) + } baseGridW := loadpoint.GridW(slot.LoadW, effectivePVW, 0, totalLoadpointW) if !modeAllows(p.Mode, baseGridW, a.GridW, a.BatteryW) { return fmt.Errorf("slot %d violates mode %s: baseline_grid_w=%.9f grid_w=%.9f battery_w=%.9f", diff --git a/go/internal/mpc/fleet_contract.go b/go/internal/mpc/fleet_contract.go new file mode 100644 index 00000000..3719b87c --- /dev/null +++ b/go/internal/mpc/fleet_contract.go @@ -0,0 +1,122 @@ +package mpc + +import ( + "fmt" + "math" +) + +// Core DP has one aggregate battery state and one EV state. Refuse models it +// cannot replay instead of dropping a second asset when the worker fails. +func coreDPModelError(p Params) error { + if p.CapacityWh == 0 { + return fmt.Errorf("Core DP requires a battery; Energyplan plans this site without storage") + } + if len(p.Storages) > 1 || len(p.activeLoadpoints()) > 1 { + return fmt.Errorf("Core DP cannot retain all %d storages and %d EVs; keeping the previous validated plan", len(p.Storages), len(p.activeLoadpoints())) + } + if requiresStorageMaps(p) { + return fmt.Errorf("Core DP cannot replay the physical storage efficiencies") + } + return nil +} + +func requiresStorageMaps(p Params) bool { + if len(p.Storages) > 1 { + return true + } + for _, b := range p.Storages { + if b.ChargeEfficiency != p.ChargeEfficiency || b.DischargeEfficiency != p.DischargeEfficiency { + return true + } + } + return false +} + +func validateAssetMaps(p Params, a Action) error { + var charging, discharging bool + for _, w := range a.StoragePowerW { + charging = charging || w > 2 + discharging = discharging || w < -2 + } + if charging && discharging { + return fmt.Errorf("opposing physical storage directions cannot be dispatched") + } + if p.CapacityWh == 0 { + if len(p.Storages) != 0 || len(a.StoragePowerW) != 0 || len(a.StorageEnergyWh) != 0 || a.BatteryW != 0 || a.SoC != 0 || p.InitialSoC != 0 { + return fmt.Errorf("site without storage contains battery power or energy") + } + } + if requiresStorageMaps(p) || (len(p.Storages) > 0 && len(a.StoragePowerW) > 0) { + if len(a.StoragePowerW) != len(p.Storages) || len(a.StorageEnergyWh) != len(p.Storages) { + return fmt.Errorf("plan must retain every physical storage") + } + } + loads := p.activeLoadpoints() + if len(loads) > 1 || len(a.LoadpointPowerW) > 0 { + if len(a.LoadpointPowerW) != len(loads) || len(a.LoadpointSoCByID) != len(loads) { + return fmt.Errorf("plan must retain every EV") + } + for _, e := range loads { + _, power := a.LoadpointPowerW[e.ID] + _, energy := a.LoadpointSoCByID[e.ID] + if !power || !energy { + return fmt.Errorf("plan omits EV %q", e.ID) + } + } + } + return nil +} + +// Inspect the wire response before translation can fill defaults or discard +// unknown identities. Core DP keeps its own aggregate and soft-target contract. +func validateExternalAssets(req externalRequest, plan externalPlan) error { + if len(plan.Actions) != len(req.Slots) || plan.HorizonSlots != len(req.Slots) || plan.Mode != Mode(req.Settings.Mode) { + return fmt.Errorf("worker changed horizon or mode") + } + var capacity, initial float64 + for _, b := range req.Storages { + capacity += b.CapacityWh + initial += b.InitialEnergyWh + } + initialSoC := 0.0 + if capacity > 0 { + initialSoC = 100 * initial / capacity + } + if !finite(plan.CapacityWh) || !finite(plan.InitialSoC) || math.Abs(plan.CapacityWh-capacity) > 1 || math.Abs(plan.InitialSoC-initialSoC) > .02 { + return fmt.Errorf("worker changed initial storage state") + } + for i, a := range plan.Actions { + if a.PVCurtailActive { + if req.Settings.PVCurtailmentMinW == nil || req.Settings.PVCurtailmentMaxW == nil || a.PVLimitW > *req.Settings.PVCurtailmentMaxW+1e-6 || !finite(a.PVLimitW) || math.Abs(a.PVLimitW-math.Round(a.PVLimitW)) > 1e-6 || a.PVLimitW+1e-6 < *req.Settings.PVCurtailmentMinW { + return fmt.Errorf("slot %d PV cap lacks executable capability or violates the minimum", i) + } + } else if a.PVLimitW != 0 { + return fmt.Errorf("slot %d worker PV cap needs an active flag", i) + } + if a.SlotStartMs != req.Slots[i].StartMs || a.SlotLenMin != req.Slots[i].LenMin { + return fmt.Errorf("slot %d changed the requested timeline", i) + } + if len(a.StoragePowerW) != len(req.Storages) || len(a.StorageEnergy) != len(req.Storages) || len(a.FlexPowerW) != len(req.FlexLoads) || len(a.FlexEnergyWh) != len(req.FlexLoads) || len(a.ThermalPowerW) != 0 || len(a.ThermalState) != 0 { + return fmt.Errorf("slot %d changed the requested assets", i) + } + for _, b := range req.Storages { + _, power := a.StoragePowerW[b.ID] + _, energy := a.StorageEnergy[b.ID] + if !power || !energy { + return fmt.Errorf("slot %d omits storage %q", i, b.ID) + } + } + for _, e := range req.FlexLoads { + _, power := a.FlexPowerW[e.ID] + wh, energy := a.FlexEnergyWh[e.ID] + if !power || !energy { + return fmt.Errorf("slot %d omits EV %q", i, e.ID) + } + deadline := min(e.TargetSlot, len(req.Slots)-1) + if e.TargetSlot >= 0 && e.TargetEnergyWh > 0 && i >= deadline && (!finite(wh) || wh+1 < e.TargetEnergyWh) { + return fmt.Errorf("slot %d misses EV %q deadline", i, e.ID) + } + } + } + return nil +} diff --git a/go/internal/mpc/fleet_contract_test.go b/go/internal/mpc/fleet_contract_test.go new file mode 100644 index 00000000..7c44081b --- /dev/null +++ b/go/internal/mpc/fleet_contract_test.go @@ -0,0 +1,202 @@ +package mpc + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" +) + +func topologyFixture(nb, ne int) ([]Slot, Params) { + slots, p := externalTestFixture() + p.SoCLevels, p.ActionLevels = 21, 21 + p.CapacityWh = 0 + p.InitialSoC = 0 + p.MaxChargeW = 0 + p.MaxDischargeW = 0 + for i := 0; i < nb; i++ { + p.Storages = append(p.Storages, StorageAssetSpec{ID: fmt.Sprintf("battery-%d", i), CapacityWh: 5000, InitialEnergyWh: 2500, MinEnergyWh: 500, MaxEnergyWh: 4750, + MaxChargeW: 2000, MaxDischargeW: 2000, ChargeEfficiency: .9 + float64(i%2)*.05, DischargeEfficiency: .95 - float64(i%2)*.05}) + p.CapacityWh += 5000 + p.MaxChargeW += 2000 + p.MaxDischargeW += 2000 + } + if nb > 0 { + p.InitialSoC = .5 + } + for i := 0; i < ne; i++ { + p.Loadpoints = append(p.Loadpoints, &LoadpointSpec{ID: fmt.Sprintf("ev-%d", i), CapacityWh: 10000, Levels: 11, SoCMax: 1, InitialSoC: .2, PluggedIn: true, + TargetSoC: .3, TargetSlotIdx: 1, MaxChargeW: 2000, AllowedStepsW: []float64{0, 1000, 2000}, ChargeEfficiency: 1}) + } + if ne > 0 { + p.Loadpoint = p.Loadpoints[0] + } + return slots, p +} + +func TestNativeAllPhysicalTopologies(t *testing.T) { + o := nativeWorker(t, 500*time.Millisecond) + defer o.Close() + for _, counts := range [][2]int{{0, 0}, {0, 1}, {0, 2}, {1, 1}, {2, 0}, {2, 1}, {2, 2}, {4, 3}} { + slots, p := topologyFixture(counts[0], counts[1]) + if err := validatePlanningParams(p); err != nil { + t.Fatal(err) + } + req := o.buildRequest(slots, p) + if len(req.Storages) != counts[0] || len(req.FlexLoads) != counts[1] { + t.Fatalf("invented/duplicated assets: %+v", req) + } + for _, mode := range []Mode{ModeArbitrage, ModeSelfConsumption, ModeCheapCharge, ModePassiveArbitrage} { + p.Mode = mode + plan, err := o.Optimize(context.Background(), slots, p) + if err != nil { + t.Fatalf("%v %s: %v", counts, mode, err) + } + if err = ValidatePlan(slots, p, &plan); err != nil { + t.Fatal(err) + } + for _, a := range plan.Actions { + if len(a.StoragePowerW) != counts[0] || len(a.LoadpointPowerW) != counts[1] { + t.Fatalf("lost assets: %+v", a) + } + } + } + } +} + +func TestNativeExternalBoundaryRejectsLostIdentitiesAndTimeline(t *testing.T) { + o := nativeWorker(t, 500*time.Millisecond) + defer o.Close() + slots, p := topologyFixture(2, 2) + req := o.buildRequest(slots, p) + encoded, _ := json.Marshal(req) + raw, err := o.transport.RoundTrip(context.Background(), encoded) + if err != nil { + t.Fatal(err) + } + var original externalResponse + if err = json.Unmarshal(raw, &original); err != nil || !original.OK { + t.Fatalf("response=%s err=%v", raw, err) + } + for _, tc := range []struct { + name string + mutate func(*externalPlan) + }{ + {"lost batteries", func(p *externalPlan) { p.Actions[0].StoragePowerW = nil; p.Actions[0].StorageEnergy = nil }}, + {"lost EV", func(p *externalPlan) { delete(p.Actions[0].FlexPowerW, "ev-1") }}, + {"extra EV", func(p *externalPlan) { p.Actions[0].FlexPowerW["ghost"] = 0 }}, + {"wrong time", func(p *externalPlan) { p.Actions[0].SlotStartMs++ }}, + {"wrong length", func(p *externalPlan) { p.Actions[0].SlotLenMin = 15 }}, + {"extra action", func(p *externalPlan) { p.Actions = append(p.Actions, p.Actions[0]) }}, + {"missed deadline", func(p *externalPlan) { p.Actions[1].FlexEnergyWh["ev-1"] = 2000 }}, + {"PV without capability", func(p *externalPlan) { p.Actions[0].PVCurtailActive = true; p.Actions[0].PVLimitW = 100 }}, + } { + t.Run(tc.name, func(t *testing.T) { + var r externalResponse + json.Unmarshal(raw, &r) + tc.mutate(&r.Plan) + if err := validateExternalAssets(req, r.Plan); err == nil { + t.Fatal("invalid worker contract accepted") + } + }) + } + // The raw response gate checks the driver ceiling before translation. + minPV, maxPV := 2.0, 15000.0 + req.Settings.PVCurtailmentMinW, req.Settings.PVCurtailmentMaxW = &minPV, &maxPV + for _, capW := range []float64{16000, 2.5} { + var r externalResponse + json.Unmarshal(raw, &r) + r.Plan.Actions[0].PVCurtailActive, r.Plan.Actions[0].PVLimitW = true, capW + if err := validateExternalAssets(req, r.Plan); err == nil { + t.Fatalf("unexecutable PV cap %v accepted", capW) + } + } + original.Plan.Actions[0].SlotStartMs++ + translated := original.toPlan(slots, p) + if err := ValidatePlan(slots, p, &translated); err == nil { + t.Fatal("translation hid a wrong timeline") + } +} + +func TestNativeFleetFailureCannotDropAssetsIntoDP(t *testing.T) { + for _, fault := range []string{"timeout", "invalid_plan"} { + t.Run(fault, func(t *testing.T) { + o := nativeWorker(t, 500*time.Millisecond) + defer o.Close() + wrapped := &EnergyplanOptimizer{ExternalOptimizer: o} + svc := shadowTestService(t) + _, p := topologyFixture(2, 2) + svc.Defaults = p + svc.Optimizer = wrapped + // This service has a single one-hour slot, so both targets fit it. + svc.Loadpoints = func(int) []*LoadpointSpec { return p.Loadpoints } + accepted := svc.Replan(context.Background()) + if accepted == nil { + t.Fatal("no baseline fleet plan") + } + before, _ := json.Marshal(accepted) + o.cfg.Timeout = 25 * time.Millisecond + o.transport = &energyplanFaultTransport{OptimizerTransport: o.transport, fault: fault} + if got := svc.Replan(context.Background()); got != accepted { + t.Fatal("fallback replaced a fleet plan") + } + after, _ := json.Marshal(svc.Latest()) + if string(before) != string(after) { + t.Fatal("failed fallback altered accepted plan") + } + if _, ok := svc.SlotDirectiveAt(time.Now()); ok { + t.Fatal("failed replacement still claims current plan") + } + }) + } +} + +func TestSharedPVAndStorageMapValidation(t *testing.T) { + slots, p := topologyFixture(0, 2) + slots = slots[:1] + slots[0].PVW = -3500 + slots[0].LoadW = 500 + for _, e := range p.Loadpoints { + e.SurplusOnly = true + } + a := Action{SlotStartMs: slots[0].StartMs, SlotLenMin: 60, GridW: 1000, CostOre: 20, LoadpointPowerW: map[string]float64{"ev-0": 2000, "ev-1": 2000}, LoadpointSoCByID: map[string]float64{"ev-0": .4, "ev-1": .4}} + plan := Plan{Actions: []Action{a}, TotalCostOre: 20} + if err := ValidatePlan(slots, p, &plan); err == nil { + t.Fatal("two EVs consumed the same PV surplus") + } + _, p = topologyFixture(2, 0) + if err := validateAssetMaps(p, Action{}); err == nil { + t.Fatal("physical storages disappeared") + } +} + +func TestPVCurtailmentProofCannotSurviveDiagnosticRestore(t *testing.T) { + p := PVCurtailment{Driver: "pv", Proof: "runtime-only", MinW: 2, MaxW: 15000} + raw, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + var restored PVCurtailment + if err := json.Unmarshal(raw, &restored); err != nil { + t.Fatal(err) + } + if restored.Valid() || restored.Proof != "" { + t.Fatal("stored diagnostics granted a new process a control capability") + } +} + +func TestRestoredPVPlanWithoutCapabilityCannotExecute(t *testing.T) { + now := time.Now() + s := &Service{} + s.InstallPlan(Plan{GeneratedAtMs: now.UnixMilli(), Actions: []Action{{SlotStartMs: now.Add(-time.Minute).UnixMilli(), SlotLenMin: 15, PVCurtailActive: true, PVLimitW: 100}}}, Params{Mode: ModeArbitrage}, "") + if _, ok := s.SlotDirectiveAt(now); ok { + t.Fatal("missing proof permitted EV/battery execution") + } + if _, _, _, ok := s.SlotAt(now); ok { + t.Fatal("missing proof permitted legacy execution") + } + if !s.PlanSnapshot().Outdated { + t.Fatal("missing proof was shown as executable") + } +} diff --git a/go/internal/mpc/loadpoint_planwindows_test.go b/go/internal/mpc/loadpoint_planwindows_test.go index d08dd717..67d8b11f 100644 --- a/go/internal/mpc/loadpoint_planwindows_test.go +++ b/go/internal/mpc/loadpoint_planwindows_test.go @@ -30,7 +30,8 @@ func TestLoadpointPlanWindowsMergesContiguousSlots(t *testing.T) { now := time.Now().UTC().Truncate(15 * time.Minute) start := now.Add(-30 * time.Minute) // Slots: [past 11 kW] [past 0] [current 4 kW] [4 kW] [0] [11 kW] - svc := &Service{last: planWithActions(start, []float64{11000, 0, 4000, 4000, 0, 11000})} + svc := &Service{} + svc.InstallPlan(*planWithActions(start, []float64{11000, 0, 4000, 4000, 0, 11000}), Params{}, "garage") windows, totalWh := svc.LoadpointPlanWindows("garage", now.Add(1*time.Minute), 1) if len(windows) != 1 { @@ -69,7 +70,8 @@ func TestLoadpointPlanWindowsLegacySingleLP(t *testing.T) { p := &Plan{GeneratedAtMs: time.Now().UnixMilli(), Actions: []Action{ {SlotStartMs: now.UnixMilli(), SlotLenMin: 15, LoadpointW: 6000}, }} - svc := &Service{last: p, lastLoadpointID: "carport"} + svc := &Service{} + svc.InstallPlan(*p, Params{}, "carport") windows, totalWh := svc.LoadpointPlanWindows("carport", now, 0) if len(windows) != 1 || totalWh != 1500 { @@ -87,7 +89,8 @@ func TestLoadpointPlanWindowsStalePlan(t *testing.T) { now := time.Now().UTC().Truncate(15 * time.Minute) p := planWithActions(now, []float64{4000}) p.GeneratedAtMs = time.Now().Add(-MaxPlanAge - time.Minute).UnixMilli() - svc := &Service{last: p} + svc := &Service{} + svc.InstallPlan(*p, Params{}, "garage") if windows, totalWh := svc.LoadpointPlanWindows("garage", now, 0); len(windows) != 0 || totalWh != 0 { t.Fatalf("stale plan: want nothing, got %+v / %v", windows, totalWh) } diff --git a/go/internal/mpc/mpc.go b/go/internal/mpc/mpc.go index 6e6596b5..9b0d6443 100644 --- a/go/internal/mpc/mpc.go +++ b/go/internal/mpc/mpc.go @@ -140,7 +140,8 @@ type Slot struct { // Params bounds the optimization. All fields are required. type Params struct { - Mode Mode + PVCurtailment PVCurtailment + Mode Mode // SoC grid SoCLevels int // e.g. 41 (2.5% steps) @@ -442,6 +443,9 @@ type SolverInfo struct { SoCLevels int `json:"soc_levels,omitempty"` ActionLevels int `json:"action_levels,omitempty"` ObjectiveOre float64 `json:"objective_ore,omitempty"` + LowerBoundOre *float64 `json:"lower_bound_ore,omitempty"` + AbsoluteGapOre *float64 `json:"absolute_gap_ore,omitempty"` + SearchNodes int64 `json:"search_nodes,omitempty"` ServiceSlack float64 `json:"service_slack,omitempty"` SolveMs float64 `json:"solve_ms,omitempty"` PrepareMs float64 `json:"prepare_ms,omitempty"` @@ -603,6 +607,9 @@ func OptimizeContext(ctx context.Context, slots []Slot, p Params) (Plan, error) if err := ctx.Err(); err != nil { return Plan{}, err } + if err := coreDPModelError(p); err != nil { + return Plan{}, err + } now := time.Now().UnixMilli() slots = sanitizeOptimizeSlots(slots) if len(slots) == 0 || p.CapacityWh <= 0 { diff --git a/go/internal/mpc/native_optimizer_test.go b/go/internal/mpc/native_optimizer_test.go index 0c1994f9..dbc43db5 100644 --- a/go/internal/mpc/native_optimizer_test.go +++ b/go/internal/mpc/native_optimizer_test.go @@ -65,8 +65,8 @@ func TestNativeProcessCoreContract(t *testing.T) { } } p.PVForecastSafetyK, p.PVUncertaintyW = 1, 300 - if _, err := o.Optimize(context.Background(), slots, p); err == nil { - t.Fatal("worker accepted unsupported scenarios") + if plan, err := o.Optimize(context.Background(), slots, p); err != nil || plan.Solver.ScenarioCount != 3 { + t.Fatalf("scenario model failed: plan=%+v err=%v", plan.Solver, err) } p.PVForecastSafetyK = 0 if _, err := o.Optimize(context.Background(), slots, p); err != nil { diff --git a/go/internal/mpc/params_validation.go b/go/internal/mpc/params_validation.go index 8643f14d..d1e3c361 100644 --- a/go/internal/mpc/params_validation.go +++ b/go/internal/mpc/params_validation.go @@ -83,6 +83,9 @@ func validateBatteryFleetMembers(fleet []BatteryFleetMember) error { // from an out-of-band start plans from energy the site does not have (or // discards energy it does have). func planningParamsRequireRecovery(p Params) bool { + if p.CapacityWh == 0 && len(p.Storages) == 0 { + return false + } if p.InitialSoC < p.SoCMin || p.InitialSoC > p.SoCMax { return true } @@ -159,6 +162,12 @@ func clampParamsIntoOperatingBand(p *Params) (clamped, ok bool) { // been applied, so an invalid value cannot reach either the external optimizer // or the Go fallback with different defaulting or failure semantics. func validatePlanningParams(p Params) error { + if err := requireNonNegativePlanningValue("pv_curtailment_min_w", p.PVCurtailment.MinW); err != nil { + return err + } + if p.PVCurtailment.MinW > 0 && !p.PVCurtailment.Valid() { + return fmt.Errorf("pv_curtailment requires identified drivers") + } switch p.Mode { case ModeSelfConsumption, ModeCheapCharge, ModePassiveArbitrage, ModeArbitrage: default: @@ -171,9 +180,12 @@ func validatePlanningParams(p Params) error { if p.ActionLevels < 3 { return fmt.Errorf("action_levels must be at least 3, got %d", p.ActionLevels) } - if err := requirePositivePlanningValue("capacity_wh", p.CapacityWh); err != nil { + if err := requireNonNegativePlanningValue("capacity_wh", p.CapacityWh); err != nil { return err } + if p.CapacityWh == 0 && (len(p.Storages) != 0 || p.MaxChargeW != 0 || p.MaxDischargeW != 0 || p.InitialSoC != 0) { + return fmt.Errorf("zero capacity_wh requires no physical storage, battery power or initial energy") + } if !finite(p.SoCMin) || !finite(p.SoCMax) || p.SoCMin < 0 || p.SoCMin >= p.SoCMax || p.SoCMax > 1 { return fmt.Errorf("soc bounds must satisfy 0 <= min < max <= 1, got %.6g..%.6g", @@ -290,11 +302,6 @@ func validateStorageSpecs(p Params, assetIDs map[string]string) error { if err := requirePlanningEfficiency(field+".discharge_efficiency", storage.DischargeEfficiency, false); err != nil { return err } - if !planningValuesEqual(storage.ChargeEfficiency, p.ChargeEfficiency) || - !planningValuesEqual(storage.DischargeEfficiency, p.DischargeEfficiency) { - return fmt.Errorf("%s efficiencies must match aggregate fallback efficiencies", field) - } - totalCapacityWh += storage.CapacityWh totalInitialWh += storage.InitialEnergyWh totalMinWh += storage.MinEnergyWh diff --git a/go/internal/mpc/params_validation_test.go b/go/internal/mpc/params_validation_test.go index b16ab8b3..b6adda8c 100644 --- a/go/internal/mpc/params_validation_test.go +++ b/go/internal/mpc/params_validation_test.go @@ -261,7 +261,6 @@ func TestValidatePlanningParamsRejectsInvalidStoragePhysics(t *testing.T) { {"zero efficiency", ".charge_efficiency", func(p *Params) { p.Storages[0].ChargeEfficiency = 0 }}, {"nan efficiency", ".charge_efficiency", func(p *Params) { p.Storages[0].ChargeEfficiency = math.NaN() }}, {"high efficiency", ".discharge_efficiency", func(p *Params) { p.Storages[0].DischargeEfficiency = 1.01 }}, - {"different fallback efficiency", "fallback efficiencies", func(p *Params) { p.Storages[0].ChargeEfficiency = 0.9 }}, {"capacity aggregate mismatch", "aggregate capacity", func(p *Params) { p.Storages[0].CapacityWh += 10 }}, {"initial aggregate mismatch", "aggregate initial", func(p *Params) { p.Storages[0].InitialEnergyWh += 10 }}, {"minimum aggregate mismatch", "aggregate min", func(p *Params) { p.Storages[0].MinEnergyWh += 10 }}, @@ -435,8 +434,8 @@ func (o *physicsGateRecoveryOptimizer) Optimize(_ context.Context, slots []Slot, GeneratedAtMs: time.Now().UnixMilli(), Mode: p.Mode, HorizonSlots: len(slots), CapacityWh: p.CapacityWh, InitialSoC: p.InitialSoC, - Actions: make([]Action, len(slots)), - Solver: &SolverInfo{Engine: "test", Backend: "recovery", Status: "optimal"}, + Actions: make([]Action, len(slots)), + Solver: &SolverInfo{Engine: "test", Backend: "recovery", Status: "optimal"}, } for i, slot := range slots { gridW := slot.LoadW + slot.PVW @@ -447,6 +446,14 @@ func (o *physicsGateRecoveryOptimizer) Optimize(_ context.Context, slots []Slot, PVW: slot.PVW, LoadW: slot.LoadW, Confidence: slot.Confidence, GridW: gridW, SoC: p.InitialSoC, CostOre: cost, } + if len(p.Storages) > 0 { + plan.Actions[i].StoragePowerW = make(map[string]float64) + plan.Actions[i].StorageEnergyWh = make(map[string]float64) + for _, b := range p.Storages { + plan.Actions[i].StoragePowerW[b.ID] = 0 + plan.Actions[i].StorageEnergyWh[b.ID] = b.InitialEnergyWh + } + } plan.TotalCostOre += cost } return plan, nil diff --git a/go/internal/mpc/physical_restore_test.go b/go/internal/mpc/physical_restore_test.go new file mode 100644 index 00000000..8035f242 --- /dev/null +++ b/go/internal/mpc/physical_restore_test.go @@ -0,0 +1,154 @@ +package mpc + +import ( + "context" + "encoding/json" + "testing" + "time" +) + +func physicalRestoreFixture(t *testing.T, batteries, evs int, pvSlot int) (Plan, Params, *Diagnostic, time.Time) { + t.Helper() + o := nativeWorker(t, 500*time.Millisecond) + defer o.Close() + slots, p := topologyFixture(batteries, evs) + now := time.Now() + start := now.Add(-time.Minute).Truncate(time.Minute) + for i := range slots { + slots[i].StartMs = start.Add(time.Duration(i) * time.Hour).UnixMilli() + } + if pvSlot >= 0 { + p.PVCurtailment = PVCurtailment{Driver: "pv", Proof: "previous-process", MinW: 2, MaxW: 15000} + slots[pvSlot].PVW, slots[pvSlot].SpotOre = -6000, -100 + } + 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) + } + plan.DecisionID = testDecisionID1 + plan.GeneratedAtMs = now.UnixMilli() + d := buildDiagnostic(&plan, slots, p, "SE4", now.UnixMilli(), "review") + raw, err := json.Marshal(d) + if err != nil { + t.Fatal(err) + } + var persisted Diagnostic + if err := json.Unmarshal(raw, &persisted); err != nil { + t.Fatal(err) + } + return plan, p, &persisted, now +} + +func TestNativeRestoredPVPlanCannotRegainExecution(t *testing.T) { + plan, p, persisted, now := physicalRestoreFixture(t, 0, 2, 0) + if !plan.Actions[0].PVCurtailActive || len(plan.Actions[0].LoadpointPowerW) != 2 { + t.Fatalf("fixture did not use PV control and two EVs: %+v", plan.Actions[0]) + } + svc := &Service{PVExecutionAllowed: func(PVCurtailment) bool { return false }} + svc.InstallPlan(plan, p, p.Loadpoint.ID) + if _, ok := svc.SlotDirectiveAt(now); ok { + t.Fatal("fixture did not revoke the original live plan") + } + if !svc.RestoreDiagnostic(persisted, now, "restart") { + return // refusing activation is safe + } + dir, active := svc.SlotDirectiveAt(now) + _, _, _, legacy := svc.SlotAt(now) + if active || legacy || !svc.PlanSnapshot().Outdated { + t.Fatalf("restored PV plan regained execution: active=%v legacy=%v outdated=%v proof=%+v EV budgets=%v", active, legacy, svc.PlanSnapshot().Outdated, dir.PVCurtailment, dir.LoadpointEnergyWh) + } +} + +func TestNativeRestoredFleetRetainsPhysicalBudgets(t *testing.T) { + plan, p, persisted, now := physicalRestoreFixture(t, 2, 0, -1) + svc := &Service{} + svc.InstallPlan(plan, p, "") + before, ok := svc.SlotDirectiveAt(now) + if !ok || len(before.StorageEnergyWh) != 2 { + t.Fatalf("fixture has no fleet budgets: %+v", before) + } + if !svc.RestoreDiagnostic(persisted, now, "restart") { + return // refusing activation is safe + } + after, active := svc.SlotDirectiveAt(now) + if active || svc.Latest() == nil || !svc.PlanSnapshot().Outdated { + t.Fatalf("restored fleet must remain archived until fresh physical inputs: before=%v after=%v aggregate=%g active=%v", before.StorageEnergyWh, after.StorageEnergyWh, after.BatteryEnergyWh, active) + } +} + +func TestNativeRestoreFuturePVDependencyRequiresNewPlan(t *testing.T) { + plan, p, persisted, now := physicalRestoreFixture(t, 0, 2, 1) + if plan.Actions[0].PVCurtailActive || !plan.Actions[1].PVCurtailActive { + t.Fatal("fixture must depend on PV only in a future slot") + } + svc := &Service{PVExecutionAllowed: func(PVCurtailment) bool { return true }} + svc.InstallPlan(plan, p, p.Loadpoint.ID) + if _, ok := svc.SlotDirectiveAt(now); !ok { + t.Fatal("live fixture is not executable") + } + if !svc.RestoreDiagnostic(persisted, now, "restart") { + t.Fatal("archive lost") + } + if svc.Latest() == nil || !svc.PlanSnapshot().Outdated { + t.Fatal("archive/execution states collapsed") + } + if _, ok := svc.SlotDirectiveAt(now); ok { + t.Fatal("future PV dependency regained execution") + } + if _, _, _, ok := svc.SlotAt(now); ok { + t.Fatal("legacy path regained execution") + } + svc.InstallPlan(plan, p, p.Loadpoint.ID) + if _, ok := svc.SlotDirectiveAt(now); !ok { + t.Fatal("new validated plan did not restore execution") + } +} + +func TestNativeShadowPreservesCurrentExecutionButCannotActivateArchive(t *testing.T) { + o := nativeWorker(t, 500*time.Millisecond) + t.Cleanup(func() { o.Close() }) + svc := shadowTestService(t) + svc.Optimizer = &EnergyplanOptimizer{ExternalOptimizer: o} + plan := svc.Replan(context.Background()) + if plan == nil || plan.Solver == nil || plan.Solver.Fallback || len(plan.Actions[0].StoragePowerW) == 0 { + t.Fatalf("fixture needs a native physical plan: %+v", plan) + } + svc.shadowWG.Wait() + if svc.Latest().DPShadow == nil { + t.Fatal("Core DP shadow did not finish") + } + assertExecution := func(want bool) { + t.Helper() + _, active := svc.SlotDirectiveAt(time.Now()) + _, _, _, legacy := svc.SlotAt(time.Now()) + if active != want || legacy != want || svc.PlanSnapshot().Outdated == want { + t.Fatalf("execution=%v legacy=%v outdated=%v, want execution=%v", active, legacy, svc.PlanSnapshot().Outdated, want) + } + } + assertExecution(true) + encoded, err := json.Marshal(svc.Diagnose()) + if err != nil { + t.Fatal(err) + } + var archive Diagnostic + if err := json.Unmarshal(encoded, &archive); err != nil { + t.Fatal(err) + } + if !svc.RestoreDiagnostic(&archive, time.Now(), "restart") { + t.Fatal("archive was not retained") + } + assertExecution(false) + // The same decision ID may finish its shadow after an archive is restored. + // Comparison data must not grant execution to that archive. + svc.recordCoreDPShadow(*plan, nil, Params{}, "late shadow", time.Now().UnixMilli(), &ShadowPlan{}) + assertExecution(false) + fresh := svc.Replan(context.Background()) + svc.shadowWG.Wait() + if fresh == nil || fresh.DecisionID == plan.DecisionID { + t.Fatal("fresh replan did not publish a new decision") + } + assertExecution(true) +} diff --git a/go/internal/mpc/pv_curtailment.go b/go/internal/mpc/pv_curtailment.go new file mode 100644 index 00000000..5279cdb7 --- /dev/null +++ b/go/internal/mpc/pv_curtailment.go @@ -0,0 +1,52 @@ +package mpc + +import "math" + +// PVCurtailment is Core's proof for one control domain covering all site PV. +// The worker sees the executable bounds; proof stays inside Core. +type PVCurtailment struct { + Driver string + // A restored diagnostic cannot grant control permission to a new process. + Proof string `json:"-"` + MinW, MaxW float64 +} + +func (p PVCurtailment) Valid() bool { + return p.Driver != "" && p.Proof != "" && finite(p.MinW) && finite(p.MaxW) && p.MinW >= 2 && p.MinW == math.Ceil(p.MinW) && p.MaxW >= p.MinW +} + +func (p PVCurtailment) Covers(slots []Slot) bool { + if !p.Valid() { + return false + } + for _, s := range slots { + if -s.PVW > p.MaxW { + return false + } + } + return true +} + +func (s *Service) planExecutionAllowed(plan *Plan, p PVCurtailment, currentContract bool) bool { + // Old diagnostic schemas omit physical parameters. Their archived maps + // cannot become a live aggregate directive merely because the fields are + // absent. A new solve must restore the complete contract in this process. + if plan != nil && !currentContract { + for _, a := range plan.Actions { + if len(a.StoragePowerW) > 0 || len(a.LoadpointPowerW) > 0 || a.PVCurtailActive { + return false + } + } + } + if plan != nil && !p.Valid() { + for _, a := range plan.Actions { + if a.PVCurtailActive { + return false + } + } + } + if p.MinW == 0 && p.Proof == "" { + return true + } + return p.Valid() && s.PVExecutionAllowed != nil && s.PVExecutionAllowed(p) +} diff --git a/go/internal/mpc/service.go b/go/internal/mpc/service.go index 673583e6..25ae0478 100644 --- a/go/internal/mpc/service.go +++ b/go/internal/mpc/service.go @@ -76,15 +76,18 @@ type BatteryFleetMember struct { // forecast from the SQLite store, reads current SoC from the telemetry // store, and re-plans on a ticker. The latest plan is cached. type Service struct { - Store *state.Store - Tele *telemetry.Store - Zone string - BaseLoad float64 // baseline household load (W). 0 disables load assumption. - Horizon time.Duration - Interval time.Duration - PV PVPredictor // optional — overrides stored pv_w_estimated - PVResidualCorrect PVResidualCorrector // optional — additive short-horizon bias on top of PV - ForecastSnapshot func(time.Time, []state.ForecastPoint) ForecastInputs + Store *state.Store + Tele *telemetry.Store + Zone string + BaseLoad float64 // baseline household load (W). 0 disables load assumption. + Horizon time.Duration + Interval time.Duration + PV PVPredictor // optional — overrides stored pv_w_estimated + PVResidualCorrect PVResidualCorrector // optional — additive short-horizon bias on top of PV + ForecastSnapshot func(time.Time, []state.ForecastPoint) ForecastInputs + PVCurtailmentProbe func() PVCurtailment + // Set before Start. Called without s.mu; must not acquire the control lock. + PVExecutionAllowed func(PVCurtailment) bool // PVNameplateW accepts a verified AC generation ceiling. A configured // DC rating or learned scale is not a hard limit. Zero disables the cut. PVNameplateW float64 @@ -229,6 +232,7 @@ type Service struct { mu sync.RWMutex last *Plan + executionPlan *Plan // Current physical inputs; preserved by metadata copies. lastSlots []Slot // inputs that went into the most recent Optimize call lastParams Params // params that went into the most recent Optimize call lastLoadpointID string // ID of the loadpoint active in the most recent plan (empty = none) @@ -341,6 +345,9 @@ func (s *Service) UpdateCapacity(totalCapWh, maxChargeW, maxDischargeW float64) s.Defaults.CapacityWh = totalCapWh s.Defaults.MaxChargeW = maxChargeW s.Defaults.MaxDischargeW = maxDischargeW + if totalCapWh == 0 { + s.Defaults.InitialSoC = 0 + } s.mu.Unlock() } @@ -363,6 +370,9 @@ func (s *Service) UpdateBatteryFleet(fleet []BatteryFleetMember, totalCapWh, max s.Defaults.CapacityWh = totalCapWh s.Defaults.MaxChargeW = maxChargeW s.Defaults.MaxDischargeW = maxDischargeW + if totalCapWh == 0 && len(cp) == 0 { + s.Defaults.InitialSoC = 0 + } s.mu.Unlock() } @@ -392,18 +402,25 @@ func (s *Service) PlanSnapshot() PlanSnapshot { return PlanSnapshot{} } s.mu.RLock() - defer s.mu.RUnlock() outdated := s.publishedReplanGeneration != s.latestReplanGeneration - return PlanSnapshot{ + proof := s.lastParams.PVCurtailment + currentContract := s.executionPlan == s.last + out := PlanSnapshot{ Plan: s.last, ReplanAt: s.lastReplanAt, Reason: s.lastReason, Pending: outdated && s.activeReplanCancel != nil, Outdated: outdated, loadpointID: s.lastLoadpointID, } + s.mu.RUnlock() + if !s.planExecutionAllowed(out.Plan, proof, currentContract) { + out.Outdated = true + out.Reason = "Plan requires fresh physical inputs or PV control" + } + return out } // InstallPlan puts a plan in the cache SlotDirectiveAt and Latest read. -// Optimize and RestoreDiagnostic already write that cache after a -// successful solve. Tests that inject a known Action use the same seam +// A successful solve publishes with current inputs; restored diagnostics +// remain archives. Tests that inject a known Action use the same seam // so the charger and battery cannot be given two different mappings of // one slot. // @@ -418,6 +435,7 @@ func (s *Service) InstallPlan(plan Plan, params Params, loadpointID string) { defer s.mu.Unlock() copied := plan s.last = &copied + s.executionPlan = s.last s.lastParams = params s.lastLoadpointID = loadpointID s.lastReplanAt = time.Now() @@ -447,16 +465,19 @@ type SlotDirective struct { DecisionID string SlotStart time.Time SlotEnd time.Time - BatteryEnergyWh float64 // total energy for the slot (site-signed) - SoCTarget float64 // plan's SoC at SlotEnd — used by divergence detector - Strategy Mode // echoed for logging + API + BatteryEnergyWh float64 // total energy for the slot (site-signed) + StorageEnergyWh map[string]float64 // per physical storage, site-signed AC Wh + SoCTarget float64 // plan's SoC at SlotEnd — used by divergence detector + Strategy Mode // echoed for logging + API // PVLimitW is the recommended cap on aggregate PV inverter output // for this slot (W, positive). 0 means "no curtailment". Set by // annotateCurtailment when exporting at zero / negative revenue // would lose money — the dispatch layer divides this across the // site's PV-supporting drivers and sends `curtail` commands. - PVLimitW float64 + PVLimitW float64 + PVCurtailActive bool + PVCurtailment PVCurtailment // GridW is the plan's forecast of slot-average grid power given the // planned battery / load / PV mix (site-signed: + = import). The @@ -502,6 +523,7 @@ func (s *Service) SlotDirectiveAt(now time.Time) (SlotDirective, bool) { // plan under one lock so a concurrent replan cannot mix generations. s.mu.RLock() p := s.last + currentContract := s.executionPlan == p failedReplacement := s.failedReplanGeneration > s.publishedReplanGeneration lpID := s.lastLoadpointID params := s.lastParams @@ -511,7 +533,7 @@ func (s *Service) SlotDirectiveAt(now time.Time) (SlotDirective, bool) { params = s.Defaults } s.mu.RUnlock() - if p == nil || failedReplacement { + if p == nil || failedReplacement || !s.planExecutionAllowed(p, params.PVCurtailment, currentContract) { return SlotDirective{}, false } if time.Since(time.UnixMilli(p.GeneratedAtMs)) > MaxPlanAge { @@ -534,9 +556,17 @@ func (s *Service) SlotDirectiveAt(now time.Time) (SlotDirective, bool) { SoCTarget: a.SoC, Strategy: params.Mode, PVLimitW: a.PVLimitW, + PVCurtailActive: a.PVCurtailActive, + PVCurtailment: params.PVCurtailment, GridW: a.GridW, LivePVSurplusSoCCap: livePVSurplusSoCCap(p.Actions, i, params), } + if len(params.Storages) > 0 && len(a.StoragePowerW) > 0 { + d.StorageEnergyWh = make(map[string]float64, len(a.StoragePowerW)) + for id, w := range a.StoragePowerW { + d.StorageEnergyWh[id] = w * float64(a.SlotLenMin) / 60 + } + } if len(a.LoadpointPowerW) > 0 { d.LoadpointEnergyWh = make(map[string]float64, len(a.LoadpointPowerW)) d.LoadpointSoCTarget = make(map[string]float64, len(a.LoadpointPowerW)) @@ -702,13 +732,14 @@ func (s *Service) SlotAt(now time.Time) (string, float64, string, bool) { } s.mu.RLock() p := s.last + currentContract := s.executionPlan == p failedReplacement := s.failedReplanGeneration > s.publishedReplanGeneration params := s.lastParams if params.Mode == "" { params = s.Defaults } s.mu.RUnlock() - if p == nil || failedReplacement { + if p == nil || failedReplacement || !s.planExecutionAllowed(p, params.PVCurtailment, currentContract) { return "", 0, "", false } if time.Since(time.UnixMilli(p.GeneratedAtMs)) > MaxPlanAge { @@ -1461,6 +1492,9 @@ func (s *Service) runReplan(request replanRequest) *Plan { clampSlotGridLimits(fallbackSlots, fuseMaxW, maxExportW) p := request.params + if s.PVCurtailmentProbe != nil { + p.PVCurtailment = s.PVCurtailmentProbe() + } if p.Mode == "" { p.Mode = ModeSelfConsumption } @@ -1476,8 +1510,10 @@ func (s *Service) runReplan(request replanRequest) *Plan { slog.Warn("mpc: no online battery capacity with SoC — keeping previous plan") return s.Latest() } - } else { + } else if p.CapacityWh > 0 { p.InitialSoC = currentSoC(s.Tele, p.InitialSoC) + } else { + p.InitialSoC = 0 } // Export pricing is per-slot now: pass bonus/fee into Params so @@ -1643,7 +1679,7 @@ func (s *Service) runReplan(request replanRequest) *Plan { return s.canceledReplan(request, "primary-solve") } if err == nil { - if recoveryRequired || downsidePrimary { + if recoveryRequired || downsidePrimary || coreDPModelError(p) != nil { candidate.DPEvaluationShadow = nil candidate.DPShadow = nil candidate.Baselines = nil @@ -1708,6 +1744,10 @@ func (s *Service) runReplan(request replanRequest) *Plan { "soc_max", p.SoCMax) return s.Latest() } + if modelErr := coreDPModelError(p); modelErr != nil { + slog.Error("mpc: primary failed and fallback cannot represent this site; keeping previous plan", "err", err, "fallback", modelErr) + return s.Latest() + } slog.Error("mpc: primary optimizer failed; using Core DP fallback", "err", err) slots = fallbackSlots solveStart := time.Now() @@ -1755,7 +1795,7 @@ func (s *Service) runReplan(request replanRequest) *Plan { // self-consumption mode: the SC baseline is the plan itself, which // makes the badge trivially zero and distracts from the price // signal. For SC runs the UI still has the plan cost on its own. - if p.Mode != ModeSelfConsumption && !recoveryRequired { + if p.Mode != ModeSelfConsumption && !recoveryRequired && coreDPModelError(p) == nil { bl := ComputeBaselines(slots, p) plan.Baselines = &bl } @@ -1792,6 +1832,7 @@ func (s *Service) runReplan(request replanRequest) *Plan { capPlanLoad(&plan, 0, s.LoadMaxW) plan.DecisionID = s.nextDecisionIDLocked() s.last = &plan + s.executionPlan = s.last s.lastSlots = slots s.lastParams = p s.lastLoadpointID = loadpointID diff --git a/go/internal/mpc/unavailable.go b/go/internal/mpc/unavailable.go index 60dd27f8..d896415b 100644 --- a/go/internal/mpc/unavailable.go +++ b/go/internal/mpc/unavailable.go @@ -18,7 +18,7 @@ func UnavailableReason(plannerEnabled bool, priceProvider string, totalCapacityW if priceProvider == "" || priceProvider == "none" { return ReasonNoPriceProvider } - if totalCapacityWh <= 0 { + if totalCapacityWh < 0 { return ReasonNoBatteryCapacity } return "" diff --git a/go/internal/mpc/unavailable_test.go b/go/internal/mpc/unavailable_test.go index 3ce01021..9120edd3 100644 --- a/go/internal/mpc/unavailable_test.go +++ b/go/internal/mpc/unavailable_test.go @@ -14,7 +14,7 @@ func TestUnavailableReasonOrder(t *testing.T) { {"disabled wins even with price and battery", false, "nordpool", 10000, ReasonPlannerDisabled}, {"no provider", true, "", 10000, ReasonNoPriceProvider}, {"provider none", true, "none", 10000, ReasonNoPriceProvider}, - {"no battery", true, "nordpool", 0, ReasonNoBatteryCapacity}, + {"without storage is supported", true, "nordpool", 0, ""}, {"negative capacity is empty pool", true, "nordpool", -1, ReasonNoBatteryCapacity}, {"ready", true, "nordpool", 9600, ""}, } diff --git a/optimizer/native/README.md b/optimizer/native/README.md index 0641fce8..d23733f9 100644 --- a/optimizer/native/README.md +++ b/optimizer/native/README.md @@ -34,9 +34,11 @@ is unset on a supported host. Set `planner.engine: energyplan` to select it explicitly, or `core` to select Core DP. Stable and development builds keep Core as the unset default; Windows has no bundled worker. -Energyplan uses the same downside PV forecast as Core. The worker gets a 500 ms -solve budget and a 2 s transport timeout. After Core validates and publishes a -plan, one Core DP shadow runs with a 10 s limit. Its result appears in +Energyplan uses the same downside PV forecast as Core. Small requests get a +500 ms solve budget; larger fleets and PV-control or risk requests get 5 s. +The transport timeout is 7 s. After Core validates and publishes a plan, one +Core DP shadow runs with a 10 s limit when Core DP can represent the site. +Its result appears in `dp_shadow`, tied to the same decision ID. It cannot change the active actions. Both plans use Core's grid cost model, with a separate terminal-energy-adjusted comparison. A failed comparison reports `rejected`, without a cost verdict. @@ -47,12 +49,21 @@ 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. -Supported requests contain one battery and at most one EV per site, with the -four existing modes, physical limits, negative tariffs and an EV deadline. -Unsupported scenarios, thermal/commercial models and multiple assets return -an error. A time limit can return a feasible plan with a remaining cost gap; -without a feasible candidate it returns a budget error. Core handles errors -through its existing fallback path. +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 +limits are 512 slots, 64 total devices and 32 scenarios; bounded planning may +stop earlier. Thermal, commercial and recourse inputs return explicit errors. +A time limit can return a feasible plan with a remaining cost gap. An unknown +bound is null; without a feasible candidate the worker returns a budget error. +Core DP fallback cannot represent every fleet. In that case Core keeps the +previous plan for diagnosis and withholds execution until a new plan succeeds. + +Core only permits a planned PV generation cap when it verifies the loaded +driver and current telemetry for the site's complete PV control domain. A +restored diagnostic containing physical device maps or PV control stays an +archive until a new plan validates current inputs. It does not restore device +budgets or PV permission from saved JSON alone. `make verify` includes the binary and integration checks. Go integration tests can also use an absolute path supplied in `FTW_NATIVE_SOLVER`. Ordinary Go tests diff --git a/optimizer/native/bundle/THIRD-PARTY-NOTICES.txt b/optimizer/native/bundle/THIRD-PARTY-NOTICES.txt index 6c2fa4f6..b8ffb001 100644 --- a/optimizer/native/bundle/THIRD-PARTY-NOTICES.txt +++ b/optimizer/native/bundle/THIRD-PARTY-NOTICES.txt @@ -2,6 +2,183 @@ Third-party components in the compiled FTW worker. These licenses apply to the named components, not to the proprietary solver. +autocfg 1.5.1 — Apache-2.0 OR MIT + +Copyright (c) 2018 Josh Stone + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +bumpalo 3.20.3 — MIT OR Apache-2.0 + +Copyright (c) 2019 Nick Fitzgerald + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +cfg-if 1.0.4 — MIT OR Apache-2.0 + +Copyright (c) 2014 Alex Crichton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +futures-core 0.3.34 — MIT OR Apache-2.0 + +Copyright (c) 2016 Alex Crichton +Copyright (c) 2017 The Tokio Authors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +futures-task 0.3.34 — MIT OR Apache-2.0 + +Copyright (c) 2016 Alex Crichton +Copyright (c) 2017 The Tokio Authors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +futures-util 0.3.34 — MIT OR Apache-2.0 + +Copyright (c) 2016 Alex Crichton +Copyright (c) 2017 The Tokio Authors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + itoa 1.0.18 — MIT OR Apache-2.0 Permission is hereby granted, free of charge, to any @@ -29,32 +206,617 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -memchr 2.8.3 — Unlicense OR MIT - -The MIT License (MIT) +js-sys 0.3.105 — MIT OR Apache-2.0 + +Copyright (c) 2014 Alex Crichton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +log 0.4.34 — MIT OR Apache-2.0 + +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +matrixmultiply 0.3.11 — MIT/Apache-2.0 + +Copyright (c) 2016 - 2023 Ulrik Sverdrup "bluss" +Copyirhgt (c) 2018 R. Janis Goldschmidt +Copyright (c) 2021 DutchGhost [constparse.rs] + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +memchr 2.8.3 — Unlicense OR MIT + +The MIT License (MIT) + +Copyright (c) 2015 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +microlp 0.6.0 — Apache-2.0 + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +microlp 0.6.0 +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. +The upstream Apache-2.0 license and copyright notices remain in this package. + + +ndarray 0.17.2 — MIT OR Apache-2.0 + +Copyright (c) 2015 - 2021 Ulrik Sverdrup "bluss", + Jim Turner, + and ndarray developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +num-complex 0.4.6 — MIT OR Apache-2.0 + +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +num-integer 0.1.47 — MIT OR Apache-2.0 + +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +num-traits 0.2.19 — MIT OR Apache-2.0 + +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +once_cell 1.21.4 — MIT OR Apache-2.0 + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +pin-project-lite 0.2.17 — Apache-2.0 OR MIT + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +portable-atomic 1.15.0 — Apache-2.0 OR MIT + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +portable-atomic-util 0.2.8 — Apache-2.0 OR MIT + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +proc-macro2 1.0.107 — MIT OR Apache-2.0 + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +quote 1.0.47 — MIT OR Apache-2.0 -Copyright (c) 2015 Andrew Gallant +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +rawpointer 0.2.1 — MIT/Apache-2.0 -proc-macro2 1.0.107 — MIT OR Apache-2.0 +Copyright (c) 2015 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -81,7 +843,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -quote 1.0.47 — MIT OR Apache-2.0 +rustversion 1.0.23 — MIT OR Apache-2.0 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -216,6 +978,87 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +slab 0.4.12 — MIT + +Copyright (c) 2019 Carl Lerche + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +smallvec 1.16.0 — MIT OR Apache-2.0 + +Copyright (c) 2018 The Servo Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +sprs 0.11.5 — MIT OR Apache-2.0 + +Copyright (c) 2015 The sprs Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + syn 3.0.3 — MIT OR Apache-2.0 Permission is hereby granted, free of charge, to any @@ -310,6 +1153,147 @@ dealings in these Data Files or Software without prior written authorization of the copyright holder. +wasm-bindgen 0.2.128 — MIT OR Apache-2.0 + +Copyright (c) 2014 Alex Crichton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +wasm-bindgen-macro 0.2.128 — MIT OR Apache-2.0 + +Copyright (c) 2014 Alex Crichton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +wasm-bindgen-macro-support 0.2.128 — MIT OR Apache-2.0 + +Copyright (c) 2014 Alex Crichton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +wasm-bindgen-shared 0.2.128 — MIT OR Apache-2.0 + +Copyright (c) 2014 Alex Crichton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +web-time 1.1.0 — MIT OR Apache-2.0 + +MIT License + +Copyright (c) 2023 dAxpeDDa + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + zmij 1.0.23 — MIT Permission is hereby granted, free of charge, to any diff --git a/optimizer/native/bundle/ftw-solver-darwin-arm64 b/optimizer/native/bundle/ftw-solver-darwin-arm64 index 46b556c6..6122e027 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 30cd7272..39ae1520 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 41c510c4..453c4194 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 a5ec4a84..bb70c858 100644 --- a/optimizer/native/bundle/manifest.json +++ b/optimizer/native/bundle/manifest.json @@ -1,9 +1,9 @@ { "schema_version": 1, "product": "energyplan", - "version": "0.2.2", + "version": "0.3.0", "source_repository": "srcfl/energyplan", - "source_commit": "b78cde268432671762e8c76c2c739f7a71f91087", + "source_commit": "3b9c59baecb71a91a8bc01208b4b8d5b9a5bfa60", "rustc": "rustc 1.95.0 (59807616e 2026-04-14)", "protocol_version": 1, "forecast_protocol_version": 1, @@ -27,8 +27,8 @@ "bytes": 968 }, "THIRD-PARTY-NOTICES.txt": { - "sha256": "dd245a0ed4b5e75dcc07ae7bdb0366bd60c05dfc93b6ab1f4309d9ba0a99d7e5", - "bytes": 14042 + "sha256": "d5381c7a521d2e378b9e3df687d2bf55358b516f26981138020358037b034f71", + "bytes": 55889 }, "forecast-v1.response.schema.json": { "sha256": "b7f06c00679b3798cbedd458f8b9b6bfd556e96ac442bb3820f9696c6c7bdd99", @@ -39,16 +39,16 @@ "bytes": 15377 }, "ftw-solver-darwin-arm64": { - "sha256": "04e4e02dc7bdfe672f71d4f37a955fc39af6ee657d6315d304b28a5d7ccb2805", - "bytes": 858240 + "sha256": "5f2160f9f18217269d0fcb5e568563629986fdf060a565a2b676c4104e516e28", + "bytes": 1108032 }, "ftw-solver-linux-amd64": { - "sha256": "70a950fc6b51f227a1441183c8571030d9f45ea89774d7231f2416c773f8927f", - "bytes": 1079848 + "sha256": "d2b2681bd498a14aea209df390b79a48b1f26ddf70e5560bc6c86c5d69d1129f", + "bytes": 1417896 }, "ftw-solver-linux-arm64": { - "sha256": "5bc5f3f98a10a0faf65b1a2630289d24bfe04402a20aa44c90f2a6058ea6af5a", - "bytes": 902840 + "sha256": "fde573c1330e7b9adcaddc42893296fdd375e0efcc8298ea947a155780780545", + "bytes": 1155360 }, "rust-runtime/COPYRIGHT-library.html": { "sha256": "90567e2718bf7fd65a71a3a43c5596488e80e5f51ed02bfea6fec54458b5f3d1",