diff --git a/assets/js/components/Battery/BatteryGridChargeOnce.vue b/assets/js/components/Battery/BatteryGridChargeOnce.vue new file mode 100644 index 0000000000..eddba6fd97 --- /dev/null +++ b/assets/js/components/Battery/BatteryGridChargeOnce.vue @@ -0,0 +1,150 @@ + + + diff --git a/assets/js/components/Battery/BatterySocGridChargeCard.vue b/assets/js/components/Battery/BatterySocGridChargeCard.vue index 316509c04e..2b848027b8 100644 --- a/assets/js/components/Battery/BatterySocGridChargeCard.vue +++ b/assets/js/components/Battery/BatterySocGridChargeCard.vue @@ -43,6 +43,8 @@ + + @@ -53,12 +55,13 @@ import formatter from "@/mixins/formatter"; import api from "@/api"; import Card from "../Helper/Card.vue"; import InlineSocSelect from "./InlineSocSelect.vue"; +import BatteryGridChargeOnce from "./BatteryGridChargeOnce.vue"; // Soc-based grid charging: charge the home battery from the grid between a start // and a stop soc, independent of the price-based grid charge limit. export default defineComponent({ name: "BatterySocGridChargeCard", - components: { Card, InlineSocSelect }, + components: { Card, InlineSocSelect, BatteryGridChargeOnce }, mixins: [formatter], props: { enabled: Boolean, diff --git a/assets/js/components/Config/LmAdvancedModal.vue b/assets/js/components/Config/LmAdvancedModal.vue index 7c1d9d1706..83ed8cf121 100644 --- a/assets/js/components/Config/LmAdvancedModal.vue +++ b/assets/js/components/Config/LmAdvancedModal.vue @@ -91,6 +91,7 @@ const FIELDS = [ { name: "timeout", unit: "min", min: 1, max: 60, integer: true, default: 10 }, { name: "peakFreeze", unit: "min", min: 1, max: 14, integer: true, default: 12 }, { name: "peakCap", unit: "×", min: 1, max: 10, integer: false, default: 2 }, + { name: "gridChargeWindow", unit: "h", min: 1, max: 24, integer: true, default: 3 }, { name: "followCycles", unitKey: "config.lmadvanced.cycles", diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 244d0eafbe..4995966ac6 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -179,6 +179,7 @@ export interface LmAdvanced { peakCap: number; /** Cycles after which a load ignoring its limit is no longer counted on, 0 = off. */ followCycles: number; + gridChargeWindow: number; } // custom: a battery profile, see core/lm/profile. Values left out are not changed. @@ -417,6 +418,8 @@ export interface State { batterySocGridChargeStart?: number; /** Soc in % at or above which soc-based grid charging stops. */ batterySocGridChargeStop?: number; + /** One-time grid charging up to target soc, right away or by until. */ + batteryGridChargeOnce?: { target: number; until?: string | null; active?: boolean }; /** Battery peak shaving is enabled. */ peakShaving?: boolean; /** Grid peak limit in W the battery reserve is used to stay below. */ diff --git a/core/keys/site_custom.go b/core/keys/site_custom.go index d85e5d2197..51673398aa 100644 --- a/core/keys/site_custom.go +++ b/core/keys/site_custom.go @@ -8,6 +8,7 @@ const ( BatterySocGridChargeStart = "batterySocGridChargeStart" BatterySocGridChargeStop = "batterySocGridChargeStop" BatterySocGridChargeRunning = "batterySocGridChargeRunning" // hysteresis state, not published + BatteryGridChargeOnce = "batteryGridChargeOnce" // one-time grid charging, see core/site_lm_once.go // battery peak shaving PeakShaving = "peakShaving" diff --git a/core/lm/README.md b/core/lm/README.md index 79d6614e22..a8e48c6ab7 100644 --- a/core/lm/README.md +++ b/core/lm/README.md @@ -139,6 +139,7 @@ Keep these in mind when merging a new evcc version: | `charger/switchsocket.go` | `RatedPower` config field, stands in for a missing power sensor | | `templates/definition/charger/homeassistant-switch.yaml` | `ratedpower` parameter | | `core/site/api.go` | embeds `CustomAPI`, one line | +| `core/site_optimizer.go` | `applyLmOptimizerInputs` where the optimizer request is assembled | | `server/http.go` | merges `customSiteRoutes`, one loop | | `assets/js/views/Battery.vue` | mounts the new cards, profile selection at the bottom | | `assets/js/views/Config.vue` | load management details section and its modals, OeMAG modal | @@ -152,7 +153,7 @@ Everything else lives in files of its own: `core/lm/`, `core/site_lm.go`, `core/ `core/site_lm_advanced.go`, `core/site_lm_status.go`, `core/site_lm_profiles.go`, `core/site_lm_follow.go`, `core/site_peak_stats.go`, `assets/js/components/LoadManagement/`, `assets/js/components/PeakShaving/`, `core/site_peakshaving.go`, `core/loadpoint_lm.go`, `charger/switchsocket_lm.go`, `core/keys/site_custom.go`, -`core/site/api_custom.go`, `server/http_custom.go`, `core/site_feedin.go`, `core/metrics/tariffs_custom.go`, +`core/site/api_custom.go`, `server/http_custom.go`, `core/site_feedin.go`, `core/metrics/tariffs_custom.go`, `core/site_optimizer_lm.go`, `core/site_lm_once.go`, `tariff/oemag.go`, `tariff/wrapper_custom.go`, `templates/definition/tariff/oemag.yaml` and the new Vue components. @@ -232,6 +233,43 @@ counts negative), and how often the battery started covering a peak. Only quarter hours metered from their start count. Kept for 24 months in `peakMonths`, see `core/site_peak_stats.go`. +## Optimizer + +The optimizer plans battery and vehicle charging and, today, advises: its +result is the battery soc forecast and the suggestions. The fork gives it its +settings as inputs, so the plan matches what the fork will actually do, see +`core/site_optimizer_lm.go`: + +- peak shaving: peak limit as hard grid import limit (`p_max_imp`), reserve + as the home battery's minimum soc (`s_min`) +- soc-based grid charging: start soc as minimum soc, so the charging is + planned ahead before the battery would fall below it; while it runs the + stop soc as goal (`s_goal`) within the grid charge window (*Erweitert → + Netzlade-Ziel erreichen in*, default 3 h) +- one-time grid charging: its target as goal, right away or at the chosen time +- grid charging refused right now (shed hold-off, running peak, unknown charge + power on a circuit) is not offered (`charge_from_grid`) +- load management: a loadpoint plans with at most its circuits' power, the + priorities 0-3/4-6/7-10 become `c_priority` 0/1/2 + +Without circuits, peak shaving, soc-based and one-time grid charging the +request is unchanged. The optimizer's automatic mode (evcc PR 32881, not +released yet) additionally needs a gate at execution; that is prepared +separately on top of these inputs. + +`TestLmOptimizerReplay` sends a recorded request with these inputs to a +running optimizer (`OPTIMIZER_REPLAY`, `OPTIMIZER_URI`, optional +`REPLAY_SETTINGS`, `REPLAY_GRID_PRICE`, `REPLAY_SOC`) and checks the plan. + +## One-time grid charging + +Battery page, below *Netzladen nach Ladestand*: grid-charges once up to the +chosen soc and switches itself off, right away or by a time of day at the +cheapest slots before it (upstream planner on the planner tariff, right away +once the time passed or when the duration is unknown). It survives a restart, +can be cancelled and passes the same gate as the soc-based grid charging, see +`core/site_lm_once.go`. + ## 4. Peak shaving See `core/site_peakshaving.go`. The battery's lower soc range is reserved for diff --git a/core/site/api_custom.go b/core/site/api_custom.go index 67bc05d4a0..ce41d1bd77 100644 --- a/core/site/api_custom.go +++ b/core/site/api_custom.go @@ -1,6 +1,10 @@ package site -import "github.com/evcc-io/evcc/core/lm/profile" +import ( + "time" + + "github.com/evcc-io/evcc/core/lm/profile" +) // CustomAPI is the part of the site api added by this fork. It lives in its own // file so that upstream changes to API merge without conflicts; API embeds it. @@ -13,6 +17,10 @@ type CustomAPI interface { GetBatterySocGridChargeStop() float64 SetBatterySocGridChargeStop(float64) error + // one-time grid charging, see core/site_lm_once.go + SetBatteryGridChargeOnce(target float64, until time.Time) error + CancelBatteryGridChargeOnce() error + // load management shed priorities, see core/site_lm.go SetLmPriority(name string, prio int) error diff --git a/core/site_lm.go b/core/site_lm.go index 03f5086bc2..d57b114c48 100644 --- a/core/site_lm.go +++ b/core/site_lm.go @@ -55,12 +55,13 @@ type lmState struct { advMu sync.Mutex adv lmAdvanced - batteryShedUntil time.Time // battery grid charge hold-off after a shed - feedInTried time.Time // last feed-in finalization attempt, see site_feedin.go - feedInOnce sync.Once // feed-in history backfilled - feedInMarket *float64 // market price last published - batteryCircuit api.Circuit // resolved from the assignment - batteryCircuitRef string // what batteryCircuit was resolved from + batteryShedUntil time.Time // battery grid charge hold-off after a shed + feedInTried time.Time // last feed-in finalization attempt, see site_feedin.go + feedInOnce sync.Once // feed-in history backfilled + feedInMarket *float64 // market price last published + gridOnce gridChargeOnce // one-time grid charging, see site_lm_once.go + batteryCircuit api.Circuit // resolved from the assignment + batteryCircuitRef string // what batteryCircuit was resolved from batteryLoad *batteryLoad } @@ -106,6 +107,7 @@ func (site *Site) restoreLmSettings() { } lm.SetPriorityLookup(site.lmPriorityLookup) + site.restoreGridChargeOnce() site.restoreLmGuard() site.restoreLmAdvanced() @@ -358,9 +360,10 @@ func (site *Site) batteryCircuitAllows() bool { func (site *Site) batteryGridChargeRequested(rate api.Rate) bool { // evaluated unconditionally so the hysteresis keeps tracking the soc socActive := site.batterySocChargeActive() + onceActive := site.batteryGridChargeOnceActive() // a running demand peak needs the battery for shaving, not charging - if !socActive && !site.batteryGridChargeActive(rate) || site.peakPausesGridCharge() { + if !socActive && !onceActive && !site.batteryGridChargeActive(rate) || site.peakPausesGridCharge() { // release what the battery had reserved on the circuit, lower priority // loads would otherwise stay throttled until the reservation expires lm.Forget(site.lmBattery()) diff --git a/core/site_lm_advanced.go b/core/site_lm_advanced.go index abf218a47c..d2418511df 100644 --- a/core/site_lm_advanced.go +++ b/core/site_lm_advanced.go @@ -25,18 +25,21 @@ type lmAdvanced struct { PeakCap *float64 `json:"peakCap,omitempty"` // allowed grid power at most this multiple of the peak limit // cycles after which a load not following its limit is no longer counted on, 0 = never FollowCycles *float64 `json:"followCycles,omitempty"` + // hours the optimizer may take to reach the stop soc of soc-based grid charging + GridChargeWindow *float64 `json:"gridChargeWindow,omitempty"` } // lmAdvancedState is what the ui shows: the values in effect type lmAdvancedState struct { - Hysteresis float64 `json:"hysteresis"` - FreeValue float64 `json:"freeValue"` - HoldOff float64 `json:"holdOff"` - Timeout float64 `json:"timeout"` - Phases int `json:"phases"` - PeakFreeze float64 `json:"peakFreeze"` - PeakCap float64 `json:"peakCap"` - FollowCycles int `json:"followCycles"` + Hysteresis float64 `json:"hysteresis"` + FreeValue float64 `json:"freeValue"` + HoldOff float64 `json:"holdOff"` + Timeout float64 `json:"timeout"` + Phases int `json:"phases"` + PeakFreeze float64 `json:"peakFreeze"` + PeakCap float64 `json:"peakCap"` + FollowCycles int `json:"followCycles"` + GridChargeWindow float64 `json:"gridChargeWindow"` } // lmAdvancedLimit is a setting's valid range @@ -46,14 +49,15 @@ type lmAdvancedLimit struct { } var lmAdvancedLimits = map[string]lmAdvancedLimit{ - "hysteresis": {0, 20, false}, - "freeValue": {1, 100000, true}, - "holdOff": {1, 60, true}, - "timeout": {1, 60, true}, - "phases": {1, 3, true}, - "peakFreeze": {1, 14, true}, - "peakCap": {1, 10, false}, - "followCycles": {0, 20, true}, + "hysteresis": {0, 20, false}, + "freeValue": {1, 100000, true}, + "holdOff": {1, 60, true}, + "timeout": {1, 60, true}, + "phases": {1, 3, true}, + "peakFreeze": {1, 14, true}, + "peakCap": {1, 10, false}, + "followCycles": {0, 20, true}, + "gridChargeWindow": {1, 24, true}, } // restoreLmAdvanced restores the persisted advanced settings @@ -85,14 +89,15 @@ func (site *Site) advanced() lmAdvanced { func (site *Site) publishLmAdvanced() { site.publish(keys.LmAdvanced, lmAdvancedState{ - Hysteresis: site.peakHysteresis(), - FreeValue: site.peakFreeValue(), - HoldOff: site.lmHoldOff().Minutes(), - Timeout: site.lmTimeout().Minutes(), - Phases: site.lmBatteryPhases(), - PeakFreeze: site.peakFreeze().Minutes(), - PeakCap: site.peakCap(), - FollowCycles: site.lmFollowCycles(), + Hysteresis: site.peakHysteresis(), + FreeValue: site.peakFreeValue(), + HoldOff: site.lmHoldOff().Minutes(), + Timeout: site.lmTimeout().Minutes(), + Phases: site.lmBatteryPhases(), + PeakFreeze: site.peakFreeze().Minutes(), + PeakCap: site.peakCap(), + FollowCycles: site.lmFollowCycles(), + GridChargeWindow: site.gridChargeWindow().Hours(), }) } @@ -141,6 +146,8 @@ func (site *Site) SetLmAdvanced(name string, value float64) error { s.adv.PeakCap = &v case "followCycles": s.adv.FollowCycles = &v + case "gridChargeWindow": + s.adv.GridChargeWindow = &v } adv := s.adv s.advMu.Unlock() diff --git a/core/site_lm_once.go b/core/site_lm_once.go new file mode 100644 index 0000000000..d9ca2d073f --- /dev/null +++ b/core/site_lm_once.go @@ -0,0 +1,207 @@ +package core + +// Custom extension: one-time grid charging up to a soc. +// +// Started by hand on the battery page, it grid-charges the home battery once up +// to the chosen soc and then switches itself off. Either right away, or by a +// time of day at the cheapest slots before it, chosen by the upstream planner +// from the planner tariff. It survives a restart and can be cancelled. It is +// independent of the soc-based grid charging and passes the same gate (peak, +// circuit, charge power setpoint). +// +// With the optimizer in automatic mode it is an optimizer input instead: the +// target soc as goal, right away at the earliest step the allowed charge power +// can reach it, or at the chosen time. See site_optimizer_lm.go. + +import ( + "errors" + "fmt" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/core/planner" + "github.com/evcc-io/evcc/db/settings" + "github.com/evcc-io/evcc/util" +) + +// gridChargeOnce is the one-time grid charge as stored +type gridChargeOnce struct { + Target float64 `json:"target"` // soc %, 0 = off + Until time.Time `json:"until,omitzero"` // cheapest slots before this time, zero = right away + Active bool `json:"active,omitempty"` // charging in this slot +} + +// gridChargeOncePublished is what the ui gets: every field always present, as +// the ui merges a published object into the previous one +type gridChargeOncePublished struct { + Target float64 `json:"target"` + Until *time.Time `json:"until"` // null = right away + Active bool `json:"active"` +} + +func (site *Site) publishGridChargeOnce(o gridChargeOnce) { + res := gridChargeOncePublished{Target: o.Target, Active: o.Active} + if !o.Until.IsZero() { + res.Until = &o.Until + } + site.publish(keys.BatteryGridChargeOnce, res) +} + +// maxGridChargeOnceAhead is how far ahead the time of day may be +const maxGridChargeOnceAhead = 48 * time.Hour + +func (site *Site) gridChargeOnce() gridChargeOnce { + s := site.lms() + + s.mu.Lock() + defer s.mu.Unlock() + + return s.gridOnce +} + +func (site *Site) setGridChargeOnce(o gridChargeOnce) { + s := site.lms() + + s.mu.Lock() + s.gridOnce = o + s.mu.Unlock() + + if err := settings.SetJson(keys.BatteryGridChargeOnce, o); err != nil { + site.log.ERROR.Printf("battery grid charge once: %v", err) + } + site.publishGridChargeOnce(o) +} + +// restoreGridChargeOnce continues a one-time grid charge across a restart +func (site *Site) restoreGridChargeOnce() { + var o gridChargeOnce + if err := settings.Json(keys.BatteryGridChargeOnce, &o); err == nil { + s := site.lms() + s.mu.Lock() + s.gridOnce = o + s.mu.Unlock() + } + + site.publishGridChargeOnce(site.gridChargeOnce()) +} + +// SetBatteryGridChargeOnce starts one-time grid charging up to target soc, right +// away with a zero until, else at the cheapest slots before until +func (site *Site) SetBatteryGridChargeOnce(target float64, until time.Time) error { + if !site.batteryConfigured() { + return errors.New("battery not configured") + } + if target < 1 || target > 100 { + return errors.New("target soc must be between 1 and 100") + } + if soc := site.GetBatterySoc(); target <= soc { + return fmt.Errorf("battery is already at %.0f%%", soc) + } + if !until.IsZero() && (!until.After(time.Now()) || time.Until(until) > maxGridChargeOnceAhead) { + return errors.New("time must be within the next 48 hours") + } + + site.log.INFO.Printf("battery grid charge once: up to %.0f%%%s", target, onceUntilText(until)) + site.setGridChargeOnce(gridChargeOnce{Target: target, Until: until}) + site.Optimize() + + return nil +} + +// CancelBatteryGridChargeOnce stops one-time grid charging +func (site *Site) CancelBatteryGridChargeOnce() error { + if site.gridChargeOnce().Target == 0 { + return nil + } + + site.log.INFO.Println("battery grid charge once: cancelled") + site.setGridChargeOnce(gridChargeOnce{}) + site.Optimize() + + return nil +} + +func onceUntilText(until time.Time) string { + if until.IsZero() { + return ", right away" + } + return ", by " + until.Local().Format("15:04") +} + +// batteryGridChargeOnceActive reports whether one-time grid charging charges in +// this cycle, and ends it once the target soc is reached +func (site *Site) batteryGridChargeOnceActive() bool { + o := site.gridChargeOnce() + if o.Target == 0 { + return false + } + + soc := site.GetBatterySoc() + if soc >= o.Target { + site.log.INFO.Printf("battery grid charge once: %.0f%% reached", o.Target) + site.setGridChargeOnce(gridChargeOnce{}) + return false + } + + active := o.Until.IsZero() || !time.Now().Before(o.Until) || site.onceSlotActive(o, soc) + + if active != o.Active { + o.Active = active + site.setGridChargeOnce(o) + } + + return active +} + +// onceRequiredDuration is how long charging from soc to the target takes at the +// grid charge power, 0 if unknown +func (site *Site) onceRequiredDuration(target, soc float64) time.Duration { + power, _ := site.lmBatteryChargePower() + capacity := site.batteryCapacityKWh() + if power <= 0 || capacity <= 0 { + return 0 + } + + hours := (target - soc) / 100 * capacity * 1e3 / power + return time.Duration(hours * float64(time.Hour)) +} + +// onceSlotActive reports whether now is one of the cheapest slots the planner +// picks to reach the target by the time. Without a known duration or tariff it +// charges right away. +func (site *Site) onceSlotActive(o gridChargeOnce, soc float64) bool { + required := site.onceRequiredDuration(o.Target, soc) + if required <= 0 { + return true + } + + var tariff api.Tariff + if site.tariffs != nil { + tariff = site.GetTariff(api.TariffUsagePlanner) + } + + plan := planner.New(util.NewLogger("gridcharge"), tariff).Plan(required, 0, o.Until, false) + + now := time.Now() + for _, slot := range plan { + if !now.Before(slot.Start) && now.Before(slot.End) { + return true + } + } + + return false +} + +// batteryCapacityKWh returns the capacity of all batteries, 0 if one is unknown +func (site *Site) batteryCapacityKWh() float64 { + var res float64 + for _, dev := range site.batteryMeters { + m, ok := api.Cap[api.BatteryCapacity](dev.Instance()) + if !ok || m.Capacity() <= 0 { + return 0 + } + res += m.Capacity() + } + return res +} diff --git a/core/site_lm_once_test.go b/core/site_lm_once_test.go new file mode 100644 index 0000000000..4ba2226c19 --- /dev/null +++ b/core/site_lm_once_test.go @@ -0,0 +1,98 @@ +package core + +import ( + "testing" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/db/settings" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/config" + optimizer "github.com/evcc-io/optimizer/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type capacityBattery struct { + scenarioBattery + kWh float64 +} + +func (b *capacityBattery) Capacity() float64 { return b.kWh } + +func onceSite(t *testing.T, soc float64) *Site { + t.Helper() + settings.SetJson(keys.BatteryGridChargeOnce, gridChargeOnce{}) + t.Cleanup(func() { settings.SetJson(keys.BatteryGridChargeOnce, gridChargeOnce{}) }) + + site := &Site{ + log: util.NewLogger("test"), + batteryMeters: []config.Device[api.Meter]{config.NewStaticDevice(config.Named{Name: "bat"}, api.Meter(&capacityBattery{kWh: 10}))}, + } + site.battery.Soc = soc + site.LoadManagement.Battery.Power = 5000 + return site +} + +// Right away: charges until the target, then switches itself off. +func TestGridChargeOnceRightAway(t *testing.T) { + site := onceSite(t, 40) + + require.Error(t, site.SetBatteryGridChargeOnce(30, time.Time{}), "already above") + require.Error(t, site.SetBatteryGridChargeOnce(80, time.Now().Add(-time.Hour)), "time passed") + require.NoError(t, site.SetBatteryGridChargeOnce(80, time.Time{})) + + assert.True(t, site.batteryGridChargeRequested(api.Rate{})) + assert.True(t, site.gridChargeOnce().Active) + + // survives a restart + restarted := onceSite(t, 40) + settings.SetJson(keys.BatteryGridChargeOnce, site.gridChargeOnce()) + restarted.restoreGridChargeOnce() + assert.Equal(t, 80.0, restarted.gridChargeOnce().Target) + + site.battery.Soc = 80 + assert.False(t, site.batteryGridChargeRequested(api.Rate{})) + assert.Zero(t, site.gridChargeOnce().Target, "ended at the target") +} + +// By a time: waits for the cheapest slots, charges right away once the time +// has passed, and can be cancelled. +func TestGridChargeOnceByTime(t *testing.T) { + site := onceSite(t, 40) + + // 40% -> 80% of 10 kWh at 5 kW takes 48 min: far ahead without a tariff the + // simple plan starts just before the time + require.NoError(t, site.SetBatteryGridChargeOnce(80, time.Now().Add(6*time.Hour))) + assert.Equal(t, 48*time.Minute, site.onceRequiredDuration(80, 40).Round(time.Minute)) + assert.False(t, site.batteryGridChargeOnceActive(), "not yet") + + // the time passed without reaching the target + o := site.gridChargeOnce() + o.Until = time.Now().Add(-time.Minute) + site.setGridChargeOnce(o) + assert.True(t, site.batteryGridChargeOnceActive(), "late: right away") + + require.NoError(t, site.CancelBatteryGridChargeOnce()) + assert.False(t, site.batteryGridChargeOnceActive()) +} + +// As optimizer input: the target as goal, right away at the earliest step. +func TestGridChargeOnceOptimizerGoal(t *testing.T) { + config.Reset() + t.Cleanup(config.Reset) + + site := onceSite(t, 40) + require.NoError(t, site.SetBatteryGridChargeOnce(80, time.Time{})) + + req := optimizer.OptimizationInput{TimeSeries: optimizer.TimeSeries{Dt: quarterHours(16)}} + batteries := []optimizerBattery{homeBattery()} + batteries[0].cfg.SInitial = 4000 + + site.applyLmOptimizerInputs(&req, batteries) + + goal := batteries[0].cfg.SGoal + require.Len(t, goal, 16) + assert.Equal(t, float32(8000), goal[3], "48 min at 5 kW: the 4th quarter hour") +} diff --git a/core/site_optimizer.go b/core/site_optimizer.go index c9ac987ddd..99e7d10761 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -602,6 +602,8 @@ func (site *Site) optimizerRequest(battery []types.Measurement) (optimizer.Optim batteries = append(batteries, optimizerBattery{cfg, detail}) } + site.applyLmOptimizerInputs(&req, batteries) // custom: fork settings as inputs, see core/site_optimizer_lm.go + for _, b := range batteries { b.cfg.PA = pa req.Batteries = append(req.Batteries, b.cfg) diff --git a/core/site_optimizer_lm.go b/core/site_optimizer_lm.go new file mode 100644 index 0000000000..5fd2323b3a --- /dev/null +++ b/core/site_optimizer_lm.go @@ -0,0 +1,212 @@ +package core + +// Custom extension: the fork's settings as optimizer inputs, see +// site_optimizer_gate.go for the gate the automatic mode passes. +// +// Inputs, not overrides: what the optimizer has to respect goes into its +// request, so its plan already contains it, in the advisory as well as in the +// automatic mode: +// +// - peak shaving: the peak limit as hard grid import limit, the reserve as +// the home battery's minimum soc +// - soc-based grid charging: the start soc as minimum soc, so the optimizer +// plans the charging ahead before the battery would fall below it; while +// charging runs the stop soc as goal within the grid charge window +// - grid charging the load management or a peak currently refuses is not +// offered (charge_from_grid off) +// - load management: a loadpoint plans with at most its circuits' power, and +// the priorities rank the batteries (c_priority) +// +// Without circuits, peak shaving and soc-based grid charging the request is +// unchanged. + +import ( + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/core/lm" + "github.com/evcc-io/evcc/util/config" + optimizer "github.com/evcc-io/optimizer/client" +) + +// defaultGridChargeWindow is how long the optimizer may take to reach the stop +// soc of running soc-based grid charging +const defaultGridChargeWindow = 3 * time.Hour + +// gridChargeWindow returns the grid charge window +func (site *Site) gridChargeWindow() time.Duration { + if v := site.advanced().GridChargeWindow; v != nil { + return time.Duration(*v * float64(time.Hour)) + } + return defaultGridChargeWindow +} + +// optimizerPriority maps a priority 0-10 onto the optimizer's 0-2 +func optimizerPriority(prio int) int { + switch { + case prio >= 7: + return 2 + case prio >= 4: + return 1 + default: + return 0 + } +} + +// applyLmOptimizerInputs adds the fork's settings to the optimizer request +func (site *Site) applyLmOptimizerInputs(req *optimizer.OptimizationInput, batteries []optimizerBattery) { + lmActive := len(config.Circuits().Devices()) > 0 + peakOn, limit, reserve := site.peakShavingConfigured() + + if peakOn && limit > 0 && (req.Grid.PMaxImp == 0 || float32(limit) < req.Grid.PMaxImp) { + req.Grid.PMaxImp = float32(limit) + } + + for i := range batteries { + b := &batteries[i] + + switch b.detail.Type { + case batteryTypeBattery: + site.applyLmBatteryInputs(&b.cfg, req.TimeSeries.Dt, peakOn, reserve) + + if lmActive && site.lmBatteryCircuit() != nil { + b.cfg.CPriority = optimizerPriority(lm.Priority(site.lmBattery())) + } + + case batteryTypeLoadpoint, batteryTypeVehicle: + if !lmActive || b.detail.loadpoint == nil || *b.detail.loadpoint >= len(site.loadpoints) { + continue + } + + lp := site.loadpoints[*b.detail.loadpoint] + if lp == nil { + continue + } + + if power := circuitPower(lp.GetCircuit()); power > 0 && float32(power) < b.cfg.CMax && float32(power) >= b.cfg.CMin { + b.cfg.CMax = float32(power) + } + + b.cfg.CPriority = optimizerPriority(lm.Priority(lp)) + } + } +} + +// circuitPower returns the lowest power limit of a circuit and its parents, 0 +// without any +func circuitPower(c api.Circuit) float64 { + var res float64 + for ; c != nil; c = c.GetParent() { + if p := c.GetMaxPower(); p > 0 && (res == 0 || p < res) { + res = p + } + } + return res +} + +// applyLmBatteryInputs sets the home battery's minimum soc and grid charge goal +func (site *Site) applyLmBatteryInputs(bat *optimizer.BatteryConfig, dt []int, peakOn bool, reserve float64) { + if bat.SCapacity <= 0 { + return + } + + wh := func(soc float64) float32 { return bat.SCapacity * float32(soc) / 100 } + + var floor float32 + if peakOn { + floor = wh(reserve) + } + + s := site.lms() + + s.mu.Lock() + socOn, start, stop, running := s.socChargeEnabled, s.socChargeStart, s.socChargeStop, s.socChargeRunning + s.mu.Unlock() + + if site.lmGridChargeBlocked() { + bat.ChargeFromGrid = false + } + + if socOn && bat.ChargeFromGrid { + floor = max(floor, wh(start)) + + if goal := wh(stop); running && goal > bat.SInitial { + if len(bat.SGoal) != len(dt) { + bat.SGoal = make([]float32, len(dt)) + } + i := slotAfter(dt, site.gridChargeWindow()) + bat.SGoal[i] = max(bat.SGoal[i], min(goal, bat.SMax)) + } + } + + // one-time grid charging: the target by the chosen time, or as early as + // the charge power can reach it, see site_lm_once.go + if o := site.gridChargeOnce(); o.Target > 0 && bat.ChargeFromGrid { + if goal := wh(o.Target); goal > bat.SInitial { + d := time.Until(o.Until) + if o.Until.IsZero() { + d = site.onceRequiredDuration(o.Target, float64(bat.SInitial/bat.SCapacity*100)) + } + if len(bat.SGoal) != len(dt) { + bat.SGoal = make([]float32, len(dt)) + } + i := slotAfter(dt, max(d, 0)) + bat.SGoal[i] = max(bat.SGoal[i], min(goal, bat.SMax)) + } + } + + // never above the current soc, the optimizer cannot start below its minimum + if floor = min(floor, bat.SInitial); floor > bat.SMin { + bat.SMin = floor + } +} + +// slotAfter returns the index of the time step in which d has passed +func slotAfter(dt []int, d time.Duration) int { + var elapsed time.Duration + for i, s := range dt { + elapsed += time.Duration(s) * time.Second + if elapsed >= d { + return i + } + } + return max(0, len(dt)-1) +} + +// peakShavingConfigured returns whether peak shaving is on and set up, with +// its limit and reserve soc +func (site *Site) peakShavingConfigured() (bool, float64, float64) { + s := site.peak() + + s.mu.Lock() + defer s.mu.Unlock() + + return s.enabled && s.set != nil, s.limit, s.reserve +} + +// lmGridChargeBlocked reports without side effects whether grid charging is +// currently refused: held off after load management shed it, paused by a +// peak, or without a known charge power on a circuit +func (site *Site) lmGridChargeBlocked() bool { + if site.lmBatteryCircuit() != nil { + if power, _ := site.lmBatteryChargePower(); power <= 0 { + return true + } + + s := site.lms() + s.mu.Lock() + shedUntil := s.batteryShedUntil + s.mu.Unlock() + + if time.Now().Before(shedUntil) { + return true + } + } + + p := site.peak() + + p.mu.Lock() + defer p.mu.Unlock() + + return p.enabled && p.set != nil && (p.demand > p.limit || p.clock.Now().Before(p.chargePause)) +} diff --git a/core/site_optimizer_lm_test.go b/core/site_optimizer_lm_test.go new file mode 100644 index 0000000000..bc3efeec66 --- /dev/null +++ b/core/site_optimizer_lm_test.go @@ -0,0 +1,163 @@ +package core + +import ( + "context" + "testing" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/core/circuit" + "github.com/evcc-io/evcc/core/lm" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/config" + optimizer "github.com/evcc-io/optimizer/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func quarterHours(n int) []int { + dt := make([]int, n) + for i := range dt { + dt[i] = 900 + } + return dt +} + +func homeBattery() optimizerBattery { + return optimizerBattery{ + cfg: optimizer.BatteryConfig{ + SCapacity: 10000, + SInitial: 5000, + SMax: 10000, + CMax: 5000, + DMax: 5000, + ChargeFromGrid: true, + }, + detail: batteryDetail{Type: batteryTypeBattery, Name: "bat"}, + } +} + +func setPeakShaving(site *Site, limit, reserve float64) { + s := site.peak() + s.enabled, s.limit, s.reserve = true, limit, reserve + s.set = func(float64) error { return nil } +} + +// Without circuits, peak shaving and soc-based grid charging the request stays +// exactly as upstream builds it. +func TestLmOptimizerInputsInert(t *testing.T) { + config.Reset() + t.Cleanup(config.Reset) + + site := &Site{log: util.NewLogger("test")} + req := optimizer.OptimizationInput{TimeSeries: optimizer.TimeSeries{Dt: quarterHours(16)}} + req.Grid.PMaxImp = 22000 + + batteries := []optimizerBattery{homeBattery()} + want := batteries[0].cfg + + site.applyLmOptimizerInputs(&req, batteries) + + assert.Equal(t, float32(22000), req.Grid.PMaxImp) + assert.Equal(t, want, batteries[0].cfg) +} + +func TestLmOptimizerInputsPeakAndGridCharge(t *testing.T) { + config.Reset() + t.Cleanup(config.Reset) + + site := &Site{log: util.NewLogger("test")} + setPeakShaving(site, 7000, 30) + + s := site.lms() + s.socChargeEnabled, s.socChargeStart, s.socChargeStop = true, 20, 80 + + req := optimizer.OptimizationInput{TimeSeries: optimizer.TimeSeries{Dt: quarterHours(16)}} + req.Grid.PMaxImp = 22000 + + // idle: peak limit, reserve as floor (above the start soc) + batteries := []optimizerBattery{homeBattery()} + site.applyLmOptimizerInputs(&req, batteries) + + bat := batteries[0].cfg + assert.Equal(t, float32(7000), req.Grid.PMaxImp, "peak limit below the circuit") + assert.Equal(t, float32(3000), bat.SMin, "reserve 30%% of 10 kWh") + assert.Nil(t, bat.SGoal, "no goal while grid charging does not run") + + // start soc above the reserve: it is the floor, planned ahead + s.socChargeStart = 40 + batteries = []optimizerBattery{homeBattery()} + site.applyLmOptimizerInputs(&req, batteries) + assert.Equal(t, float32(4000), batteries[0].cfg.SMin) + + // running: stop soc as goal after the grid charge window (3 h = 12th quarter hour) + s.socChargeRunning = true + batteries = []optimizerBattery{homeBattery()} + site.applyLmOptimizerInputs(&req, batteries) + bat = batteries[0].cfg + require.Len(t, bat.SGoal, 16) + assert.Equal(t, float32(8000), bat.SGoal[11]) + + // below the floor: the minimum is the current soc + batteries = []optimizerBattery{homeBattery()} + batteries[0].cfg.SInitial = 1500 + site.applyLmOptimizerInputs(&req, batteries) + assert.Equal(t, float32(1500), batteries[0].cfg.SMin) + + // a running peak refuses grid charging: not offered, no goal, no start soc floor + site.peak().demand = 9000 + batteries = []optimizerBattery{homeBattery()} + site.applyLmOptimizerInputs(&req, batteries) + bat = batteries[0].cfg + assert.False(t, bat.ChargeFromGrid) + assert.Nil(t, bat.SGoal) + assert.Equal(t, float32(3000), bat.SMin, "reserve only") +} + +// A loadpoint plans with at most its circuits' power, priorities map to 0-2. +func TestLmOptimizerInputsLoadpoint(t *testing.T) { + config.Reset() + t.Cleanup(config.Reset) + lm.Reset() + t.Cleanup(lm.Reset) + + root, err := circuit.New(util.NewLogger("test"), "main", 0, 22000, nil, 0) + require.NoError(t, err) + require.NoError(t, config.Circuits().Add(config.NewStaticDevice(config.Named{Name: "main"}, api.Circuit(root)))) + sub, err := circuit.NewConfigurableFromConfig(context.TODO(), map[string]any{"title": "garage", "maxPower": 7000, "parent": "main"}) + require.NoError(t, err) + + lp := &Loadpoint{log: util.NewLogger("lp"), circuit: sub, LmPrio: 8} + site := &Site{log: util.NewLogger("test"), loadpoints: []*Loadpoint{lp}} + + id := 0 + batteries := []optimizerBattery{{ + cfg: optimizer.BatteryConfig{CMin: 1400, CMax: 11000}, + detail: batteryDetail{Type: batteryTypeLoadpoint, loadpoint: &id}, + }} + + req := optimizer.OptimizationInput{TimeSeries: optimizer.TimeSeries{Dt: quarterHours(4)}} + site.applyLmOptimizerInputs(&req, batteries) + + assert.Equal(t, float32(7000), batteries[0].cfg.CMax, "garage circuit") + assert.Equal(t, 2, batteries[0].cfg.CPriority) + + // with a vehicle the entry is typed as such, same inputs + batteries[0].cfg.CMax, batteries[0].cfg.CPriority = 11000, 0 + batteries[0].detail.Type = batteryTypeVehicle + site.applyLmOptimizerInputs(&req, batteries) + assert.Equal(t, float32(7000), batteries[0].cfg.CMax) + assert.Equal(t, 2, batteries[0].cfg.CPriority) + + for prio, want := range map[int]int{0: 0, 3: 0, 4: 1, 6: 1, 7: 2, 10: 2} { + assert.Equal(t, want, optimizerPriority(prio), "priority %d", prio) + } +} + +func TestSlotAfter(t *testing.T) { + dt := []int{300, 900, 900, 3600} + assert.Equal(t, 0, slotAfter(dt, 5*time.Minute)) + assert.Equal(t, 1, slotAfter(dt, 10*time.Minute)) + assert.Equal(t, 3, slotAfter(dt, time.Hour)) + assert.Equal(t, 3, slotAfter(dt, 48*time.Hour), "capped at the horizon") +} diff --git a/core/site_optimizer_replay_test.go b/core/site_optimizer_replay_test.go new file mode 100644 index 0000000000..c81b32efb5 --- /dev/null +++ b/core/site_optimizer_replay_test.go @@ -0,0 +1,181 @@ +package core + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "testing" + "time" + + "github.com/evcc-io/evcc/util" + optimizer "github.com/evcc-io/optimizer/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestLmOptimizerReplay sends a recorded optimizer request with the fork's +// inputs to a running optimizer and checks that its plan respects them. It only +// runs when OPTIMIZER_REPLAY names a file {"req": ..., "details": ...} as the +// optimize page shows it and OPTIMIZER_URI a running optimizer. +func TestLmOptimizerReplay(t *testing.T) { + file, uri := os.Getenv("OPTIMIZER_REPLAY"), os.Getenv("OPTIMIZER_URI") + if file == "" || uri == "" { + t.Skip("OPTIMIZER_REPLAY and OPTIMIZER_URI not set") + } + + b, err := os.ReadFile(file) + require.NoError(t, err) + + var rec struct { + Req optimizer.OptimizationInput `json:"req"` + Details struct { + BatteryDetails []batteryDetail `json:"batteryDetails"` + } `json:"details"` + } + require.NoError(t, json.Unmarshal(b, &rec)) + require.Len(t, rec.Details.BatteryDetails, len(rec.Req.Batteries)) + + client, err := optimizer.NewClientWithResponses(uri, optimizer.WithHTTPClient(&http.Client{Timeout: 90 * time.Second})) + require.NoError(t, err) + + solve := func(t *testing.T, req optimizer.OptimizationInput) (*optimizer.OptimizationResult, time.Duration) { + start := time.Now() + resp, err := client.PostOptimizeChargeScheduleWithResponse(context.Background(), req) + elapsed := time.Since(start) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode(), string(resp.Body)) + require.NotNil(t, resp.JSON200) + return resp.JSON200, elapsed + } + + home := -1 + for i, d := range rec.Details.BatteryDetails { + if d.Type == batteryTypeBattery { + home = i + } + } + require.GreaterOrEqual(t, home, 0, "no home battery in the recording") + + peakW := func(res *optimizer.OptimizationResult, dt []int) float64 { + var peak float64 + for i, wh := range res.GridImport { + peak = max(peak, float64(wh)/float64(dt[i])*3600) + } + return peak + } + + // optional overrides for realistic cases: grid price in EUR/kWh and the + // home battery's initial soc in % + if v := os.Getenv("REPLAY_GRID_PRICE"); v != "" { + var price float64 + _, err := fmt.Sscanf(v, "%f", &price) + require.NoError(t, err) + for i := range rec.Req.TimeSeries.PN { + rec.Req.TimeSeries.PN[i] = float32(price / 1000) + } + } + if v := os.Getenv("REPLAY_SOC"); v != "" { + var soc float64 + _, err := fmt.Sscanf(v, "%f", &soc) + require.NoError(t, err) + b := &rec.Req.Batteries[home] + b.SInitial = b.SCapacity * float32(soc) / 100 + b.SMin = min(b.SMin, b.SInitial) + } + + // upstream request as recorded + base, elapsed := solve(t, rec.Req) + minSoc := func(res *optimizer.OptimizationResult) float64 { + soc := res.Batteries[home].StateOfCharge + m := float64(soc[0]) + for _, v := range soc { + m = min(m, float64(v)) + } + return m / float64(rec.Req.Batteries[home].SCapacity) * 100 + } + t.Logf("recorded request: %s, %d steps, %v, grid peak %.0f W, battery min %.1f%%", base.Status, len(rec.Req.TimeSeries.Dt), elapsed.Round(time.Millisecond), peakW(base, rec.Req.TimeSeries.Dt), minSoc(base)) + + // scenario runs the recorded request with the fork's inputs for the given + // settings and checks the plan respects them + // goalMayMiss: the goal may fall short while the peak limit, which is hard, + // leaves no room for it + scenario := func(t *testing.T, limit, reserve, start, stop float64, running bool, initialSoc float64, goalMayMiss bool) { + site := &Site{log: util.NewLogger("replay")} + setPeakShaving(site, limit, reserve) + s := site.lms() + s.socChargeEnabled, s.socChargeStart, s.socChargeStop, s.socChargeRunning = true, start, stop, running + + req := rec.Req + batteries := make([]optimizerBattery, len(req.Batteries)) + for i := range req.Batteries { + batteries[i] = optimizerBattery{cfg: req.Batteries[i], detail: rec.Details.BatteryDetails[i]} + } + batteries[home].cfg.ChargeFromGrid = true // as with a battery that has a charge mode + if initialSoc > 0 { + batteries[home].cfg.SInitial = batteries[home].cfg.SCapacity * float32(initialSoc) / 100 + } + + site.applyLmOptimizerInputs(&req, batteries) + + req.Batteries = make([]optimizer.BatteryConfig, len(batteries)) + for i, b := range batteries { + req.Batteries[i] = b.cfg + } + bat := req.Batteries[home] + + res, elapsed := solve(t, req) + t.Logf("%s, %v, limit %.0f W, grid peak %.0f W, min soc %.0f Wh, battery min %.1f%%", res.Status, elapsed.Round(time.Millisecond), limit, peakW(res, req.TimeSeries.Dt), bat.SMin, minSoc(res)) + + soc := res.Batteries[home].StateOfCharge + require.NotEmpty(t, soc) + + for i, v := range soc { + assert.GreaterOrEqual(t, v, bat.SMin-1, "soc below the minimum at step %d", i) + } + + if running && bat.SGoal != nil { + i := slotAfter(req.TimeSeries.Dt, site.gridChargeWindow()) + t.Logf("goal %.0f Wh at step %d, planned %.0f Wh", bat.SGoal[i], i, soc[i]) + if !goalMayMiss { + assert.GreaterOrEqual(t, soc[i], bat.SGoal[i]-1, "goal not reached at step %d", i) + } + } + + // the hard limit holds unless the optimizer reports it could not be kept + for i, wh := range res.GridImport { + w := float64(wh) / float64(req.TimeSeries.Dt[i]) * 3600 + var over float64 + if i < len(res.GridImportOvershoot) { + over = float64(res.GridImportOvershoot[i]) + } + if w > limit+1 && over <= 0 { + assert.Failf(t, "grid import above the limit", "step %d: %.0f W > %.0f W", i, w, limit) + } + } + t.Logf("limit violations: %+v", res.LimitViolations) + } + + // the settings of the installation: REPLAY_SETTINGS="limit,reserve,start,stop" + if v := os.Getenv("REPLAY_SETTINGS"); v != "" { + var limit, reserve, start, stop float64 + _, err := fmt.Sscanf(v, "%f,%f,%f,%f", &limit, &reserve, &start, &stop) + require.NoError(t, err) + t.Run("settings", func(t *testing.T) { scenario(t, limit, reserve, start, stop, false, 0, false) }) + } + + peak := peakW(base, rec.Req.TimeSeries.Dt) + + // nearly empty battery, grid charging running, room for the charging on top + // of the recorded peak: the goal is reached within the window + t.Run("charge", func(t *testing.T) { + scenario(t, peak+float64(rec.Req.Batteries[home].CMax), 40, 30, 90, true, 25, false) + }) + + // the same with the limit below the recorded peak: the hard peak limit wins, + // the goal may fall short + t.Run("conflict", func(t *testing.T) { + scenario(t, max(1000, 0.6*peak), 40, 30, 90, true, 25, true) + }) +} diff --git a/i18n/de.json b/i18n/de.json index e57bcc23ee..7e721b3861 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -79,6 +79,20 @@ "off": "Inaktiv", "range": "Laden startet bei {start} oder darunter und stoppt bei {stop}.", "waiting": "Wartet auf den Startladestand" + }, + "gridChargeOnce": { + "title": "Einmalig laden", + "sentence": "Einmalig bis {soc} aus dem Netz laden", + "now": "sofort", + "until": "bis Uhrzeit", + "byTime": "bis {time}, zur günstigsten Zeit", + "timeLabel": "Uhrzeit", + "start": "Starten", + "cancel": "Abbrechen", + "running": "Lädt einmalig bis {soc}", + "active": "lädt", + "waiting": "wartet auf die günstigste Zeit", + "help": "Endet von selbst beim Ziel-Ladestand, läuft über Neustarts weiter. Lastmanagement und Peak-Grenze gelten." } }, "batterySettings": { @@ -525,7 +539,9 @@ "phasesLabel": "Phasen der Batterie", "timeoutHelp": "So lange hält ein wartender Verbraucher höherer Priorität Leistung frei. Standard {std} min.", "timeoutLabel": "Ablaufzeit der Reservierung", - "title": "Erweiterte Einstellungen" + "title": "Erweiterte Einstellungen", + "gridChargeWindowLabel": "Netzlade-Ziel erreichen in", + "gridChargeWindowHelp": "Mit Optimizer: So lange darf er brauchen, um beim Netzladen nach Ladestand den Stopp-Ladestand zu erreichen. Er wählt dafür die günstigste Zeit. Standard {std} h." }, "lmpriorities": { "battery": "Hausbatterie (Netzladen)", diff --git a/i18n/en.json b/i18n/en.json index 4476b5f910..6e720c4526 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -79,6 +79,20 @@ "off": "Inactive", "range": "Charging starts at {start} or below and stops at {stop}.", "waiting": "Waiting for the start state of charge" + }, + "gridChargeOnce": { + "title": "Charge once", + "sentence": "Charge once from the grid up to {soc}", + "now": "right away", + "until": "by a time", + "byTime": "by {time}, at the cheapest time", + "timeLabel": "Time", + "start": "Start", + "cancel": "Cancel", + "running": "Charging once up to {soc}", + "active": "charging", + "waiting": "waiting for the cheapest time", + "help": "Ends by itself at the target soc and continues across restarts. Load management and the peak limit apply." } }, "batterySettings": { @@ -525,7 +539,9 @@ "phasesLabel": "Battery phases", "timeoutHelp": "How long a waiting higher priority load keeps power reserved. Default {std} min.", "timeoutLabel": "Reservation expiry", - "title": "Advanced settings" + "title": "Advanced settings", + "gridChargeWindowLabel": "Reach the grid charge goal within", + "gridChargeWindowHelp": "With the optimizer: how long it may take to reach the stop soc of soc-based grid charging. It picks the cheapest time. Default {std} h." }, "lmpriorities": { "battery": "Home battery (grid charging)", diff --git a/server/http_custom.go b/server/http_custom.go index ad1717d4b0..cbd6dc11db 100644 --- a/server/http_custom.go +++ b/server/http_custom.go @@ -8,6 +8,7 @@ import ( "encoding/json" "net/http" "strconv" + "time" "github.com/evcc-io/evcc/core/lm/profile" "github.com/evcc-io/evcc/core/site" @@ -25,6 +26,11 @@ func customSiteRoutes(site site.API) map[string]route { "batterysocgridchargestart": {"POST", "/batterysocgridchargestart/{value:[0-9.]+}", floatHandler(site.SetBatterySocGridChargeStart, site.GetBatterySocGridChargeStart)}, "batterysocgridchargestop": {"POST", "/batterysocgridchargestop/{value:[0-9.]+}", floatHandler(site.SetBatterySocGridChargeStop, site.GetBatterySocGridChargeStop)}, + // one-time grid charging, see core/site_lm_once.go + "batterygridchargeonce": {"POST", "/batterygridchargeonce/{soc:[0-9]+}", gridChargeOnceHandler(site)}, + "batterygridchargeonceuntil": {"POST", "/batterygridchargeonce/{soc:[0-9]+}/{until:[0-9]{2}:[0-9]{2}}", gridChargeOnceHandler(site)}, + "batterygridchargeoncecancel": {"DELETE", "/batterygridchargeonce", gridChargeOnceCancelHandler(site)}, + // load management shed priorities, see core/site_lm.go "lmpriority": {"POST", "/lmpriority/{name:" + namePattern + "}/{value:[0-9]+}", lmPriorityHandler(site)}, @@ -166,3 +172,49 @@ func lmPriorityHandler(site site.API) http.HandlerFunc { jsonWrite(w, prio) } } + +// gridChargeOnceHandler starts one-time grid charging up to a soc, right away +// or at the cheapest slots before the next occurrence of a time of day (HH:MM) +func gridChargeOnceHandler(site site.API) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + + soc, err := strconv.ParseFloat(vars["soc"], 64) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + var until time.Time + if s := vars["until"]; s != "" { + t, err := time.ParseInLocation("15:04", s, time.Local) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + now := time.Now() + until = time.Date(now.Year(), now.Month(), now.Day(), t.Hour(), t.Minute(), 0, 0, time.Local) + if !until.After(now) { + until = until.AddDate(0, 0, 1) + } + } + + if err := site.SetBatteryGridChargeOnce(soc, until); err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + jsonWrite(w, soc) + } +} + +// gridChargeOnceCancelHandler stops one-time grid charging +func gridChargeOnceCancelHandler(site site.API) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := site.CancelBatteryGridChargeOnce(); err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + jsonWrite(w, true) + } +}