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 }) => {