Optimizer: make grid charging strategy configurable (#30782)
This commit is contained in:
parent
ebe4a3d6a3
commit
494f6cf9bb
14 changed files with 555 additions and 50 deletions
|
|
@ -176,6 +176,7 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w
|
|||
|
||||
### State Management
|
||||
|
||||
- Never access the store from sub-components; keep them stateless and pass the values they need as props (emit events back to the parent). Only top-level views read from the store. This keeps components reusable and testable (e.g. Storybook should never mock the store).
|
||||
- Use `reactive()` from Vue for simple global state
|
||||
- Implement property setters for nested object updates using helper functions
|
||||
- Use localStorage with reactive wrappers for persistent settings
|
||||
|
|
|
|||
18
assets/js/components/MaterialIcon/Lock.vue
Normal file
18
assets/js/components/MaterialIcon/Lock.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<template>
|
||||
<svg :style="svgStyle" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M6 22q-.825 0-1.412-.587T4 20V10q0-.825.588-1.412T6 8h1V6q0-2.075 1.463-3.537T12 1t3.538 1.463T17 6v2h1q.825 0 1.413.588T20 10v10q0 .825-.587 1.413T18 22zm0-2h12V10H6zm6-3q.825 0 1.413-.587T14 15t-.587-1.412T12 13t-1.412.588T10 15t.588 1.413T12 17M9 8h6V6q0-1.25-.875-2.125T12 3t-2.125.875T9 6zM6 20V10z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import icon from "@/mixins/icon";
|
||||
|
||||
export default defineComponent({
|
||||
name: "Lock",
|
||||
mixins: [icon],
|
||||
});
|
||||
</script>
|
||||
40
assets/js/components/Optimize/OptimizeHeader.stories.ts
Normal file
40
assets/js/components/Optimize/OptimizeHeader.stories.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import OptimizeHeader from "./OptimizeHeader.vue";
|
||||
import { CURRENCY, OptimizationStatus } from "@/types/evcc";
|
||||
import type { Meta, StoryFn } from "@storybook/vue3";
|
||||
|
||||
export default {
|
||||
title: "Optimize/OptimizeHeader",
|
||||
component: OptimizeHeader,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
},
|
||||
} as Meta<typeof OptimizeHeader>;
|
||||
|
||||
const base = {
|
||||
updated: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
|
||||
horizonHours: 47,
|
||||
currency: CURRENCY.EUR,
|
||||
chargingStrategies: ["charge_before_export", "attenuate_grid_peaks", "none"],
|
||||
selectedStrategy: "charge_before_export",
|
||||
pending: false,
|
||||
};
|
||||
|
||||
const Template: StoryFn<typeof OptimizeHeader> = (args) => ({
|
||||
components: { OptimizeHeader },
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
template: '<div class="container px-0"><OptimizeHeader v-bind="args" /></div>',
|
||||
});
|
||||
|
||||
// you pay the grid (normal case): neutral color
|
||||
export const Payment = Template.bind({});
|
||||
Payment.args = { ...base, status: OptimizationStatus.OPTIMAL, netCost: 12.4 };
|
||||
|
||||
// solar surplus, you receive a credit: green, signed
|
||||
export const Credit = Template.bind({});
|
||||
Credit.args = { ...base, status: OptimizationStatus.OPTIMAL, netCost: -3.8 };
|
||||
|
||||
// solver could not produce a plan
|
||||
export const Infeasible = Template.bind({});
|
||||
Infeasible.args = { ...base, status: OptimizationStatus.INFEASIBLE, netCost: 0 };
|
||||
341
assets/js/components/Optimize/OptimizeHeader.vue
Normal file
341
assets/js/components/Optimize/OptimizeHeader.vue
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
<template>
|
||||
<div class="optimize-header">
|
||||
<!-- Summary fields -->
|
||||
<div class="fields row g-0">
|
||||
<!-- Primary goal -->
|
||||
<div class="field col-12 col-md-6 col-lg-3">
|
||||
<div class="field-head">
|
||||
<div class="field-label small text-uppercase fw-bold evcc-gray">
|
||||
Primary goal
|
||||
</div>
|
||||
<div class="field-caption small evcc-gray mt-1">always, fixed</div>
|
||||
</div>
|
||||
<div class="field-value gap-2">
|
||||
<span class="large fw-bold text-lowercase evcc-default-text">Lowest cost</span>
|
||||
<LockIcon class="value-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Secondary goal (only interactive setting) -->
|
||||
<div class="field col-12 col-md-6 col-lg-3">
|
||||
<div class="field-head">
|
||||
<div class="field-label small text-uppercase fw-bold evcc-gray">
|
||||
Secondary goal
|
||||
</div>
|
||||
<div class="field-caption small evcc-gray mt-1">only on a tie</div>
|
||||
</div>
|
||||
<div class="field-value gap-2">
|
||||
<CustomSelect
|
||||
id="optimizerSecondaryGoal"
|
||||
:options="strategyOptions"
|
||||
:selected="selectedStrategy"
|
||||
@change="onStrategyChange"
|
||||
>
|
||||
<span
|
||||
class="large fw-bold text-lowercase evcc-default-text secondary-goal"
|
||||
>{{ secondaryGoalLabel }}</span
|
||||
>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result -->
|
||||
<div class="field col-12 col-md-6 col-lg-3">
|
||||
<div class="field-head">
|
||||
<div class="field-label small text-uppercase fw-bold evcc-gray">Result</div>
|
||||
<div class="field-caption small evcc-gray mt-1">
|
||||
<span v-if="relativeTime" class="tabular">{{ relativeTime }}, </span>
|
||||
<span v-if="pending" class="updating fw-bold text-decoration-underline"
|
||||
>updating…</span
|
||||
>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-link p-0 align-baseline fw-bold text-decoration-underline update-now"
|
||||
@click="$emit('optimize')"
|
||||
>
|
||||
update now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-value gap-2">
|
||||
<StatusIndicator :variant="statusVariant">
|
||||
<span class="large fw-bold text-lowercase evcc-default-text">{{
|
||||
status
|
||||
}}</span>
|
||||
</StatusIndicator>
|
||||
<span
|
||||
ref="statusInfo"
|
||||
class="value-icon info-icon"
|
||||
data-bs-toggle="tooltip"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<shopicon-regular-info></shopicon-regular-info>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Net grid cost -->
|
||||
<div class="field col-12 col-md-6 col-lg-3">
|
||||
<div class="field-head">
|
||||
<div class="field-label small text-uppercase fw-bold evcc-gray">
|
||||
Net grid cost
|
||||
</div>
|
||||
<div class="field-caption small evcc-gray mt-1">
|
||||
over the next {{ horizonHours }} hours
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-value gap-2">
|
||||
<span
|
||||
class="fs-4 fw-bold tabular"
|
||||
:class="isCredit ? 'text-primary' : 'evcc-default-text'"
|
||||
>
|
||||
{{ netCostDisplay }}
|
||||
</span>
|
||||
<span
|
||||
ref="costInfo"
|
||||
class="value-icon info-icon"
|
||||
data-bs-toggle="tooltip"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<shopicon-regular-info></shopicon-regular-info>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import Tooltip from "bootstrap/js/dist/tooltip";
|
||||
import CustomSelect from "../Helper/CustomSelect.vue";
|
||||
import StatusIndicator from "../Config/StatusIndicator.vue";
|
||||
import LockIcon from "../MaterialIcon/Lock.vue";
|
||||
import formatter from "@/mixins/formatter";
|
||||
import minuteTicker from "@/mixins/minuteTicker";
|
||||
import { CURRENCY, OptimizationStatus } from "@/types/evcc";
|
||||
import "@h2d2/shopicons/es/regular/info";
|
||||
|
||||
// human labels for the optimizer grid charging strategies; the available values
|
||||
// themselves come from backend state to avoid drift if the enum changes
|
||||
const STRATEGY_LABELS: Record<string, string> = {
|
||||
charge_before_export: "fill battery first",
|
||||
attenuate_grid_peaks: "reduce grid peaks",
|
||||
none: "no preference",
|
||||
};
|
||||
|
||||
const STATUS_TOOLTIP =
|
||||
"The optimizer result:<br><br>" +
|
||||
"<strong>Optimal</strong>: the best plan was found.<br>" +
|
||||
"<strong>Infeasible</strong>: the constraints cannot all be met.<br>" +
|
||||
"<strong>Not solved</strong>: optimization hasn't finished yet.";
|
||||
|
||||
const COST_TOOLTIP =
|
||||
"Forecasted net grid cost over the optimization window: energy bought minus sold, " +
|
||||
"with leftover battery charge credited at the lowest price. Lower is better, " +
|
||||
"positive means you pay the grid, negative means you receive a credit.";
|
||||
|
||||
export default defineComponent({
|
||||
name: "OptimizeHeader",
|
||||
components: {
|
||||
CustomSelect,
|
||||
StatusIndicator,
|
||||
LockIcon,
|
||||
},
|
||||
mixins: [formatter, minuteTicker],
|
||||
props: {
|
||||
updated: { type: String, default: "" },
|
||||
status: { type: String as PropType<OptimizationStatus>, default: undefined },
|
||||
netCost: { type: Number, default: 0 },
|
||||
horizonHours: { type: Number, default: 0 },
|
||||
currency: { type: String as PropType<CURRENCY>, default: CURRENCY.EUR },
|
||||
chargingStrategies: { type: Array as PropType<string[]>, default: () => [] },
|
||||
selectedStrategy: { type: String, default: "" },
|
||||
pending: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ["optimize", "change-strategy"],
|
||||
data() {
|
||||
return {
|
||||
tooltips: [] as Tooltip[],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
strategyOptions() {
|
||||
return this.chargingStrategies.map((value) => ({
|
||||
value,
|
||||
name: STRATEGY_LABELS[value] || value,
|
||||
}));
|
||||
},
|
||||
secondaryGoalLabel(): string {
|
||||
return STRATEGY_LABELS[this.selectedStrategy] || this.selectedStrategy;
|
||||
},
|
||||
relativeTime(): string {
|
||||
if (!this.updated) return "";
|
||||
const elapsed = new Date(this.updated).getTime() - this.everyMinute.getTime();
|
||||
if (Math.abs(elapsed) < 60 * 1000) return "just now";
|
||||
return this.fmtTimeAgo(elapsed);
|
||||
},
|
||||
statusVariant(): "success" | "warning" | "muted" {
|
||||
switch (this.status) {
|
||||
case OptimizationStatus.OPTIMAL:
|
||||
return "success";
|
||||
case OptimizationStatus.INFEASIBLE:
|
||||
case OptimizationStatus.UNBOUNDED:
|
||||
return "warning";
|
||||
default:
|
||||
return "muted";
|
||||
}
|
||||
},
|
||||
isCredit(): boolean {
|
||||
return this.netCost < 0;
|
||||
},
|
||||
netCostDisplay(): string {
|
||||
return this.fmtMoney(this.netCost, this.currency, true, true);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.initTooltips();
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.tooltips.forEach((t) => t.dispose());
|
||||
},
|
||||
methods: {
|
||||
onStrategyChange(e: Event) {
|
||||
this.$emit("change-strategy", (e.target as HTMLSelectElement).value);
|
||||
},
|
||||
initTooltips() {
|
||||
const items: [Element | undefined, string][] = [
|
||||
[this.$refs["statusInfo"] as Element | undefined, STATUS_TOOLTIP],
|
||||
[this.$refs["costInfo"] as Element | undefined, COST_TOOLTIP],
|
||||
];
|
||||
for (const [el, title] of items) {
|
||||
if (el) {
|
||||
// left-align via inner markup: tooltips render at <body>, outside scoped styles
|
||||
this.tooltips.push(
|
||||
new Tooltip(el, {
|
||||
title: `<div class="text-start">${title}</div>`,
|
||||
html: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* underline + weight come from utility classes; only the gray/hover color is custom */
|
||||
.update-now {
|
||||
font-size: inherit;
|
||||
color: var(--evcc-gray);
|
||||
}
|
||||
.update-now:hover {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
.updating {
|
||||
font-size: inherit;
|
||||
color: var(--evcc-gray);
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
/* small: each field a full-width row (Bootstrap col-12), label left, value right */
|
||||
.field {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 0;
|
||||
border-top: 1px solid var(--evcc-gray-25);
|
||||
}
|
||||
.field:last-child {
|
||||
border-bottom: 1px solid var(--evcc-gray-25);
|
||||
}
|
||||
.field-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.field-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
.value-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--evcc-gray);
|
||||
}
|
||||
.info-icon {
|
||||
cursor: help;
|
||||
}
|
||||
.secondary-goal {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--evcc-gray);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
/* md and up: each field a vertical stack (label, value, caption); columns via Bootstrap grid */
|
||||
@media (min-width: 768px) {
|
||||
.field {
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
}
|
||||
.field:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
/* promote label + caption so value can sit between them via order */
|
||||
.field-head {
|
||||
display: contents;
|
||||
}
|
||||
.field-label {
|
||||
order: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.field-value {
|
||||
order: 1;
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
.field-caption {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* medium (col-md-6): two columns, dividers between */
|
||||
@media (min-width: 768px) and (max-width: 991.98px) {
|
||||
.field:nth-child(odd) {
|
||||
padding-left: 0;
|
||||
}
|
||||
.field:nth-child(even) {
|
||||
padding-right: 0;
|
||||
border-left: 1px solid var(--evcc-gray-25);
|
||||
}
|
||||
.field:nth-child(n + 3) {
|
||||
border-top: 1px solid var(--evcc-gray-25);
|
||||
}
|
||||
}
|
||||
|
||||
/* large (col-lg-3): four equal blocks in one row, dividers between */
|
||||
@media (min-width: 992px) {
|
||||
.field {
|
||||
padding-block: 0;
|
||||
border-left: 1px solid var(--evcc-gray-25);
|
||||
}
|
||||
.field:first-child {
|
||||
padding-left: 0;
|
||||
border-left: none;
|
||||
}
|
||||
.field:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -128,6 +128,8 @@ export interface State {
|
|||
ocpp?: Ocpp;
|
||||
ocppforwarder?: ConfigStatus<OcppForwarderRule[], OcppForwarderSession[]>;
|
||||
optimizer?: boolean;
|
||||
optimizerChargingStrategy?: string;
|
||||
optimizerChargingStrategies?: string[];
|
||||
mcp?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -729,6 +731,7 @@ export type ValueOf<T> = T[keyof T];
|
|||
|
||||
// EvOpt interfaces matching OpenAPI spec exactly
|
||||
export interface EvOpt {
|
||||
updated: string;
|
||||
req: OptimizationInput;
|
||||
res: OptimizationResult;
|
||||
details: OptimizationDetails;
|
||||
|
|
|
|||
|
|
@ -1,42 +1,25 @@
|
|||
<template>
|
||||
<div class="container px-4 safe-area-inset">
|
||||
<TopHeader title="Optimize Debug" />
|
||||
<div class="alert alert-light mb-5 d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
This page is for development purposes only. Gives insights into the upcoming
|
||||
optimization algorithm.
|
||||
</span>
|
||||
<button
|
||||
class="btn btn-sm ms-3 text-nowrap"
|
||||
:class="optimizeCooldown ? 'btn-outline-secondary disabled' : 'btn-outline-dark'"
|
||||
:disabled="optimizeCooldown"
|
||||
@click="optimizeNow"
|
||||
>
|
||||
{{ optimizeCooldown ? "Requested" : "Optimize now" }}
|
||||
</button>
|
||||
</div>
|
||||
<TopHeader title="Optimize Debug 🧪" />
|
||||
<OptimizeHeader
|
||||
class="mt-4 mb-5"
|
||||
:updated="evopt?.updated"
|
||||
:status="evopt?.res?.status"
|
||||
:net-cost="netCost"
|
||||
:horizon-hours="horizonHours"
|
||||
:currency="currency"
|
||||
:charging-strategies="chargingStrategies"
|
||||
:selected-strategy="optimizerChargingStrategy"
|
||||
:pending="pending"
|
||||
@optimize="optimizeNow"
|
||||
@change-strategy="changeChargingStrategy"
|
||||
/>
|
||||
<div class="row">
|
||||
<main class="col-12">
|
||||
<div v-if="evopt">
|
||||
<!-- Optimizer Plan -->
|
||||
<section class="mb-5">
|
||||
<h3
|
||||
class="fw-normal d-flex gap-3 flex-wrap d-flex align-items-baseline overflow-hidden mb-4"
|
||||
>
|
||||
<span class="d-block no-wrap text-truncate">Result: Charging Plan</span>
|
||||
<small class="d-block no-wrap text-truncate">
|
||||
{{ evopt.res.status }} ・
|
||||
{{
|
||||
fmtMoney(
|
||||
(evopt.res.objective_value || 0) * -1,
|
||||
currency,
|
||||
true,
|
||||
true
|
||||
)
|
||||
}}
|
||||
net cost
|
||||
</small>
|
||||
</h3>
|
||||
<h3 class="fw-normal mb-4">Result: Charging Plan</h3>
|
||||
<ChargeChart
|
||||
:evopt="evopt"
|
||||
:battery-details="evopt.details.batteryDetails"
|
||||
|
|
@ -137,6 +120,7 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import Header from "../components/Top/Header.vue";
|
||||
import OptimizeHeader from "../components/Optimize/OptimizeHeader.vue";
|
||||
import BatteryConfigurationTable from "../components/Optimize/BatteryConfigurationTable.vue";
|
||||
import SocChart from "../components/Optimize/SocChart.vue";
|
||||
import ChargeChart from "../components/Optimize/ChargeChart.vue";
|
||||
|
|
@ -154,6 +138,7 @@ export default defineComponent({
|
|||
name: "Optimize",
|
||||
components: {
|
||||
TopHeader: Header,
|
||||
OptimizeHeader,
|
||||
BatteryConfigurationTable,
|
||||
SocChart,
|
||||
ChargeChart,
|
||||
|
|
@ -164,7 +149,7 @@ export default defineComponent({
|
|||
mixins: [formatter],
|
||||
data() {
|
||||
return {
|
||||
optimizeCooldown: false,
|
||||
pending: false,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
|
|
@ -177,19 +162,19 @@ export default defineComponent({
|
|||
currency() {
|
||||
return store.state.currency || CURRENCY.EUR;
|
||||
},
|
||||
statusBadgeClass() {
|
||||
if (!this.evopt?.res.status) return "bg-secondary";
|
||||
|
||||
switch (this.evopt.res.status) {
|
||||
case "Optimal":
|
||||
return "bg-success";
|
||||
case "Infeasible":
|
||||
return "bg-danger";
|
||||
case "Unbounded":
|
||||
return "bg-warning";
|
||||
default:
|
||||
return "bg-secondary";
|
||||
}
|
||||
chargingStrategies(): string[] {
|
||||
return store.state.optimizerChargingStrategies || [];
|
||||
},
|
||||
optimizerChargingStrategy(): string {
|
||||
return store.state.optimizerChargingStrategy || "";
|
||||
},
|
||||
netCost(): number {
|
||||
return (this.evopt?.res?.objective_value || 0) * -1;
|
||||
},
|
||||
horizonHours(): number {
|
||||
const dt = this.evopt?.req?.time_series?.dt;
|
||||
if (!dt?.length) return 0;
|
||||
return Math.round(dt.reduce((sum, s) => sum + s, 0) / 3600);
|
||||
},
|
||||
deviceColors() {
|
||||
return deviceColorMap(store.state.deviceColors);
|
||||
|
|
@ -216,14 +201,18 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
watch: {
|
||||
evopt() {
|
||||
this.optimizeCooldown = false;
|
||||
"evopt.updated"() {
|
||||
// re-enable the refresh action once a fresh optimizer run lands
|
||||
this.pending = false;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
optimizeNow() {
|
||||
this.pending = true;
|
||||
api.post("optimize");
|
||||
this.optimizeCooldown = true;
|
||||
},
|
||||
changeChargingStrategy(value: string) {
|
||||
api.post(`optimizerchargingstrategy/${value}`);
|
||||
},
|
||||
dimColorBy25Percent(color: string): string {
|
||||
// Convert color to 25% opacity (40 in hex = 25% of 255)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ const (
|
|||
BufferSoc = "bufferSoc"
|
||||
BufferStartSoc = "bufferStartSoc"
|
||||
|
||||
// optimizer
|
||||
OptimizerChargingStrategy = "optimizerChargingStrategy"
|
||||
OptimizerChargingStrategies = "optimizerChargingStrategies"
|
||||
|
||||
// battery status
|
||||
Battery = "battery"
|
||||
BatteryMode = "batteryMode"
|
||||
|
|
|
|||
10
core/site.go
10
core/site.go
|
|
@ -80,6 +80,9 @@ type Site struct {
|
|||
batteryDischargeControl bool // prevent battery discharge for fast and planned charging
|
||||
batteryGridChargeLimit *float64 // grid charging limit
|
||||
|
||||
// optimizer settings
|
||||
optimizerChargingStrategy string // optimizer grid charging strategy
|
||||
|
||||
loadpoints []*Loadpoint // Loadpoints
|
||||
tariffs *tariff.Tariffs // Tariffs
|
||||
coordinator *coordinator.Coordinator // Vehicles
|
||||
|
|
@ -347,6 +350,13 @@ func (site *Site) restoreSettings() error {
|
|||
return err
|
||||
}
|
||||
}
|
||||
if v, err := settings.String(keys.OptimizerChargingStrategy); err == nil && v != "" {
|
||||
if err := site.SetOptimizerChargingStrategy(v); err != nil {
|
||||
site.log.WARN.Printf("optimizer charging strategy: %v", err)
|
||||
}
|
||||
}
|
||||
site.publish(keys.OptimizerChargingStrategy, site.GetOptimizerChargingStrategy())
|
||||
site.publish(keys.OptimizerChargingStrategies, optimizerChargingStrategies)
|
||||
|
||||
// drop legacy accumulator-based forecast settings (now stored via metrics collector)
|
||||
settings.Delete("solarAccForecast")
|
||||
|
|
|
|||
|
|
@ -54,6 +54,11 @@ type API interface {
|
|||
// SetBatteryGridChargeLimit sets the grid charge limit
|
||||
SetBatteryGridChargeLimit(limit *float64) error
|
||||
|
||||
// GetOptimizerChargingStrategy gets the optimizer grid charging strategy
|
||||
GetOptimizerChargingStrategy() string
|
||||
// SetOptimizerChargingStrategy sets the optimizer grid charging strategy
|
||||
SetOptimizerChargingStrategy(strategy string) error
|
||||
|
||||
//
|
||||
// power and energy
|
||||
//
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package core
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -383,6 +385,43 @@ func (site *Site) SetBatteryGridChargeLimit(val *float64) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// GetOptimizerChargingStrategy returns the optimizer grid charging strategy,
|
||||
// falling back to the default when unset.
|
||||
func (site *Site) GetOptimizerChargingStrategy() string {
|
||||
site.RLock()
|
||||
defer site.RUnlock()
|
||||
if site.optimizerChargingStrategy == "" {
|
||||
return defaultOptimizerChargingStrategy
|
||||
}
|
||||
return site.optimizerChargingStrategy
|
||||
}
|
||||
|
||||
// SetOptimizerChargingStrategy validates and persists the optimizer grid
|
||||
// charging strategy and re-runs the optimizer when it changes.
|
||||
func (site *Site) SetOptimizerChargingStrategy(strategy string) error {
|
||||
if !slices.Contains(optimizerChargingStrategies, strategy) {
|
||||
return fmt.Errorf("invalid optimizer charging strategy: %s", strategy)
|
||||
}
|
||||
|
||||
site.Lock()
|
||||
changed := site.optimizerChargingStrategy != strategy
|
||||
if changed {
|
||||
site.optimizerChargingStrategy = strategy
|
||||
}
|
||||
site.Unlock()
|
||||
|
||||
if changed {
|
||||
site.log.DEBUG.Println("set optimizer charging strategy:", strategy)
|
||||
settings.SetString(keys.OptimizerChargingStrategy, strategy)
|
||||
site.publish(keys.OptimizerChargingStrategy, strategy)
|
||||
|
||||
// re-run the optimizer so the new strategy takes effect immediately
|
||||
site.triggerOptimizer()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBatteryMode returns the battery mode
|
||||
func (site *Site) GetBatteryMode() api.BatteryMode {
|
||||
site.RLock()
|
||||
|
|
|
|||
|
|
@ -36,11 +36,39 @@ var (
|
|||
optimizerUpdated time.Time
|
||||
)
|
||||
|
||||
// optimizerChargingStrategies are the valid grid charging strategies; the first
|
||||
// entry is the default and preserves the previous hard-coded behavior.
|
||||
var optimizerChargingStrategies = []string{
|
||||
string(optimizer.OptimizerStrategyChargingStrategyChargeBeforeExport),
|
||||
string(optimizer.OptimizerStrategyChargingStrategyAttenuateGridPeaks),
|
||||
string(optimizer.OptimizerStrategyChargingStrategyNone),
|
||||
}
|
||||
|
||||
const defaultOptimizerChargingStrategy = string(optimizer.OptimizerStrategyChargingStrategyChargeBeforeExport)
|
||||
|
||||
// triggerOptimizer re-runs the optimizer immediately so a changed setting takes
|
||||
// effect without waiting for the next slot. It is a no-op when the optimizer is
|
||||
// not active or a run is already in progress; the running update reflects the
|
||||
// change on its next slot.
|
||||
func (site *Site) triggerOptimizer() {
|
||||
if !sponsor.IsAuthorized() || !optimizerEnabled() {
|
||||
return
|
||||
}
|
||||
if !mu.TryLock() {
|
||||
return
|
||||
}
|
||||
optimizerUpdated = time.Time{} // bypass the slot/debounce gate
|
||||
mu.Unlock()
|
||||
|
||||
go site.optimizerUpdateAsync()
|
||||
}
|
||||
|
||||
// optimizerResult wraps the optimizer publish payload to implement BytesMarshaler.
|
||||
// This ensures publishComplex serializes it as a single JSON message instead of
|
||||
// recursively decomposing each struct field and array element into individual MQTT
|
||||
// topics (~1,500 messages per optimizer run).
|
||||
type optimizerResult struct {
|
||||
Updated time.Time `json:"updated"`
|
||||
Req optimizer.OptimizationInput `json:"req"`
|
||||
Res optimizer.OptimizationResult `json:"res"`
|
||||
Details requestDetails `json:"details"`
|
||||
|
|
@ -164,7 +192,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
|
|||
|
||||
req := optimizer.OptimizationInput{
|
||||
Strategy: optimizer.OptimizerStrategy{
|
||||
ChargingStrategy: optimizer.OptimizerStrategyChargingStrategyChargeBeforeExport, // AttenuateGridPeaks
|
||||
ChargingStrategy: optimizer.OptimizerStrategyChargingStrategy(site.GetOptimizerChargingStrategy()),
|
||||
DischargingStrategy: optimizer.OptimizerStrategyDischargingStrategyDischargeBeforeImport,
|
||||
},
|
||||
EtaC: eta,
|
||||
|
|
@ -217,6 +245,10 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
|
|||
}
|
||||
|
||||
for i, dev := range site.batteryMeters {
|
||||
// measurements may lag the configured meters on an off-cycle trigger
|
||||
if i >= len(battery) {
|
||||
break
|
||||
}
|
||||
b := battery[i]
|
||||
|
||||
if b.Capacity == nil || *b.Capacity == 0 || b.Soc == nil {
|
||||
|
|
@ -258,6 +290,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
|
|||
}
|
||||
|
||||
site.publish("evopt", optimizerResult{
|
||||
Updated: time.Now(),
|
||||
Req: req,
|
||||
Res: *resp.JSON200,
|
||||
Details: details,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/core/loadpoint"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
optimizer "github.com/evcc-io/optimizer/client"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
|
@ -148,3 +149,17 @@ func TestBatteryForecastSocExtremes(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
func TestOptimizerChargingStrategy(t *testing.T) {
|
||||
site := &Site{log: util.NewLogger("foo")}
|
||||
|
||||
// default when unset
|
||||
assert.Equal(t, defaultOptimizerChargingStrategy, site.GetOptimizerChargingStrategy())
|
||||
|
||||
// invalid value rejected, strategy unchanged
|
||||
require.Error(t, site.SetOptimizerChargingStrategy("bogus"))
|
||||
assert.Equal(t, defaultOptimizerChargingStrategy, site.GetOptimizerChargingStrategy())
|
||||
|
||||
// valid change is applied (re-trigger is gated on sponsor/enabled, not unit-tested here)
|
||||
require.NoError(t, site.SetOptimizerChargingStrategy(string(optimizer.OptimizerStrategyChargingStrategyAttenuateGridPeaks)))
|
||||
assert.Equal(t, "attenuate_grid_peaks", site.GetOptimizerChargingStrategy())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -163,6 +163,8 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) {
|
|||
"optimize": {"POST", "/optimize", getHandler(site.Optimize)},
|
||||
"telemetry2": {"POST", "/settings/telemetry/{value:[01truefalse]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
|
||||
"devicecolors": {"PUT", "/devicecolors", updateDeviceColor(site)},
|
||||
|
||||
"optimizerchargingstrategy": {"POST", "/optimizerchargingstrategy/{value:[a-z_]+}", stringHandler(site.SetOptimizerChargingStrategy, site.GetOptimizerChargingStrategy)},
|
||||
}
|
||||
|
||||
for _, r := range routes {
|
||||
|
|
|
|||
|
|
@ -143,6 +143,11 @@ func boolHandler(set func(bool) error, get func() bool) http.HandlerFunc {
|
|||
return handler(strconv.ParseBool, set, get)
|
||||
}
|
||||
|
||||
// stringHandler updates string-param api
|
||||
func stringHandler(set func(string) error, get func() string) http.HandlerFunc {
|
||||
return handler(func(s string) (string, error) { return s, nil }, set, get)
|
||||
}
|
||||
|
||||
// durationHandler updates duration-param api
|
||||
func durationHandler(set func(time.Duration) error, get func() time.Duration) http.HandlerFunc {
|
||||
return handler(util.ParseDuration, set, get)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue