diff --git a/api/prioritybasis_enumer.go b/api/prioritybasis_enumer.go new file mode 100644 index 00000000000..d943afdcd1b --- /dev/null +++ b/api/prioritybasis_enumer.go @@ -0,0 +1,90 @@ +// Code generated by "enumer -type PriorityBasis -trimprefix PriorityBasis -transform=lower -text"; DO NOT EDIT. + +package api + +import ( + "fmt" + "strings" +) + +const _PriorityBasisName = "percentenergy" + +var _PriorityBasisIndex = [...]uint8{0, 7, 13} + +const _PriorityBasisLowerName = "percentenergy" + +func (i PriorityBasis) String() string { + if i < 0 || i >= PriorityBasis(len(_PriorityBasisIndex)-1) { + return fmt.Sprintf("PriorityBasis(%d)", i) + } + return _PriorityBasisName[_PriorityBasisIndex[i]:_PriorityBasisIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _PriorityBasisNoOp() { + var x [1]struct{} + _ = x[PriorityBasisPercent-(0)] + _ = x[PriorityBasisEnergy-(1)] +} + +var _PriorityBasisValues = []PriorityBasis{PriorityBasisPercent, PriorityBasisEnergy} + +var _PriorityBasisNameToValueMap = map[string]PriorityBasis{ + _PriorityBasisName[0:7]: PriorityBasisPercent, + _PriorityBasisLowerName[0:7]: PriorityBasisPercent, + _PriorityBasisName[7:13]: PriorityBasisEnergy, + _PriorityBasisLowerName[7:13]: PriorityBasisEnergy, +} + +var _PriorityBasisNames = []string{ + _PriorityBasisName[0:7], + _PriorityBasisName[7:13], +} + +// PriorityBasisString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func PriorityBasisString(s string) (PriorityBasis, error) { + if val, ok := _PriorityBasisNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _PriorityBasisNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to PriorityBasis values", s) +} + +// PriorityBasisValues returns all values of the enum +func PriorityBasisValues() []PriorityBasis { + return _PriorityBasisValues +} + +// PriorityBasisStrings returns a slice of all String values of the enum +func PriorityBasisStrings() []string { + strs := make([]string, len(_PriorityBasisNames)) + copy(strs, _PriorityBasisNames) + return strs +} + +// IsAPriorityBasis returns "true" if the value is listed in the enum definition. "false" otherwise +func (i PriorityBasis) IsAPriorityBasis() bool { + for _, v := range _PriorityBasisValues { + if i == v { + return true + } + } + return false +} + +// MarshalText implements the encoding.TextMarshaler interface for PriorityBasis +func (i PriorityBasis) MarshalText() ([]byte, error) { + return []byte(i.String()), nil +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface for PriorityBasis +func (i *PriorityBasis) UnmarshalText(text []byte) error { + var err error + *i, err = PriorityBasisString(string(text)) + return err +} diff --git a/api/prioritystrategy.go b/api/prioritystrategy.go new file mode 100644 index 00000000000..927bec34c32 --- /dev/null +++ b/api/prioritystrategy.go @@ -0,0 +1,22 @@ +package api + +// PriorityStrategy determines how a loadpoint is ranked against other loadpoints +// of the same priority when distributing surplus power. +type PriorityStrategy int + +//go:generate go tool enumer -type PriorityStrategy -trimprefix Priority -transform=lower -text +const ( + PriorityNone PriorityStrategy = iota // no sub-ordering (default) + PrioritySoc // prefer the lower vehicle soc + PriorityDeficit // prefer the larger gap to limit soc +) + +// PriorityBasis determines whether a priority strategy ranks loadpoints by soc +// percentage or by absolute energy (kWh). +type PriorityBasis int + +//go:generate go tool enumer -type PriorityBasis -trimprefix PriorityBasis -transform=lower -text +const ( + PriorityBasisPercent PriorityBasis = iota // rank by soc-% (default) + PriorityBasisEnergy // rank by absolute energy (kWh) +) diff --git a/api/prioritystrategy_enumer.go b/api/prioritystrategy_enumer.go new file mode 100644 index 00000000000..8b9b2c92b42 --- /dev/null +++ b/api/prioritystrategy_enumer.go @@ -0,0 +1,94 @@ +// Code generated by "enumer -type PriorityStrategy -trimprefix Priority -transform=lower -text"; DO NOT EDIT. + +package api + +import ( + "fmt" + "strings" +) + +const _PriorityStrategyName = "nonesocdeficit" + +var _PriorityStrategyIndex = [...]uint8{0, 4, 7, 14} + +const _PriorityStrategyLowerName = "nonesocdeficit" + +func (i PriorityStrategy) String() string { + if i < 0 || i >= PriorityStrategy(len(_PriorityStrategyIndex)-1) { + return fmt.Sprintf("PriorityStrategy(%d)", i) + } + return _PriorityStrategyName[_PriorityStrategyIndex[i]:_PriorityStrategyIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _PriorityStrategyNoOp() { + var x [1]struct{} + _ = x[PriorityNone-(0)] + _ = x[PrioritySoc-(1)] + _ = x[PriorityDeficit-(2)] +} + +var _PriorityStrategyValues = []PriorityStrategy{PriorityNone, PrioritySoc, PriorityDeficit} + +var _PriorityStrategyNameToValueMap = map[string]PriorityStrategy{ + _PriorityStrategyName[0:4]: PriorityNone, + _PriorityStrategyLowerName[0:4]: PriorityNone, + _PriorityStrategyName[4:7]: PrioritySoc, + _PriorityStrategyLowerName[4:7]: PrioritySoc, + _PriorityStrategyName[7:14]: PriorityDeficit, + _PriorityStrategyLowerName[7:14]: PriorityDeficit, +} + +var _PriorityStrategyNames = []string{ + _PriorityStrategyName[0:4], + _PriorityStrategyName[4:7], + _PriorityStrategyName[7:14], +} + +// PriorityStrategyString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func PriorityStrategyString(s string) (PriorityStrategy, error) { + if val, ok := _PriorityStrategyNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _PriorityStrategyNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to PriorityStrategy values", s) +} + +// PriorityStrategyValues returns all values of the enum +func PriorityStrategyValues() []PriorityStrategy { + return _PriorityStrategyValues +} + +// PriorityStrategyStrings returns a slice of all String values of the enum +func PriorityStrategyStrings() []string { + strs := make([]string, len(_PriorityStrategyNames)) + copy(strs, _PriorityStrategyNames) + return strs +} + +// IsAPriorityStrategy returns "true" if the value is listed in the enum definition. "false" otherwise +func (i PriorityStrategy) IsAPriorityStrategy() bool { + for _, v := range _PriorityStrategyValues { + if i == v { + return true + } + } + return false +} + +// MarshalText implements the encoding.TextMarshaler interface for PriorityStrategy +func (i PriorityStrategy) MarshalText() ([]byte, error) { + return []byte(i.String()), nil +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface for PriorityStrategy +func (i *PriorityStrategy) UnmarshalText(text []byte) error { + var err error + *i, err = PriorityStrategyString(string(text)) + return err +} diff --git a/assets/js/components/Config/LoadpointModal.vue b/assets/js/components/Config/LoadpointModal.vue index 96bd3af05c2..03a27f6fea7 100644 --- a/assets/js/components/Config/LoadpointModal.vue +++ b/assets/js/components/Config/LoadpointModal.vue @@ -300,6 +300,57 @@ /> + + + + + + + + + + + +
{{ $t("config.loadpoint.electricalTitle") }} {{ @@ -634,6 +685,8 @@ import { getModal, openModal, replaceModal, closeModal } from "@/configModal"; import { CHARGE_MODE, LOADPOINT_TYPE, + PRIORITY_STRATEGY, + PRIORITY_BASIS, type DeviceType, type LoadpointType, type ConfigCharger, @@ -654,6 +707,9 @@ const defaultValues = { minCurrent: 6, maxCurrent: 16, priority: 0, + priorityStrategy: PRIORITY_STRATEGY.NONE, + priorityBasis: PRIORITY_BASIS.PERCENT, + priorityHysteresis: 0, defaultMode: "", thresholds: { enable: { delay: 1 * nsPerMin, threshold: 0 }, @@ -795,6 +851,58 @@ export default { result[10]!.name = "10 (highest)"; return result; }, + priorityStrategy: { + // fall back to the none (default) strategy + get(): PRIORITY_STRATEGY { + return this.values.priorityStrategy || PRIORITY_STRATEGY.NONE; + }, + set(value: PRIORITY_STRATEGY) { + this.values.priorityStrategy = value; + }, + }, + priorityStrategyOptions(): { key: PRIORITY_STRATEGY; name: string }[] { + return [ + { + key: PRIORITY_STRATEGY.NONE, + name: this.$t("config.loadpoint.priorityStrategyNone"), + }, + { + key: PRIORITY_STRATEGY.SOC, + name: this.$t("config.loadpoint.priorityStrategySoc"), + }, + { + key: PRIORITY_STRATEGY.DEFICIT, + name: this.$t("config.loadpoint.priorityStrategyDeficit"), + }, + ]; + }, + priorityBasis: { + // backend returns "" for the percent basis; map to/from the explicit "percent" choice + get(): PRIORITY_BASIS { + return this.values.priorityBasis || PRIORITY_BASIS.PERCENT; + }, + set(value: PRIORITY_BASIS) { + this.values.priorityBasis = value; + }, + }, + priorityBasisOptions(): { key: PRIORITY_BASIS; name: string }[] { + return [ + { + key: PRIORITY_BASIS.PERCENT, + name: this.$t("config.loadpoint.priorityBasisPercent"), + }, + { + key: PRIORITY_BASIS.ENERGY, + name: this.$t("config.loadpoint.priorityBasisEnergy"), + }, + ]; + }, + priorityHysteresisAvailable(): boolean { + return this.priorityStrategy !== PRIORITY_STRATEGY.NONE; + }, + priorityHysteresisUnit(): string { + return this.priorityBasis === PRIORITY_BASIS.ENERGY ? "kWh" : "%"; + }, phasesOptions() { return [ { value: 1, name: this.$t("config.loadpoint.phases1p") }, diff --git a/assets/js/components/Loadpoints/SettingsModal.vue b/assets/js/components/Loadpoints/SettingsModal.vue index cc441bd50ad..4dd2bd2d01f 100644 --- a/assets/js/components/Loadpoints/SettingsModal.vue +++ b/assets/js/components/Loadpoints/SettingsModal.vue @@ -124,6 +124,91 @@ + +
+ {{ $t("main.loadpointSettings.priority.heading") }} +
+
+ +
+ +
+
+ + {{ $t("main.loadpointSettings.priorityStrategy.help") }} + +
+
+
+ +
+ +
+
+ + {{ $t("main.loadpointSettings.priorityBasis.help") }} + +
+
+
+ +
+ + {{ priorityHysteresisUnit }} +
+
+ + {{ $t("main.loadpointSettings.priorityHysteresis.help") }} + +
+
@@ -136,7 +221,15 @@ import SmartCostLimit from "../Tariff/SmartCostLimit.vue"; import SmartFeedInPriority from "../Tariff/SmartFeedInPriority.vue"; import SettingsBatteryBoost from "./SettingsBatteryBoost.vue"; import { defineComponent, type PropType } from "vue"; -import { PHASES, CURRENCY, SMART_COST_TYPE, type Forecast, type UiLoadpoint } from "@/types/evcc"; +import { + PHASES, + CURRENCY, + SMART_COST_TYPE, + PRIORITY_STRATEGY, + PRIORITY_BASIS, + type Forecast, + type UiLoadpoint, +} from "@/types/evcc"; import api from "@/api"; const V = 230; @@ -181,6 +274,9 @@ export default defineComponent({ selectedMaxCurrent: undefined as number | undefined, selectedMinCurrent: undefined as number | undefined, selectedPhases: undefined as number | undefined, + selectedPriorityStrategy: PRIORITY_STRATEGY.NONE as PRIORITY_STRATEGY, + selectedPriorityBasis: PRIORITY_BASIS.PERCENT as PRIORITY_BASIS, + selectedPriorityHysteresis: 0 as number, isModalVisible: false, }; }, @@ -244,6 +340,52 @@ export default defineComponent({ batteryBoostAvailable() { return this.batteryConfigured; }, + priorityStrategy(): PRIORITY_STRATEGY { + // fall back to the none (default) strategy + return this.loadpoint?.priorityStrategy || PRIORITY_STRATEGY.NONE; + }, + priorityBasis(): PRIORITY_BASIS { + // published state sends "" for the percent (default) basis + return this.loadpoint?.priorityBasis || PRIORITY_BASIS.PERCENT; + }, + priorityHysteresis(): number { + return this.loadpoint?.priorityHysteresis ?? 0; + }, + priorityStrategyOptions(): { value: PRIORITY_STRATEGY; name: string }[] { + return [ + { + value: PRIORITY_STRATEGY.NONE, + name: this.$t("main.loadpointSettings.priorityStrategy.none"), + }, + { + value: PRIORITY_STRATEGY.SOC, + name: this.$t("main.loadpointSettings.priorityStrategy.soc"), + }, + { + value: PRIORITY_STRATEGY.DEFICIT, + name: this.$t("main.loadpointSettings.priorityStrategy.deficit"), + }, + ]; + }, + priorityBasisOptions(): { value: PRIORITY_BASIS; name: string }[] { + return [ + { + value: PRIORITY_BASIS.PERCENT, + name: this.$t("main.loadpointSettings.priorityBasis.percent"), + }, + { + value: PRIORITY_BASIS.ENERGY, + name: this.$t("main.loadpointSettings.priorityBasis.energy"), + }, + ]; + }, + priorityHysteresisAvailable(): boolean { + // hysteresis only affects soc/deficit sub-ordering, not the none strategy + return this.selectedPriorityStrategy !== PRIORITY_STRATEGY.NONE; + }, + priorityHysteresisUnit(): string { + return this.selectedPriorityBasis === PRIORITY_BASIS.ENERGY ? "kWh" : "%"; + }, }, watch: { maxCurrent(value) { @@ -255,6 +397,15 @@ export default defineComponent({ phasesConfigured(value) { this.selectedPhases = value; }, + priorityStrategy(value) { + this.selectedPriorityStrategy = value; + }, + priorityBasis(value) { + this.selectedPriorityBasis = value; + }, + priorityHysteresis(value) { + this.selectedPriorityHysteresis = value; + }, }, methods: { open(loadpointId: string) { @@ -262,6 +413,9 @@ export default defineComponent({ this.selectedPhases = this.phasesConfigured; this.selectedMaxCurrent = this.maxCurrent; this.selectedMinCurrent = this.minCurrent; + this.selectedPriorityStrategy = this.priorityStrategy; + this.selectedPriorityBasis = this.priorityBasis; + this.selectedPriorityHysteresis = this.priorityHysteresis; const modalRef = this.$refs["modal"] as InstanceType | undefined; modalRef?.open(); }, @@ -286,6 +440,20 @@ export default defineComponent({ setBatteryBoostLimit(limit: number) { api.post(this.apiPath("batteryboostlimit") + "/" + limit); }, + setPriorityStrategy() { + api.post(this.apiPath("prioritystrategy") + "/" + this.selectedPriorityStrategy); + }, + setPriorityBasis() { + api.post(this.apiPath("prioritybasis") + "/" + this.selectedPriorityBasis); + }, + setPriorityHysteresis() { + const value = Math.min( + 99, + Math.max(0, Math.round(this.selectedPriorityHysteresis || 0)) + ); + this.selectedPriorityHysteresis = value; + api.post(this.apiPath("priorityhysteresis") + "/" + value); + }, currentOption(current: number, isDefault: boolean, phases?: number) { const kw = this.fmtPhasePower(current, phases); let name = `${this.fmtNumber(current, undefined)} A (${kw})`; diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index bb8305bf59a..10016649301 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -249,6 +249,9 @@ export interface ConfigLoadpoint { title: string; defaultMode: string; priority: number; + priorityStrategy: PRIORITY_STRATEGY; + priorityBasis: PRIORITY_BASIS; + priorityHysteresis: number; phasesConfigured: number; minCurrent: number; maxCurrent: number; @@ -321,6 +324,7 @@ export interface Loadpoint { effectivePlanTime: string | null; effectivePlanStrategy: PlanStrategy; effectivePriority: number; + effectivePriorityScore: number; enableDelay: number; enableThreshold: number; enabled: boolean; @@ -343,6 +347,9 @@ export interface Loadpoint { planProjectedStart: string | null; planTime: string | null; priority: number; + priorityStrategy: PRIORITY_STRATEGY; + priorityBasis: PRIORITY_BASIS; + priorityHysteresis: number; pvAction: PV_ACTION; pvRemaining: number; sessionCo2PerKWh: number | null; @@ -466,6 +473,17 @@ export enum PV_ACTION { DISABLE = "disable", } +export enum PRIORITY_STRATEGY { + NONE = "none", + SOC = "soc", + DEFICIT = "deficit", +} + +export enum PRIORITY_BASIS { + PERCENT = "percent", + ENERGY = "energy", +} + export enum CHARGER_STATUS_REASON { UNKNOWN = "unknown", WAITING_FOR_AUTHORIZATION = "waitingforauthorization", diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go index 3384ed4fcc2..a3e4cc1dd2e 100644 --- a/core/keys/loadpoint.go +++ b/core/keys/loadpoint.go @@ -2,30 +2,33 @@ package keys const ( // loadpoint settings - Name = "name" // loadpoint name (config identifier) - Title = "title" // loadpoint title - Mode = "mode" // charge mode - DefaultMode = "defaultMode" // default charge mode - Charger = "charger" // charger ref - Meter = "meter" // meter ref - Circuit = "circuit" // circuit ref - DefaultVehicle = "vehicle" // default vehicle ref - Priority = "priority" // priority - MinCurrent = "minCurrent" // min current - MaxCurrent = "maxCurrent" // max current - MinSoc = "minSoc" // min soc - MinSocNotReached = "minSocNotReached" // min soc not reached - LimitSoc = "limitSoc" // limit soc - LimitEnergy = "limitEnergy" // limit energy - Soc = "soc" - Thresholds = "thresholds" - UI = "ui" // display-only ui settings (json) - EnableThreshold = "enableThreshold" - DisableThreshold = "disableThreshold" - EnableDelay = "enableDelay" - DisableDelay = "disableDelay" - BatteryBoost = "batteryBoost" - BatteryBoostLimit = "batteryBoostLimit" + Name = "name" // loadpoint name (config identifier) + Title = "title" // loadpoint title + Mode = "mode" // charge mode + DefaultMode = "defaultMode" // default charge mode + Charger = "charger" // charger ref + Meter = "meter" // meter ref + Circuit = "circuit" // circuit ref + DefaultVehicle = "vehicle" // default vehicle ref + Priority = "priority" // priority + PriorityStrategy = "priorityStrategy" // priority strategy (none, soc, deficit) + PriorityBasis = "priorityBasis" // priority strategy basis (percent, energy) + PriorityHysteresis = "priorityHysteresis" // priority sub-ordering deadband (soc-% or kWh per basis, 0 = off) + MinCurrent = "minCurrent" // min current + MaxCurrent = "maxCurrent" // max current + MinSoc = "minSoc" // min soc + MinSocNotReached = "minSocNotReached" // min soc not reached + LimitSoc = "limitSoc" // limit soc + LimitEnergy = "limitEnergy" // limit energy + Soc = "soc" + Thresholds = "thresholds" + UI = "ui" // display-only ui settings (json) + EnableThreshold = "enableThreshold" + DisableThreshold = "disableThreshold" + EnableDelay = "enableDelay" + DisableDelay = "disableDelay" + BatteryBoost = "batteryBoost" + BatteryBoostLimit = "batteryBoostLimit" PhasesConfigured = "phasesConfigured" // desired phase mode (0/1/3, 0 = automatic), user selection PhasesActive = "phasesActive" // expectedly active phases, taking vehicle into account (1/2/3) @@ -55,12 +58,13 @@ const ( SmartFeedInPriorityNextStart = "smartFeedInPriorityNextStart" // smart feed-in priority next start, time of next pause // effective values - EffectivePriority = "effectivePriority" // effective priority - EffectivePlanId = "effectivePlanId" // effective plan id - EffectivePlanTime = "effectivePlanTime" // effective plan time - EffectivePlanSoc = "effectivePlanSoc" // effective plan soc - EffectiveMinCurrent = "effectiveMinCurrent" // effective min current - EffectiveMaxCurrent = "effectiveMaxCurrent" // effective max current + EffectivePriority = "effectivePriority" // effective priority + EffectivePriorityScore = "effectivePriorityScore" // effective priority score (tier + strategy sub-ordering) + EffectivePlanId = "effectivePlanId" // effective plan id + EffectivePlanTime = "effectivePlanTime" // effective plan time + EffectivePlanSoc = "effectivePlanSoc" // effective plan soc + EffectiveMinCurrent = "effectiveMinCurrent" // effective min current + EffectiveMaxCurrent = "effectiveMaxCurrent" // effective max current EffectiveLimitSoc = "effectiveLimitSoc" // effective limit soc EffectivePlanStrategy = "effectivePlanStrategy" // effective plan strategy diff --git a/core/loadpoint.go b/core/loadpoint.go index 92bd04778ec..6ba7a94549a 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -101,9 +101,12 @@ type Loadpoint struct { ui loadpoint.UIConfig // display-only, not used in control logic // from yaml - DefaultMode api.ChargeMode `mapstructure:"mode"` // Default charge mode, used for disconnect - Title string `mapstructure:"title"` // UI title - Priority int `mapstructure:"priority"` // Priority + DefaultMode api.ChargeMode `mapstructure:"mode"` // Default charge mode, used for disconnect + Title string `mapstructure:"title"` // UI title + Priority int `mapstructure:"priority"` // Priority + PriorityStrategy api.PriorityStrategy `mapstructure:"priorityStrategy"` // Priority strategy (none, soc, deficit) + PriorityBasis api.PriorityBasis `mapstructure:"priorityBasis"` // Priority strategy basis (percent, energy) + PriorityHysteresis int `mapstructure:"priorityHysteresis"` // Priority sub-ordering deadband (soc-% or kWh per basis, 0 = off) // from yaml, deprecated GuardDuration_ time.Duration `mapstructure:"guardduration"` // ignored, present for compatibility @@ -111,17 +114,20 @@ type Loadpoint struct { MinCurrent_ float64 `mapstructure:"minCurrent"` // ignored, present for compatibility MaxCurrent_ float64 `mapstructure:"maxCurrent"` // ignored, present for compatibility - title string // UI title - priority int // Priority - minCurrent float64 // PV mode: start current Min+PV mode: min current - maxCurrent float64 // Max allowed current. Physically ensured by the charger - phasesConfigured int // Charger configured phase mode 0/1/3 - limitSoc int // Session limit for soc - limitEnergy float64 // Session limit for energy - smartCostLimit *float64 // always charge if consumption cost is below this value - smartFeedInPriorityLimit *float64 // prevent charging if feed-in cost is above this value - batteryBoost int // battery boost state - batteryBoostLimit int // battery boost soc limit (0-100, 100=disabled) + title string // UI title + priority int // Priority + priorityStrategy api.PriorityStrategy // Priority strategy (none, soc, deficit) + priorityBasis api.PriorityBasis // Priority strategy basis (percent, energy) + priorityHysteresis int // Priority sub-ordering deadband (soc-% or kWh per basis, 0 = off) + minCurrent float64 // PV mode: start current Min+PV mode: min current + maxCurrent float64 // Max allowed current. Physically ensured by the charger + phasesConfigured int // Charger configured phase mode 0/1/3 + limitSoc int // Session limit for soc + limitEnergy float64 // Session limit for energy + smartCostLimit *float64 // always charge if consumption cost is below this value + smartFeedInPriorityLimit *float64 // prevent charging if feed-in cost is above this value + batteryBoost int // battery boost state + batteryBoostLimit int // battery boost soc limit (0-100, 100=disabled) mode api.ChargeMode enabled bool // Charger enabled state @@ -219,6 +225,15 @@ func NewLoadpointFromConfig(log *util.Logger, settings settings.Settings, collec lp.setPriority(lp.Priority) } + // PriorityStrategy/PriorityBasis are validated at decode via their TextUnmarshaler + lp.priorityStrategy = lp.PriorityStrategy + lp.priorityBasis = lp.PriorityBasis + + if lp.PriorityHysteresis < 0 || lp.PriorityHysteresis > 99 { + return lp, fmt.Errorf("invalid priority hysteresis: %d (must be 0..99)", lp.PriorityHysteresis) + } + lp.priorityHysteresis = lp.PriorityHysteresis + if lp.CircuitRef != "" { dev, err := config.Circuits().ByName(lp.CircuitRef) if err != nil { @@ -349,6 +364,19 @@ func (lp *Loadpoint) restoreSettings() { if v, err := lp.settings.Int(keys.Priority); err == nil { lp.setPriority(int(v)) } + if v, err := lp.settings.String(keys.PriorityStrategy); err == nil { + if strategy, err := api.PriorityStrategyString(v); err == nil { + lp.setPriorityStrategy(strategy) + } + } + if v, err := lp.settings.String(keys.PriorityBasis); err == nil { + if basis, err := api.PriorityBasisString(v); err == nil { + lp.setPriorityBasis(basis) + } + } + if v, err := lp.settings.Int(keys.PriorityHysteresis); err == nil && v >= 0 && v <= 99 { + lp.setPriorityHysteresis(int(v)) + } if v, err := lp.settings.Int(keys.PhasesConfigured); err == nil && (v > 0 || lp.hasPhaseSwitching()) { lp.setPhasesConfigured(int(v)) } @@ -694,6 +722,9 @@ func (lp *Loadpoint) Prepare(site site.API, uiChan chan<- util.Param, pushChan c lp.publish(keys.Title, lp.GetTitle()) lp.publish(keys.Mode, lp.GetMode()) lp.publish(keys.Priority, lp.GetPriority()) + lp.publish(keys.PriorityStrategy, lp.GetPriorityStrategy()) + lp.publish(keys.PriorityBasis, lp.GetPriorityBasis()) + lp.publish(keys.PriorityHysteresis, lp.GetPriorityHysteresis()) lp.publish(keys.MinCurrent, lp.GetMinCurrent()) lp.publish(keys.MaxCurrent, lp.GetMaxCurrent()) diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go index 71051f4db39..85fd9ed0311 100644 --- a/core/loadpoint/api.go +++ b/core/loadpoint/api.go @@ -57,6 +57,18 @@ type API interface { GetPriority() int // SetPriority sets the priority SetPriority(int) + // GetPriorityStrategy returns the priority strategy + GetPriorityStrategy() api.PriorityStrategy + // SetPriorityStrategy sets the priority strategy + SetPriorityStrategy(api.PriorityStrategy) + // GetPriorityBasis returns the priority strategy basis (percent, energy) + GetPriorityBasis() api.PriorityBasis + // SetPriorityBasis sets the priority strategy basis (percent, energy) + SetPriorityBasis(api.PriorityBasis) + // GetPriorityHysteresis returns the priority sub-ordering deadband (soc-% or kWh per basis) + GetPriorityHysteresis() int + // SetPriorityHysteresis sets the priority sub-ordering deadband (soc-% or kWh per basis) + SetPriorityHysteresis(int) // GetMinCurrent returns the min charging current GetMinCurrent() float64 // SetMinCurrent sets the min charging current @@ -96,8 +108,8 @@ type API interface { // effective values // - // EffectivePriority returns the effective priority - EffectivePriority() int + // EffectivePriorityScore returns the sortable priority score (tier + strategy sub-ordering) for the given basis + EffectivePriorityScore(basis api.PriorityBasis) float64 // EffectiveLimitSoc returns the effective session limit soc EffectiveLimitSoc() int // EffectivePlanId returns the effective plan id diff --git a/core/loadpoint/config.go b/core/loadpoint/config.go index 9268d5cdd52..8649c806f59 100644 --- a/core/loadpoint/config.go +++ b/core/loadpoint/config.go @@ -34,6 +34,11 @@ type DynamicConfig struct { PlanStrategy api.PlanStrategy `json:"planStrategy"` + // priority sub-ordering within a tier (see api.PriorityStrategy) + PriorityStrategy api.PriorityStrategy `json:"priorityStrategy"` + PriorityBasis api.PriorityBasis `json:"priorityBasis"` + PriorityHysteresis int `json:"priorityHysteresis"` + Thresholds ThresholdsConfig `json:"thresholds"` Soc SocConfig `json:"soc"` UI UIConfig `json:"ui"` @@ -67,6 +72,9 @@ func SplitConfig(payload map[string]any) (DynamicConfig, map[string]any, error) func (payload DynamicConfig) Apply(lp API) error { lp.SetTitle(payload.Title) lp.SetPriority(payload.Priority) + lp.SetPriorityStrategy(payload.PriorityStrategy) + lp.SetPriorityBasis(payload.PriorityBasis) + lp.SetPriorityHysteresis(payload.PriorityHysteresis) lp.SetSmartCostLimit(payload.SmartCostLimit) lp.SetSmartFeedInPriorityLimit(payload.SmartFeedInPriorityLimit) lp.SetThresholds(payload.Thresholds) diff --git a/core/loadpoint/config_test.go b/core/loadpoint/config_test.go index 72c5b816674..d0be8ecf90c 100644 --- a/core/loadpoint/config_test.go +++ b/core/loadpoint/config_test.go @@ -3,6 +3,7 @@ package loadpoint import ( "testing" + "github.com/evcc-io/evcc/api" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -23,3 +24,24 @@ func TestSplitConfigUI(t *testing.T) { assert.Equal(t, 45.0, dynamic.UI.MaxTemp) assert.NotContains(t, other, "ui") } + +func TestSplitConfigPriority(t *testing.T) { + payload := map[string]any{ + "priority": 3, + "priorityStrategy": "soc", + "priorityBasis": "energy", + "priorityHysteresis": 5, + } + + dynamic, other, err := SplitConfig(payload) + require.NoError(t, err) + + assert.Equal(t, 3, dynamic.Priority) + assert.Equal(t, api.PrioritySoc, dynamic.PriorityStrategy) + assert.Equal(t, api.PriorityBasisEnergy, dynamic.PriorityBasis) + assert.Equal(t, 5, dynamic.PriorityHysteresis) + assert.NotContains(t, other, "priority") + assert.NotContains(t, other, "priorityStrategy") + assert.NotContains(t, other, "priorityBasis") + assert.NotContains(t, other, "priorityHysteresis") +} diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go index 3f43e60a4aa..7cf9889631c 100644 --- a/core/loadpoint/mock.go +++ b/core/loadpoint/mock.go @@ -151,18 +151,18 @@ func (mr *MockAPIMockRecorder) EffectivePlanTime() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectivePlanTime", reflect.TypeOf((*MockAPI)(nil).EffectivePlanTime)) } -// EffectivePriority mocks base method. -func (m *MockAPI) EffectivePriority() int { +// EffectivePriorityScore mocks base method. +func (m *MockAPI) EffectivePriorityScore(basis api.PriorityBasis) float64 { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "EffectivePriority") - ret0, _ := ret[0].(int) + ret := m.ctrl.Call(m, "EffectivePriorityScore", basis) + ret0, _ := ret[0].(float64) return ret0 } -// EffectivePriority indicates an expected call of EffectivePriority. -func (mr *MockAPIMockRecorder) EffectivePriority() *gomock.Call { +// EffectivePriorityScore indicates an expected call of EffectivePriorityScore. +func (mr *MockAPIMockRecorder) EffectivePriorityScore(basis any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectivePriority", reflect.TypeOf((*MockAPI)(nil).EffectivePriority)) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectivePriorityScore", reflect.TypeOf((*MockAPI)(nil).EffectivePriorityScore), basis) } // GetBatteryBoost mocks base method. @@ -559,6 +559,48 @@ func (mr *MockAPIMockRecorder) GetPriority() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPriority", reflect.TypeOf((*MockAPI)(nil).GetPriority)) } +// GetPriorityBasis mocks base method. +func (m *MockAPI) GetPriorityBasis() api.PriorityBasis { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPriorityBasis") + ret0, _ := ret[0].(api.PriorityBasis) + return ret0 +} + +// GetPriorityBasis indicates an expected call of GetPriorityBasis. +func (mr *MockAPIMockRecorder) GetPriorityBasis() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPriorityBasis", reflect.TypeOf((*MockAPI)(nil).GetPriorityBasis)) +} + +// GetPriorityHysteresis mocks base method. +func (m *MockAPI) GetPriorityHysteresis() int { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPriorityHysteresis") + ret0, _ := ret[0].(int) + return ret0 +} + +// GetPriorityHysteresis indicates an expected call of GetPriorityHysteresis. +func (mr *MockAPIMockRecorder) GetPriorityHysteresis() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPriorityHysteresis", reflect.TypeOf((*MockAPI)(nil).GetPriorityHysteresis)) +} + +// GetPriorityStrategy mocks base method. +func (m *MockAPI) GetPriorityStrategy() api.PriorityStrategy { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPriorityStrategy") + ret0, _ := ret[0].(api.PriorityStrategy) + return ret0 +} + +// GetPriorityStrategy indicates an expected call of GetPriorityStrategy. +func (mr *MockAPIMockRecorder) GetPriorityStrategy() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPriorityStrategy", reflect.TypeOf((*MockAPI)(nil).GetPriorityStrategy)) +} + // GetRemainingDuration mocks base method. func (m *MockAPI) GetRemainingDuration() time.Duration { m.ctrl.T.Helper() @@ -1005,6 +1047,42 @@ func (mr *MockAPIMockRecorder) SetPriority(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPriority", reflect.TypeOf((*MockAPI)(nil).SetPriority), arg0) } +// SetPriorityBasis mocks base method. +func (m *MockAPI) SetPriorityBasis(arg0 api.PriorityBasis) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetPriorityBasis", arg0) +} + +// SetPriorityBasis indicates an expected call of SetPriorityBasis. +func (mr *MockAPIMockRecorder) SetPriorityBasis(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPriorityBasis", reflect.TypeOf((*MockAPI)(nil).SetPriorityBasis), arg0) +} + +// SetPriorityHysteresis mocks base method. +func (m *MockAPI) SetPriorityHysteresis(arg0 int) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetPriorityHysteresis", arg0) +} + +// SetPriorityHysteresis indicates an expected call of SetPriorityHysteresis. +func (mr *MockAPIMockRecorder) SetPriorityHysteresis(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPriorityHysteresis", reflect.TypeOf((*MockAPI)(nil).SetPriorityHysteresis), arg0) +} + +// SetPriorityStrategy mocks base method. +func (m *MockAPI) SetPriorityStrategy(arg0 api.PriorityStrategy) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetPriorityStrategy", arg0) +} + +// SetPriorityStrategy indicates an expected call of SetPriorityStrategy. +func (mr *MockAPIMockRecorder) SetPriorityStrategy(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPriorityStrategy", reflect.TypeOf((*MockAPI)(nil).SetPriorityStrategy), arg0) +} + // SetSmartCostLimit mocks base method. func (m *MockAPI) SetSmartCostLimit(limit *float64) { m.ctrl.T.Helper() diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go index 04a88a26b96..5bccf9748a7 100644 --- a/core/loadpoint_api.go +++ b/core/loadpoint_api.go @@ -228,6 +228,27 @@ func (lp *Loadpoint) GetPriority() int { return lp.priority } +// GetPriorityStrategy returns the loadpoint priority strategy +func (lp *Loadpoint) GetPriorityStrategy() api.PriorityStrategy { + lp.RLock() + defer lp.RUnlock() + return lp.priorityStrategy +} + +// GetPriorityBasis returns the loadpoint priority strategy basis +func (lp *Loadpoint) GetPriorityBasis() api.PriorityBasis { + lp.RLock() + defer lp.RUnlock() + return lp.priorityBasis +} + +// GetPriorityHysteresis returns the priority sub-ordering deadband in soc-% +func (lp *Loadpoint) GetPriorityHysteresis() int { + lp.RLock() + defer lp.RUnlock() + return lp.priorityHysteresis +} + // setPriority sets the loadpoint priority (no mutex) func (lp *Loadpoint) setPriority(prio int) { lp.priority = prio @@ -246,6 +267,75 @@ func (lp *Loadpoint) SetPriority(prio int) { } } +// setPriorityStrategy sets the loadpoint priority strategy (no mutex) +func (lp *Loadpoint) setPriorityStrategy(strategy api.PriorityStrategy) { + lp.priorityStrategy = strategy + lp.publish(keys.PriorityStrategy, lp.priorityStrategy) + lp.settings.SetString(keys.PriorityStrategy, strategy.String()) +} + +// SetPriorityStrategy sets the loadpoint priority strategy +func (lp *Loadpoint) SetPriorityStrategy(strategy api.PriorityStrategy) { + lp.Lock() + defer lp.Unlock() + + if !strategy.IsAPriorityStrategy() { + lp.log.ERROR.Printf("invalid priority strategy: %d", strategy) + return + } + + lp.log.DEBUG.Printf("set priority strategy: %s", strategy) + if lp.priorityStrategy != strategy { + lp.setPriorityStrategy(strategy) + } +} + +// setPriorityBasis sets the loadpoint priority strategy basis (no mutex) +func (lp *Loadpoint) setPriorityBasis(basis api.PriorityBasis) { + lp.priorityBasis = basis + lp.publish(keys.PriorityBasis, lp.priorityBasis) + lp.settings.SetString(keys.PriorityBasis, basis.String()) +} + +// SetPriorityBasis sets the loadpoint priority strategy basis +func (lp *Loadpoint) SetPriorityBasis(basis api.PriorityBasis) { + lp.Lock() + defer lp.Unlock() + + if !basis.IsAPriorityBasis() { + lp.log.ERROR.Printf("invalid priority basis: %d", basis) + return + } + + lp.log.DEBUG.Printf("set priority basis: %s", basis) + if lp.priorityBasis != basis { + lp.setPriorityBasis(basis) + } +} + +// setPriorityHysteresis sets the loadpoint priority sub-ordering deadband (no mutex) +func (lp *Loadpoint) setPriorityHysteresis(hysteresis int) { + lp.priorityHysteresis = hysteresis + lp.publish(keys.PriorityHysteresis, lp.priorityHysteresis) + lp.settings.SetInt(keys.PriorityHysteresis, int64(lp.priorityHysteresis)) +} + +// SetPriorityHysteresis sets the loadpoint priority sub-ordering deadband in soc-% +func (lp *Loadpoint) SetPriorityHysteresis(hysteresis int) { + lp.Lock() + defer lp.Unlock() + + if hysteresis < 0 || hysteresis > 99 { + lp.log.ERROR.Printf("invalid priority hysteresis: %d (must be 0..99)", hysteresis) + return + } + + lp.log.DEBUG.Println("set priority hysteresis:", hysteresis) + if lp.priorityHysteresis != hysteresis { + lp.setPriorityHysteresis(hysteresis) + } +} + // GetPhases returns the enabled phases func (lp *Loadpoint) GetPhases() int { lp.RLock() diff --git a/core/loadpoint_effective.go b/core/loadpoint_effective.go index 786d8be000d..ce468e278e5 100644 --- a/core/loadpoint_effective.go +++ b/core/loadpoint_effective.go @@ -12,7 +12,8 @@ import ( // PublishEffectiveValues publishes all effective values func (lp *Loadpoint) PublishEffectiveValues() { - lp.publish(keys.EffectivePriority, lp.EffectivePriority()) + lp.publish(keys.EffectivePriority, lp.effectivePriority()) + lp.publish(keys.EffectivePriorityScore, lp.EffectivePriorityScore(lp.GetPriorityBasis())) lp.publish(keys.EffectivePlanId, lp.EffectivePlanId()) lp.publish(keys.EffectivePlanTime, lp.EffectivePlanTime()) lp.publish(keys.EffectivePlanSoc, lp.EffectivePlanSoc()) @@ -22,8 +23,8 @@ func (lp *Loadpoint) PublishEffectiveValues() { lp.publish(keys.EffectiveLimitSoc, lp.EffectiveLimitSoc()) } -// EffectivePriority returns the effective priority -func (lp *Loadpoint) EffectivePriority() int { +// effectivePriority returns the effective priority tier (integer part of the score). +func (lp *Loadpoint) effectivePriority() int { if v := lp.GetVehicle(); v != nil { if res, ok := v.OnIdentified().GetPriority(); ok { return res @@ -32,6 +33,64 @@ func (lp *Loadpoint) EffectivePriority() int { return lp.GetPriority() } +// EffectivePriorityScore ranks loadpoints for surplus distribution: the integer part is +// the effective priority tier, the fractional part [0,1) sub-orders within the tier by the +// priority strategy/basis (higher wins). Basis is passed in so the prioritizer can score a +// whole tier on one scale, see Prioritizer.effectiveBasis. +func (lp *Loadpoint) EffectivePriorityScore(basis api.PriorityBasis) float64 { + score := float64(lp.effectivePriority()) + + soc := lp.GetSoc() + if soc <= 0 { + return score + } + + // gap is the soc-% quantity the strategy ranks by (a larger gap scores higher) + var gap float64 + switch lp.GetPriorityStrategy() { + case api.PrioritySoc: + gap = 100 - soc + case api.PriorityDeficit: + gap = float64(lp.EffectiveLimitSoc()) - soc + default: + return score + } + + // energy basis: convert the soc-% gap into absolute kWh using the vehicle + // capacity, falling back to the percentage gap when capacity is unknown + if basis == api.PriorityBasisEnergy { + if capacity := lp.vehicleCapacity(); capacity > 0 { + gap = gap / 100 * capacity + } else { + lp.log.DEBUG.Println("priority basis energy: unknown vehicle capacity, ranking by soc percentage") + } + } + + return score + priorityFraction(gap) +} + +// vehicleCapacity returns the active vehicle's usable capacity in kWh, or 0 if +// no vehicle is active or its capacity is unknown. +func (lp *Loadpoint) vehicleCapacity() float64 { + if v := lp.GetVehicle(); v != nil { + return v.Capacity() + } + return 0 +} + +// priorityFraction maps a soc-based value to a [0,1) sub-ordering offset, kept +// strictly below 1 so it can never bump a loadpoint into the next priority tier. +func priorityFraction(v float64) float64 { + switch { + case v <= 0: + return 0 + case v > 99: + return 0.99 + default: + return v / 100 + } +} + type plan struct { Id int Start time.Time // last possible start time diff --git a/core/loadpoint_effective_test.go b/core/loadpoint_effective_test.go index 2b6dc2dd4e5..eb36c7e7523 100644 --- a/core/loadpoint_effective_test.go +++ b/core/loadpoint_effective_test.go @@ -17,6 +17,67 @@ func TestEffectiveLimitSoc(t *testing.T) { assert.Equal(t, 100, lp.effectiveLimitSoc()) } +func TestEffectivePriorityScore(t *testing.T) { + tc := []struct { + strategy api.PriorityStrategy + basis api.PriorityBasis + priority int + soc, limitSoc float64 + capacity float64 // vehicle capacity in kWh, 0 = no/unknown vehicle + expected float64 + }{ + // none: fractional part is always zero + {api.PriorityNone, api.PriorityBasisPercent, 0, 50, 0, 0, 0}, + {api.PriorityNone, api.PriorityBasisPercent, 2, 50, 0, 0, 2}, + // soc (percent): lower soc scores higher within the tier + {api.PrioritySoc, api.PriorityBasisPercent, 0, 20, 0, 0, 0.80}, + {api.PrioritySoc, api.PriorityBasisPercent, 0, 80, 0, 0, 0.20}, + {api.PrioritySoc, api.PriorityBasisPercent, 1, 20, 0, 0, 1.80}, + {api.PrioritySoc, api.PriorityBasisPercent, 0, 100, 0, 0, 0}, // full vehicle: no boost + {api.PrioritySoc, api.PriorityBasisPercent, 0, 0, 0, 0, 0}, // unknown soc: falls back to plain priority + // deficit (percent): larger gap to the limit soc scores higher within the tier + {api.PriorityDeficit, api.PriorityBasisPercent, 0, 50, 80, 0, 0.30}, + {api.PriorityDeficit, api.PriorityBasisPercent, 0, 50, 0, 0, 0.50}, // no limit set -> default 100 + {api.PriorityDeficit, api.PriorityBasisPercent, 0, 90, 80, 0, 0}, // soc above limit: no boost + {api.PriorityDeficit, api.PriorityBasisPercent, 0, 0, 80, 0, 0}, // unknown soc: falls back to plain priority + // soc (energy): gap is scaled by capacity -> (100-soc)/100*capacity, /100 for the fraction + {api.PrioritySoc, api.PriorityBasisEnergy, 0, 20, 0, 50, 0.40}, // 80% * 50kWh = 40kWh + {api.PrioritySoc, api.PriorityBasisEnergy, 0, 80, 0, 50, 0.10}, // 20% * 50kWh = 10kWh + {api.PrioritySoc, api.PriorityBasisEnergy, 0, 20, 0, 25, 0.20}, // 80% * 25kWh = 20kWh + {api.PrioritySoc, api.PriorityBasisEnergy, 0, 20, 0, 0, 0.80}, // capacity unknown: falls back to percent + // deficit (energy): (limitSoc-soc)/100*capacity, /100 for the fraction + {api.PriorityDeficit, api.PriorityBasisEnergy, 0, 50, 80, 50, 0.15}, // 30% * 50kWh = 15kWh + {api.PriorityDeficit, api.PriorityBasisEnergy, 0, 50, 80, 0, 0.30}, // capacity unknown: falls back to percent + // Steve's case: a small second car is NOT over-prioritized under the energy basis. + // Percent basis would rank B (40%) above A (50%); energy basis ranks A (needs 37.5kWh) above B (15kWh). + {api.PrioritySoc, api.PriorityBasisPercent, 0, 50, 0, 75, 0.50}, // car A, percent + {api.PrioritySoc, api.PriorityBasisPercent, 0, 40, 0, 25, 0.60}, // car B, percent -> B wins + {api.PrioritySoc, api.PriorityBasisEnergy, 0, 50, 0, 75, 0.375}, // car A, energy -> A wins + {api.PrioritySoc, api.PriorityBasisEnergy, 0, 40, 0, 25, 0.15}, // car B, energy + } + + for _, tc := range tc { + t.Logf("%+v", tc) + + lp := NewLoadpoint(util.NewLogger("foo"), nil) + lp.priority = tc.priority + lp.priorityStrategy = tc.strategy + lp.priorityBasis = tc.basis + lp.vehicleSoc = tc.soc + lp.limitSoc = int(tc.limitSoc) + + if tc.capacity > 0 { + ctrl := gomock.NewController(t) + vehicle := api.NewMockVehicle(ctrl) + vehicle.EXPECT().Capacity().Return(tc.capacity).AnyTimes() + vehicle.EXPECT().OnIdentified().Return(api.ActionConfig{}).AnyTimes() + lp.vehicle = vehicle + } + + assert.InDelta(t, tc.expected, lp.EffectivePriorityScore(tc.basis), 1e-9) + } +} + func TestEffectiveMinMaxCurrent(t *testing.T) { tc := []struct { chargerMin, chargerMax float64 diff --git a/core/loadpoint_priority_test.go b/core/loadpoint_priority_test.go new file mode 100644 index 00000000000..94bfee552f0 --- /dev/null +++ b/core/loadpoint_priority_test.go @@ -0,0 +1,130 @@ +package core + +import ( + "errors" + "testing" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/util" + "github.com/stretchr/testify/assert" +) + +// memSettings is a minimal in-memory settings.Settings implementation for tests. +type memSettings struct { + data map[string]any +} + +func newMemSettings() *memSettings { + return &memSettings{data: make(map[string]any)} +} + +func (s *memSettings) SetString(key string, val string) { s.data[key] = val } +func (s *memSettings) SetInt(key string, val int64) { s.data[key] = val } +func (s *memSettings) SetFloat(key string, val float64) { s.data[key] = val } +func (s *memSettings) SetFloatPtr(key string, val *float64) { s.data[key] = val } +func (s *memSettings) SetTime(key string, val time.Time) { s.data[key] = val } +func (s *memSettings) SetBool(key string, val bool) { s.data[key] = val } +func (s *memSettings) SetJson(key string, val any) error { s.data[key] = val; return nil } + +func (s *memSettings) String(key string) (string, error) { + if v, ok := s.data[key].(string); ok { + return v, nil + } + return "", errors.New("not found") +} + +func (s *memSettings) Int(key string) (int64, error) { + if v, ok := s.data[key].(int64); ok { + return v, nil + } + return 0, errors.New("not found") +} + +func (s *memSettings) Float(key string) (float64, error) { + if v, ok := s.data[key].(float64); ok { + return v, nil + } + return 0, errors.New("not found") +} + +func (s *memSettings) Time(key string) (time.Time, error) { + if v, ok := s.data[key].(time.Time); ok { + return v, nil + } + return time.Time{}, errors.New("not found") +} + +func (s *memSettings) Bool(key string) (bool, error) { + if v, ok := s.data[key].(bool); ok { + return v, nil + } + return false, errors.New("not found") +} + +func (s *memSettings) Json(key string, res any) error { return errors.New("not implemented") } + +func newPriorityTestLoadpoint() (*Loadpoint, *memSettings) { + s := newMemSettings() + lp := NewLoadpoint(util.NewLogger("foo"), s) + return lp, s +} + +func TestSetPriorityStrategy(t *testing.T) { + lp, s := newPriorityTestLoadpoint() + + // valid: soc + lp.SetPriorityStrategy(api.PrioritySoc) + assert.Equal(t, api.PrioritySoc, lp.GetPriorityStrategy()) + assert.Equal(t, api.PrioritySoc.String(), s.data[keys.PriorityStrategy], "soc must be persisted") + + // valid: deficit + lp.SetPriorityStrategy(api.PriorityDeficit) + assert.Equal(t, api.PriorityDeficit, lp.GetPriorityStrategy()) + assert.Equal(t, api.PriorityDeficit.String(), s.data[keys.PriorityStrategy]) + + // valid: none (default) + lp.SetPriorityStrategy(api.PriorityNone) + assert.Equal(t, api.PriorityNone, lp.GetPriorityStrategy()) + assert.Equal(t, api.PriorityNone.String(), s.data[keys.PriorityStrategy]) + + // invalid: rejected, state unchanged + lp.SetPriorityStrategy(api.PrioritySoc) + delete(s.data, keys.PriorityStrategy) + lp.SetPriorityStrategy(api.PriorityStrategy(99)) + assert.Equal(t, api.PrioritySoc, lp.GetPriorityStrategy(), "invalid strategy must not change state") + _, persisted := s.data[keys.PriorityStrategy] + assert.False(t, persisted, "invalid strategy must not be persisted") +} + +func TestSetPriorityHysteresis(t *testing.T) { + lp, s := newPriorityTestLoadpoint() + + // valid + lp.SetPriorityHysteresis(5) + assert.Equal(t, 5, lp.GetPriorityHysteresis()) + assert.Equal(t, int64(5), s.data[keys.PriorityHysteresis], "valid hysteresis must be persisted") + + // boundary: 99 ok + lp.SetPriorityHysteresis(99) + assert.Equal(t, 99, lp.GetPriorityHysteresis()) + assert.Equal(t, int64(99), s.data[keys.PriorityHysteresis]) + + // boundary: 0 ok (off) + lp.SetPriorityHysteresis(0) + assert.Equal(t, 0, lp.GetPriorityHysteresis()) + assert.Equal(t, int64(0), s.data[keys.PriorityHysteresis]) + + // invalid: > 99 rejected, state unchanged + lp.SetPriorityHysteresis(7) + delete(s.data, keys.PriorityHysteresis) + lp.SetPriorityHysteresis(100) + assert.Equal(t, 7, lp.GetPriorityHysteresis(), "out-of-range hysteresis must not change state") + _, persisted := s.data[keys.PriorityHysteresis] + assert.False(t, persisted, "out-of-range hysteresis must not be persisted") + + // invalid: negative rejected + lp.SetPriorityHysteresis(-1) + assert.Equal(t, 7, lp.GetPriorityHysteresis(), "negative hysteresis must not change state") +} diff --git a/core/prioritizer/prioritizer.go b/core/prioritizer/prioritizer.go index 6cdb88866a1..28e29d642e8 100644 --- a/core/prioritizer/prioritizer.go +++ b/core/prioritizer/prioritizer.go @@ -2,6 +2,7 @@ package prioritizer import ( "fmt" + "math" "sync" "github.com/evcc-io/evcc/api" @@ -31,23 +32,76 @@ func (p *Prioritizer) UpdateChargePowerFlexibility(lp loadpoint.API, rates api.R } func (p *Prioritizer) GetChargePowerFlexibility(lp loadpoint.API) float64 { - prio := lp.EffectivePriority() + // rank every candidate on a basis resolved per priority tier so the score + // fractions compared below share one scale (see effectiveBasis) + candidates := p.candidates(lp) + score := lp.EffectivePriorityScore(p.effectiveBasis(lp, candidates)) + + // hysteresis deadband (soc-% -> score fraction): only outrank another loadpoint + // when ahead by more than the band, so near-equal soc loadpoints tie and converge + // instead of leapfrogging each other as their soc crosses. capped below 1.0 so it + // never weakens cross-tier (integer priority) ordering. + band := math.Min(float64(lp.GetPriorityHysteresis())/100, 0.99) var ( reduceBy float64 msg string ) - for lp, power := range p.demand { - if lp.EffectivePriority() < prio && power > 0 { + for other, power := range p.demand { + otherScore := other.EffectivePriorityScore(p.effectiveBasis(other, candidates)) + if score-otherScore > band && power > 0 { reduceBy += power - msg += fmt.Sprintf("%.0fW from %s at prio %d, ", power, lp.GetTitle(), lp.EffectivePriority()) + msg += fmt.Sprintf("%.0fW from %s at prio %.2f, ", power, other.GetTitle(), otherScore) } } if p.log != nil && reduceBy > 0 { - p.log.DEBUG.Printf("lp %s at prio %d gets additional %stotal %.0fW\n", lp.GetTitle(), lp.EffectivePriority(), msg, reduceBy) + p.log.DEBUG.Printf("lp %s at prio %.2f gets additional %stotal %.0fW\n", lp.GetTitle(), score, msg, reduceBy) } return reduceBy } + +// candidates returns the loadpoints that participate in ranking: the target plus +// every loadpoint that has registered demand. +func (p *Prioritizer) candidates(lp loadpoint.API) []loadpoint.API { + res := []loadpoint.API{lp} + for other := range p.demand { + if other != lp { + res = append(res, other) + } + } + return res +} + +// effectiveBasis returns the priority basis to score lp with. The energy basis +// ranks by absolute kWh while the percent basis ranks by soc-%; their fractions +// are not comparable, so a whole priority tier must use a single basis. When any +// energy-basis loadpoint in lp's tier has no known vehicle capacity (its energy +// score would silently fall back to a percentage), the entire tier is ranked by +// percent so configured and unconfigured vehicles are never mixed across scales. +func (p *Prioritizer) effectiveBasis(lp loadpoint.API, candidates []loadpoint.API) api.PriorityBasis { + if lp.GetPriorityBasis() != api.PriorityBasisEnergy { + return lp.GetPriorityBasis() + } + + tier := priorityTier(lp) + for _, other := range candidates { + if priorityTier(other) != tier || other.GetPriorityBasis() != api.PriorityBasisEnergy { + continue + } + if v := other.GetVehicle(); v == nil || v.Capacity() <= 0 { + return api.PriorityBasisPercent + } + } + + return api.PriorityBasisEnergy +} + +// priorityTier is the integer (cross-tier) part of a loadpoint's score. The +// strategy sub-ordering lives in the fraction and is basis-independent, so any +// basis yields the same tier. +func priorityTier(lp loadpoint.API) int { + return int(math.Floor(lp.EffectivePriorityScore(api.PriorityBasisPercent))) +} diff --git a/core/prioritizer/prioritizer_test.go b/core/prioritizer/prioritizer_test.go index 0d65c87a8dc..fd714bef896 100644 --- a/core/prioritizer/prioritizer_test.go +++ b/core/prioritizer/prioritizer_test.go @@ -3,6 +3,7 @@ package prioritizer import ( "testing" + "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/loadpoint" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" @@ -15,13 +16,15 @@ func TestPrioritzer(t *testing.T) { lo := loadpoint.NewMockAPI(ctrl) lo.EXPECT().GetTitle().AnyTimes() - lo.EXPECT().GetPriority().Return(0).AnyTimes() // prio 0 - lo.EXPECT().EffectivePriority().Return(0).AnyTimes() // prio 0 + lo.EXPECT().GetPriorityBasis().Return(api.PriorityBasisPercent).AnyTimes() + lo.EXPECT().EffectivePriorityScore(gomock.Any()).Return(0.0).AnyTimes() // prio 0 + lo.EXPECT().GetPriorityHysteresis().Return(0).AnyTimes() hi := loadpoint.NewMockAPI(ctrl) hi.EXPECT().GetTitle().AnyTimes() - hi.EXPECT().GetPriority().Return(1).AnyTimes() // prio 1 - hi.EXPECT().EffectivePriority().Return(1).AnyTimes() // prio 1 + hi.EXPECT().GetPriorityBasis().Return(api.PriorityBasisPercent).AnyTimes() + hi.EXPECT().EffectivePriorityScore(gomock.Any()).Return(1.0).AnyTimes() // prio 1 + hi.EXPECT().GetPriorityHysteresis().Return(0).AnyTimes() // no additional power available lo.EXPECT().GetChargePowerFlexibility(nil).Return(300.0) @@ -38,3 +41,119 @@ func TestPrioritzer(t *testing.T) { p.UpdateChargePowerFlexibility(lo, nil) assert.Equal(t, 0.0, p.GetChargePowerFlexibility(hi)) } + +// TestPrioritizerWithinTier verifies that loadpoints sharing the same priority +// tier are ranked by their fractional score (e.g. soc/deficit strategy), so the +// emptier vehicle takes surplus from the fuller one. +func TestPrioritizerWithinTier(t *testing.T) { + ctrl := gomock.NewController(t) + + p := New(nil) + + full := loadpoint.NewMockAPI(ctrl) // prio 0, soc 80 -> score 0.20 + full.EXPECT().GetTitle().AnyTimes() + full.EXPECT().GetPriorityBasis().Return(api.PriorityBasisPercent).AnyTimes() + full.EXPECT().EffectivePriorityScore(gomock.Any()).Return(0.20).AnyTimes() + full.EXPECT().GetPriorityHysteresis().Return(0).AnyTimes() + + empty := loadpoint.NewMockAPI(ctrl) // prio 0, soc 20 -> score 0.80 + empty.EXPECT().GetTitle().AnyTimes() + empty.EXPECT().GetPriorityBasis().Return(api.PriorityBasisPercent).AnyTimes() + empty.EXPECT().EffectivePriorityScore(gomock.Any()).Return(0.80).AnyTimes() + empty.EXPECT().GetPriorityHysteresis().Return(0).AnyTimes() + + // fuller vehicle has nothing below it -> no extra power + full.EXPECT().GetChargePowerFlexibility(nil).Return(500.0) + p.UpdateChargePowerFlexibility(full, nil) + assert.Equal(t, 0.0, p.GetChargePowerFlexibility(full)) + + // emptier vehicle (higher score in the same tier) takes the fuller one's flexible power + empty.EXPECT().GetChargePowerFlexibility(nil).Return(1e3) + p.UpdateChargePowerFlexibility(empty, nil) + assert.Equal(t, 500.0, p.GetChargePowerFlexibility(empty)) +} + +// TestPrioritizerHysteresis verifies the priority deadband: within the same tier, +// a loadpoint only outranks another when ahead by more than the configured band, so +// near-equal soc loadpoints tie (no stealing, no leapfrog) while clearly-emptier ones +// still take priority. +func TestPrioritizerHysteresis(t *testing.T) { + ctrl := gomock.NewController(t) + + p := New(nil) + + // soc 50 -> 0.50, soc 51 -> 0.49, 5% deadband (0.05) + a := loadpoint.NewMockAPI(ctrl) + a.EXPECT().GetTitle().AnyTimes() + a.EXPECT().GetPriorityBasis().Return(api.PriorityBasisPercent).AnyTimes() + a.EXPECT().EffectivePriorityScore(gomock.Any()).Return(0.50).AnyTimes() + a.EXPECT().GetPriorityHysteresis().Return(5).AnyTimes() + + b := loadpoint.NewMockAPI(ctrl) + b.EXPECT().GetTitle().AnyTimes() + b.EXPECT().GetPriorityBasis().Return(api.PriorityBasisPercent).AnyTimes() + b.EXPECT().EffectivePriorityScore(gomock.Any()).Return(0.49).AnyTimes() + b.EXPECT().GetPriorityHysteresis().Return(5).AnyTimes() + + // clearly emptier (soc 40 -> 0.60), same 5% band + c := loadpoint.NewMockAPI(ctrl) + c.EXPECT().GetTitle().AnyTimes() + c.EXPECT().GetPriorityBasis().Return(api.PriorityBasisPercent).AnyTimes() + c.EXPECT().EffectivePriorityScore(gomock.Any()).Return(0.60).AnyTimes() + c.EXPECT().GetPriorityHysteresis().Return(5).AnyTimes() + + b.EXPECT().GetChargePowerFlexibility(nil).Return(400.0) + p.UpdateChargePowerFlexibility(b, nil) + + // a is only 0.01 ahead of b -> within the 0.05 band -> no steal (no leapfrog) + assert.Equal(t, 0.0, p.GetChargePowerFlexibility(a)) + + // c is 0.11 ahead of b -> beyond the band -> takes b's flexible power + assert.Equal(t, 400.0, p.GetChargePowerFlexibility(c)) +} + +// TestPrioritizerEnergyBasisMixedCapacity verifies that when one loadpoint in a +// tier has a known vehicle capacity and another (also energy basis) does not, the +// tier is ranked by percent rather than mixing a kWh fraction against a percentage +// fraction. Without the fallback the unconfigured vehicle's percentage gap would +// out-score the configured vehicle's (smaller) kWh gap and wrongly steal surplus. +// +// known: soc 20%, 50 kWh -> energy 0.40, percent 0.80 +// unknown: soc 50%, no cap -> energy would fall back to percent 0.50 +// +// With percent ranking the genuinely emptier "known" car (0.80) outranks "unknown" +// (0.50); the buggy mixed ranking would have unknown (0.50) beat known (0.40). +func TestPrioritizerEnergyBasisMixedCapacity(t *testing.T) { + ctrl := gomock.NewController(t) + + p := New(nil) + + vehicle := api.NewMockVehicle(ctrl) + vehicle.EXPECT().Capacity().Return(50.0).AnyTimes() + + // known capacity, lower soc -> percent score 0.80 (energy 0.40) + known := loadpoint.NewMockAPI(ctrl) + known.EXPECT().GetTitle().AnyTimes() + known.EXPECT().GetPriorityBasis().Return(api.PriorityBasisEnergy).AnyTimes() + known.EXPECT().GetVehicle().Return(vehicle).AnyTimes() + known.EXPECT().GetPriorityHysteresis().Return(0).AnyTimes() + known.EXPECT().EffectivePriorityScore(api.PriorityBasisPercent).Return(0.80).AnyTimes() + + // unknown capacity, higher soc -> percent score 0.50 + unknown := loadpoint.NewMockAPI(ctrl) + unknown.EXPECT().GetTitle().AnyTimes() + unknown.EXPECT().GetPriorityBasis().Return(api.PriorityBasisEnergy).AnyTimes() + unknown.EXPECT().GetVehicle().Return(nil).AnyTimes() + unknown.EXPECT().GetPriorityHysteresis().Return(0).AnyTimes() + unknown.EXPECT().EffectivePriorityScore(api.PriorityBasisPercent).Return(0.50).AnyTimes() + + // unknown (fuller, 0.50) has nothing emptier below it -> no extra power + unknown.EXPECT().GetChargePowerFlexibility(nil).Return(700.0) + p.UpdateChargePowerFlexibility(unknown, nil) + assert.Equal(t, 0.0, p.GetChargePowerFlexibility(unknown)) + + // known (emptier, 0.80) outranks unknown and takes its flexible power + known.EXPECT().GetChargePowerFlexibility(nil).Return(1e3) + p.UpdateChargePowerFlexibility(known, nil) + assert.Equal(t, 700.0, p.GetChargePowerFlexibility(known)) +} diff --git a/i18n/en.json b/i18n/en.json index 2bd7b8b15e3..2b666502c8a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -405,7 +405,14 @@ "pollModeConnectedHelp": "Update vehicle status in regular intervals when connected.", "pollModeLabel": "Update behaviour", "priorityHelp": "Higher priority get preferred access to solar surplus.", + "priorityHysteresisHelp": "Don't reorder loadpoints of equal priority until the charge level gap exceeds this many percent. Prevents constant leapfrogging between cars. Set to 0 to disable.", + "priorityHysteresisLabel": "Priority hysteresis", "priorityLabel": "Priority", + "priorityStrategyDeficit": "Largest deficit", + "priorityStrategyHelp": "How to order loadpoints of equal priority when distributing solar surplus.", + "priorityStrategyLabel": "Priority strategy", + "priorityStrategyNone": "None", + "priorityStrategySoc": "Lower charge level first", "save": "Save", "solarBehaviorCustomHelp": "Define your own enable and disable thresholds and delays.", "solarBehaviorDefaultHelp": "Start after {enableDelay} of sufficient surplus. Stop when there is not enough surplus for {disableDelay}.", @@ -1328,6 +1335,20 @@ "phases_3": "3 phase", "phases_3_hint": "({min} to {max})" }, + "priority": { + "heading": "Priority" + }, + "priorityHysteresis": { + "help": "Don't reorder loadpoints until the charge level gap exceeds this many percent. Prevents constant leapfrogging between cars. Set to 0 to disable.", + "label": "Hysteresis" + }, + "priorityStrategy": { + "deficit": "Largest deficit", + "help": "How to order loadpoints of equal priority when distributing solar surplus.", + "label": "Strategy", + "none": "None", + "soc": "Lower charge level first" + }, "smartCostCheap": "Cheap Grid Charging", "smartCostClean": "Clean Grid Charging", "title": "Settings {0}", diff --git a/server/http.go b/server/http.go index 2956eecccde..503fb2f0f55 100644 --- a/server/http.go +++ b/server/http.go @@ -220,6 +220,9 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) { "smartFeedInPriority": {"POST", "/smartfeedinprioritylimit/{value:-?[0-9.]+}", floatPtrHandler(pass(lp.SetSmartFeedInPriorityLimit), lp.GetSmartFeedInPriorityLimit)}, "smartFeedInPriorityDelete": {"DELETE", "/smartfeedinprioritylimit", floatPtrHandler(pass(lp.SetSmartFeedInPriorityLimit), lp.GetSmartFeedInPriorityLimit)}, "priority": {"POST", "/priority/{value:[0-9]+}", intHandler(pass(lp.SetPriority), lp.GetPriority)}, + "priorityStrategy": {"POST", "/prioritystrategy/{value:[a-z]+}", handler(eapi.PriorityStrategyString, pass(lp.SetPriorityStrategy), lp.GetPriorityStrategy)}, + "priorityBasis": {"POST", "/prioritybasis/{value:[a-z]+}", handler(eapi.PriorityBasisString, pass(lp.SetPriorityBasis), lp.GetPriorityBasis)}, + "priorityHysteresis": {"POST", "/priorityhysteresis/{value:[0-9]+}", intHandler(pass(lp.SetPriorityHysteresis), lp.GetPriorityHysteresis)}, "batteryBoost": {"POST", "/batteryboost/{value:[01truefalse]+}", boolHandler(lp.SetBatteryBoost, func() bool { return lp.GetBatteryBoost() > 0 })}, "batteryBoostLimit": {"POST", "/batteryboostlimit/{value:[0-9]+}", intHandler(pass(lp.SetBatteryBoostLimit), lp.GetBatteryBoostLimit)}, } diff --git a/server/http_config_loadpoint_handler.go b/server/http_config_loadpoint_handler.go index 87f71fcb97c..2bae92e2285 100644 --- a/server/http_config_loadpoint_handler.go +++ b/server/http_config_loadpoint_handler.go @@ -32,6 +32,9 @@ func getLoadpointDynamicConfig(lp loadpoint.API) loadpoint.DynamicConfig { Title: lp.GetTitle(), DefaultMode: string(lp.GetDefaultMode()), Priority: lp.GetPriority(), + PriorityStrategy: lp.GetPriorityStrategy(), + PriorityBasis: lp.GetPriorityBasis(), + PriorityHysteresis: lp.GetPriorityHysteresis(), PhasesConfigured: lp.GetPhasesConfigured(), MinCurrent: lp.GetMinCurrent(), MaxCurrent: lp.GetMaxCurrent(), diff --git a/server/mcp/openapi.json b/server/mcp/openapi.json index 5aa3652ab4e..9744d348943 100644 --- a/server/mcp/openapi.json +++ b/server/mcp/openapi.json @@ -131,6 +131,22 @@ "$ref": "#/components/schemas/Power" } }, + "priorityBasis": { + "in": "path", + "name": "basis", + "required": true, + "schema": { + "$ref": "#/components/schemas/PriorityBasis" + } + }, + "priorityStrategy": { + "in": "path", + "name": "strategy", + "required": true, + "schema": { + "$ref": "#/components/schemas/PriorityStrategy" + } + }, "soc": { "description": "SOC in %", "in": "path", @@ -633,6 +649,23 @@ "minimum": 0, "type": "integer" }, + "PriorityBasis": { + "description": "Whether the priority strategy ranks by soc percentage or by absolute energy (kWh).", + "enum": [ + "percent", + "energy" + ], + "type": "string" + }, + "PriorityStrategy": { + "description": "Priority strategy used to sub-order loadpoints of the same priority.", + "enum": [ + "none", + "soc", + "deficit" + ], + "type": "string" + }, "Rate": { "description": "A charging interval", "properties": { @@ -2034,6 +2067,117 @@ ] } }, + "/loadpoints/{id}/prioritybasis/{basis}": { + "post": { + "description": "Set whether the priority strategy ranks by soc percentage or by absolute energy (kWh).", + "externalDocs": { + "url": "https://docs.evcc.io/en/docs/reference/configuration/loadpoints#priority" + }, + "operationId": "setLoadpointPriorityBasis", + "parameters": [ + { + "$ref": "#/components/parameters/id" + }, + { + "$ref": "#/components/parameters/priorityBasis" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "result": { + "$ref": "#/components/schemas/PriorityBasis" + } + }, + "type": "object" + } + } + }, + "description": "Success" + } + }, + "summary": "Set priority basis", + "tags": [ + "loadpoints" + ] + } + }, + "/loadpoints/{id}/priorityhysteresis/{hysteresis}": { + "post": { + "description": "Set the deadband used when sub-ordering loadpoints of the same priority (soc-%, or kWh with the energy basis; 0 = off).", + "externalDocs": { + "url": "https://docs.evcc.io/en/docs/reference/configuration/loadpoints#priority" + }, + "operationId": "setLoadpointPriorityHysteresis", + "parameters": [ + { + "$ref": "#/components/parameters/id" + }, + { + "description": "Deadband in soc-% or kWh per basis (0..99, 0 = off).", + "example": 5, + "in": "path", + "name": "hysteresis", + "required": true, + "schema": { + "maximum": 99, + "minimum": 0, + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/IntegerResult" + } + }, + "summary": "Set priority hysteresis", + "tags": [ + "loadpoints" + ] + } + }, + "/loadpoints/{id}/prioritystrategy/{strategy}": { + "post": { + "description": "Set the strategy used to sub-order loadpoints of the same priority.", + "externalDocs": { + "url": "https://docs.evcc.io/en/docs/reference/configuration/loadpoints#priority" + }, + "operationId": "setLoadpointPriorityStrategy", + "parameters": [ + { + "$ref": "#/components/parameters/id" + }, + { + "$ref": "#/components/parameters/priorityStrategy" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "result": { + "$ref": "#/components/schemas/PriorityStrategy" + } + }, + "type": "object" + } + } + }, + "description": "Success" + } + }, + "summary": "Set priority strategy", + "tags": [ + "loadpoints" + ] + } + }, "/loadpoints/{id}/smartcostlimit": { "delete": { "description": "Delete cost or emission limit for fast-charging with grid energy.", diff --git a/server/mcp/openapi.md b/server/mcp/openapi.md index d576736ff7f..0749297572d 100644 --- a/server/mcp/openapi.md +++ b/server/mcp/openapi.md @@ -918,6 +918,72 @@ call setLoadpointPriority { } ``` +## setLoadpointPriorityBasis + +Set whether the priority strategy ranks by soc percentage or by absolute energy (kWh). + +**Tags:** loadpoints + +**Arguments:** + +| Name | Type | Description | +|------|------|-------------| +| basis | string | Whether the priority strategy ranks by soc percentage or by absolute energy (kWh). | +| id | integer | Loadpoint index starting at 1 | + +**Example call:** + +```json +call setLoadpointPriorityBasis { + "basis": "example", + "id": 123 +} +``` + +## setLoadpointPriorityHysteresis + +Set the deadband used when sub-ordering loadpoints of the same priority (soc-%, or kWh with the energy basis; 0 = off). + +**Tags:** loadpoints + +**Arguments:** + +| Name | Type | Description | +|------|------|-------------| +| hysteresis | integer | Deadband in soc-% or kWh per basis (0..99, 0 = off). | +| id | integer | Loadpoint index starting at 1 | + +**Example call:** + +```json +call setLoadpointPriorityHysteresis { + "hysteresis": 123, + "id": 123 +} +``` + +## setLoadpointPriorityStrategy + +Set the strategy used to sub-order loadpoints of the same priority. + +**Tags:** loadpoints + +**Arguments:** + +| Name | Type | Description | +|------|------|-------------| +| id | integer | Loadpoint index starting at 1 | +| strategy | string | Priority strategy used to sub-order loadpoints of the same priority. | + +**Example call:** + +```json +call setLoadpointPriorityStrategy { + "id": 123, + "strategy": "example" +} +``` + ## setLoadpointSmartCostLimit Set cost or emission limit for fast-charging with grid energy. diff --git a/server/mqtt.go b/server/mqtt.go index c3f94298fdf..7d494efe4ba 100644 --- a/server/mqtt.go +++ b/server/mqtt.go @@ -249,6 +249,9 @@ func (m *MQTT) listenLoadpointSetters(topic string, site site.API, lp loadpoint. {"phasesConfigured", intSetter(lp.SetPhasesConfigured)}, {"limitSoc", intSetter(pass(lp.SetLimitSoc))}, {"priority", intSetter(pass(lp.SetPriority))}, + {"priorityStrategy", setterFunc(api.PriorityStrategyString, pass(lp.SetPriorityStrategy))}, + {"priorityBasis", setterFunc(api.PriorityBasisString, pass(lp.SetPriorityBasis))}, + {"priorityHysteresis", intSetter(pass(lp.SetPriorityHysteresis))}, {"minCurrent", floatSetter(lp.SetMinCurrent)}, {"maxCurrent", floatSetter(lp.SetMaxCurrent)}, {"limitEnergy", floatSetter(pass(lp.SetLimitEnergy))}, diff --git a/server/openapi.yaml b/server/openapi.yaml index e6dadb881f5..dbb4bbebdff 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -603,6 +603,73 @@ paths: responses: "200": $ref: "#/components/responses/IntegerResult" + /loadpoints/{id}/prioritystrategy/{strategy}: + post: + operationId: setLoadpointPriorityStrategy + summary: Set priority strategy + description: "Set the strategy used to sub-order loadpoints of the same priority." + externalDocs: + url: https://docs.evcc.io/en/docs/reference/configuration/loadpoints#priority + tags: + - loadpoints + parameters: + - $ref: "#/components/parameters/id" + - $ref: "#/components/parameters/priorityStrategy" + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + result: + $ref: "#/components/schemas/PriorityStrategy" + /loadpoints/{id}/prioritybasis/{basis}: + post: + operationId: setLoadpointPriorityBasis + summary: Set priority basis + description: "Set whether the priority strategy ranks by soc percentage or by absolute energy (kWh)." + externalDocs: + url: https://docs.evcc.io/en/docs/reference/configuration/loadpoints#priority + tags: + - loadpoints + parameters: + - $ref: "#/components/parameters/id" + - $ref: "#/components/parameters/priorityBasis" + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + result: + $ref: "#/components/schemas/PriorityBasis" + /loadpoints/{id}/priorityhysteresis/{hysteresis}: + post: + operationId: setLoadpointPriorityHysteresis + summary: Set priority hysteresis + description: "Set the deadband used when sub-ordering loadpoints of the same priority (soc-%, or kWh with the energy basis; 0 = off)." + externalDocs: + url: https://docs.evcc.io/en/docs/reference/configuration/loadpoints#priority + tags: + - loadpoints + parameters: + - $ref: "#/components/parameters/id" + - name: hysteresis + in: path + description: Deadband in soc-% or kWh per basis (0..99, 0 = off). + required: true + example: 5 + schema: + type: integer + minimum: 0 + maximum: 99 + responses: + "200": + $ref: "#/components/responses/IntegerResult" /loadpoints/{id}/smartcostlimit: delete: operationId: deleteLoadpointSmartCostLimit @@ -1520,6 +1587,19 @@ components: - "now" - "minpv" - "pv" + PriorityStrategy: + description: "Priority strategy used to sub-order loadpoints of the same priority." + type: string + enum: + - "none" + - "soc" + - "deficit" + PriorityBasis: + description: "Whether the priority strategy ranks by soc percentage or by absolute energy (kWh)." + type: string + enum: + - "percent" + - "energy" Odometer: nullable: true type: "number" @@ -1727,6 +1807,18 @@ components: required: true schema: $ref: "#/components/schemas/Mode" + priorityStrategy: + name: strategy + in: path + required: true + schema: + $ref: "#/components/schemas/PriorityStrategy" + priorityBasis: + name: basis + in: path + required: true + schema: + $ref: "#/components/schemas/PriorityBasis" soc: name: soc description: SOC in % diff --git a/tests/config-loadpoint.spec.ts b/tests/config-loadpoint.spec.ts index 3bf55a0af26..5ed36c1b2a8 100644 --- a/tests/config-loadpoint.spec.ts +++ b/tests/config-loadpoint.spec.ts @@ -157,8 +157,8 @@ test.describe("charging loadpoint", async () => { // second loadpoint: increase priority await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click(); await expectModalVisible(lpModal); - await expect(lpModal.getByLabel("Priority")).toHaveValue("0"); - await lpModal.getByLabel("Priority").selectOption("1"); + await expect(lpModal.getByLabel("Priority", { exact: true })).toHaveValue("0"); + await lpModal.getByLabel("Priority", { exact: true }).selectOption("1"); await lpModal.getByRole("button", { name: "Save" }).click(); await expectModalHidden(lpModal); @@ -169,10 +169,10 @@ test.describe("charging loadpoint", async () => { // check priorities await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click(); await expectModalVisible(lpModal); - await expect(lpModal.getByLabel("Priority")).toHaveValue("1"); + await expect(lpModal.getByLabel("Priority", { exact: true })).toHaveValue("1"); // change back to 0 - await lpModal.getByLabel("Priority").selectOption("0 (default)"); + await lpModal.getByLabel("Priority", { exact: true }).selectOption("0 (default)"); await lpModal.getByRole("button", { name: "Save" }).click(); await expectModalHidden(lpModal); @@ -183,7 +183,7 @@ test.describe("charging loadpoint", async () => { // check priorities await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click(); await expectModalVisible(lpModal); - await expect(lpModal.getByLabel("Priority")).toHaveValue("0"); + await expect(lpModal.getByLabel("Priority", { exact: true })).toHaveValue("0"); }); test("vehicle", async ({ page }) => {