Optimizer: publish battery forecast (#24633)
This commit is contained in:
parent
4af949a8e4
commit
5f5c363538
15 changed files with 646 additions and 173 deletions
|
|
@ -68,6 +68,92 @@ GridAndPV.args = {
|
|||
},
|
||||
} as any;
|
||||
|
||||
function hoursFromNow(h: number): string {
|
||||
return new Date(Date.now() + h * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
const batteryBase = {
|
||||
gridConfigured: true,
|
||||
pvConfigured: true,
|
||||
batteryConfigured: true,
|
||||
homePower: 800,
|
||||
};
|
||||
|
||||
const bat = (power: number, soc: number, forecast: any, devices: any[] = []) => ({
|
||||
power,
|
||||
soc,
|
||||
capacity: 10,
|
||||
devices,
|
||||
forecast,
|
||||
});
|
||||
|
||||
const dev = (title: string, power: number, soc: number, forecast: any) => ({
|
||||
title,
|
||||
power,
|
||||
soc,
|
||||
capacity: 10,
|
||||
controllable: true,
|
||||
forecast,
|
||||
});
|
||||
|
||||
export const BatteryForecastDischarging = Template.bind({});
|
||||
BatteryForecastDischarging.args = {
|
||||
...batteryBase,
|
||||
gridPower: 500,
|
||||
homePower: 1800,
|
||||
battery: bat(1300, 62, { full: null, empty: hoursFromNow(0.4) }),
|
||||
} as any;
|
||||
|
||||
export const BatteryForecastCharging = Template.bind({});
|
||||
BatteryForecastCharging.args = {
|
||||
...batteryBase,
|
||||
pvPower: 6000,
|
||||
gridPower: -1000,
|
||||
battery: bat(-4200, 45, { full: hoursFromNow(2.5), empty: null }),
|
||||
} as any;
|
||||
|
||||
export const BatteryForecastBoth = Template.bind({});
|
||||
BatteryForecastBoth.args = {
|
||||
...batteryBase,
|
||||
pvPower: 3000,
|
||||
gridPower: -500,
|
||||
battery: bat(-1700, 70, { full: hoursFromNow(2), empty: hoursFromNow(36) }),
|
||||
} as any;
|
||||
|
||||
export const BatteryForecastMultiDischarging = Template.bind({});
|
||||
BatteryForecastMultiDischarging.args = {
|
||||
...batteryBase,
|
||||
gridPower: 500,
|
||||
homePower: 2500,
|
||||
battery: bat(2000, 55, { full: null, empty: hoursFromNow(48) }, [
|
||||
dev("Powerwall", 1200, 60, { full: null, empty: hoursFromNow(48) }),
|
||||
dev("BYD", 800, 48, { full: null, empty: hoursFromNow(0.25) }),
|
||||
]),
|
||||
} as any;
|
||||
|
||||
export const BatteryForecastMultiCharging = Template.bind({});
|
||||
BatteryForecastMultiCharging.args = {
|
||||
...batteryBase,
|
||||
pvPower: 8000,
|
||||
gridPower: -1000,
|
||||
homePower: 1000,
|
||||
battery: bat(-6000, 40, { full: hoursFromNow(26), empty: null }, [
|
||||
dev("Powerwall", -3500, 35, { full: hoursFromNow(26), empty: null }),
|
||||
dev("BYD", -2500, 47, { full: hoursFromNow(0.7), empty: null }),
|
||||
]),
|
||||
} as any;
|
||||
|
||||
export const BatteryForecastGridChargeLimit = Template.bind({});
|
||||
BatteryForecastGridChargeLimit.args = {
|
||||
...batteryBase,
|
||||
pvPower: 5000,
|
||||
gridPower: -500,
|
||||
batteryGridChargeLimit: 0.15,
|
||||
smartCostType: "price",
|
||||
currency: CURRENCY.EUR,
|
||||
battery: bat(-3700, 50, { full: hoursFromNow(1.5), empty: null }),
|
||||
} as any;
|
||||
|
||||
export const BatteryAndGrid = Template.bind({});
|
||||
BatteryAndGrid.args = {
|
||||
gridConfigured: true,
|
||||
|
|
|
|||
|
|
@ -126,14 +126,34 @@
|
|||
@details-clicked="openBatterySettingsModal"
|
||||
@toggle="toggleBattery"
|
||||
>
|
||||
<template v-if="batteryGridChargeLimitSet" #subline>
|
||||
<div class="d-none d-md-block"> </div>
|
||||
<template
|
||||
v-if="batteryForecastExists || batteryGridChargeLimitSet"
|
||||
#subline
|
||||
>
|
||||
<div
|
||||
v-if="batteryForecastEmpty"
|
||||
class="d-flex align-items-center mb-2"
|
||||
>
|
||||
<ForecastMessage>{{
|
||||
batteryForecastEmpty
|
||||
}}</ForecastMessage>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="batteryForecastFull"
|
||||
class="d-none d-md-block mb-2"
|
||||
>
|
||||
|
||||
</div>
|
||||
<div v-if="batteryGridChargeLimitSet" class="d-none d-md-block">
|
||||
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="hasMultipleBatteries" #expanded>
|
||||
<EnergyflowEntry
|
||||
v-for="(b, index) in batteryDevices"
|
||||
:key="index"
|
||||
:name="b.title || genericBatteryTitle(index)"
|
||||
:prediction="devicePrediction(b, false)"
|
||||
:details="b.soc"
|
||||
:detailsFmt="batteryFmt"
|
||||
:power="dischargePower(b.power)"
|
||||
|
|
@ -250,8 +270,24 @@
|
|||
@details-clicked="openBatterySettingsModal"
|
||||
@toggle="toggleBattery"
|
||||
>
|
||||
<template v-if="batteryGridChargeLimitSet" #subline>
|
||||
<template
|
||||
v-if="batteryForecastExists || batteryGridChargeLimitSet"
|
||||
#subline
|
||||
>
|
||||
<div
|
||||
v-if="batteryForecastFull"
|
||||
class="d-flex align-items-center mb-2"
|
||||
>
|
||||
<ForecastMessage>{{ batteryForecastFull }}</ForecastMessage>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="batteryForecastEmpty"
|
||||
class="d-none d-md-block mb-2"
|
||||
>
|
||||
|
||||
</div>
|
||||
<button
|
||||
v-if="batteryGridChargeLimitSet"
|
||||
type="button"
|
||||
class="btn-reset d-flex justify-content-between text-start pe-4"
|
||||
@click.stop="openBatterySettingsModal"
|
||||
|
|
@ -276,6 +312,7 @@
|
|||
v-for="(b, index) in batteryDevices"
|
||||
:key="index"
|
||||
:name="b.title || genericBatteryTitle(index)"
|
||||
:prediction="devicePrediction(b, true)"
|
||||
:details="b.soc"
|
||||
:detailsFmt="batteryFmt"
|
||||
:power="chargePower(b.power)"
|
||||
|
|
@ -311,6 +348,7 @@ import Visualization from "./Visualization.vue";
|
|||
import Entry from "./Entry.vue";
|
||||
import formatter, { POWER_UNIT } from "@/mixins/formatter";
|
||||
import AnimatedNumber from "../Helper/AnimatedNumber.vue";
|
||||
import ForecastMessage from "./ForecastMessage.vue";
|
||||
import settings from "@/settings";
|
||||
import collector from "@/mixins/collector.js";
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
|
|
@ -329,6 +367,7 @@ export default defineComponent({
|
|||
Visualization,
|
||||
EnergyflowEntry: Entry,
|
||||
AnimatedNumber,
|
||||
ForecastMessage,
|
||||
},
|
||||
mixins: [formatter, collector],
|
||||
props: {
|
||||
|
|
@ -363,7 +402,11 @@ export default defineComponent({
|
|||
forecast: { type: Object as PropType<Forecast>, default: () => ({}) },
|
||||
},
|
||||
data: () => {
|
||||
return { detailsOpen: false, detailsCompleteHeight: null as number | null, ready: false };
|
||||
return {
|
||||
detailsOpen: false,
|
||||
detailsCompleteHeight: null as number | null,
|
||||
ready: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
showCo2() {
|
||||
|
|
@ -545,6 +588,21 @@ export default defineComponent({
|
|||
consumers() {
|
||||
return [...this.aux, ...this.ext];
|
||||
},
|
||||
batteryForecastFull(): string | null {
|
||||
if (this.batteryExpanded && this.hasMultipleBatteries) return null;
|
||||
const timeago = this.fmtForecastRelative(this.battery?.forecast?.full);
|
||||
if (!timeago) return null;
|
||||
return this.$t("main.energyflow.batteryForecastFull", { timeago });
|
||||
},
|
||||
batteryForecastEmpty(): string | null {
|
||||
if (this.batteryExpanded && this.hasMultipleBatteries) return null;
|
||||
const timeago = this.fmtForecastRelative(this.battery?.forecast?.empty);
|
||||
if (!timeago) return null;
|
||||
return this.$t("main.energyflow.batteryForecastEmpty", { timeago });
|
||||
},
|
||||
batteryForecastExists(): boolean {
|
||||
return !!(this.batteryForecastEmpty || this.batteryForecastFull);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
pvConfigured() {
|
||||
|
|
@ -646,6 +704,31 @@ export default defineComponent({
|
|||
genericConsumerTitle(index: number) {
|
||||
return `${this.$t("config.devices.consumer")} #${index + 1}`;
|
||||
},
|
||||
fmtForecastRelative(isoString?: string | null): string | null {
|
||||
if (!isoString) return null;
|
||||
const elapsed = new Date(isoString).getTime() - Date.now();
|
||||
if (elapsed <= 0) return null;
|
||||
return this.fmtTimeAgo(elapsed, "always");
|
||||
},
|
||||
deviceForecastFull(device: { forecast?: { full?: string | null } }): string | null {
|
||||
return this.fmtForecastRelative(device.forecast?.full);
|
||||
},
|
||||
deviceForecastEmpty(device: { forecast?: { empty?: string | null } }): string | null {
|
||||
return this.fmtForecastRelative(device.forecast?.empty);
|
||||
},
|
||||
devicePrediction(
|
||||
device: { forecast?: { full?: string | null; empty?: string | null } },
|
||||
full: boolean
|
||||
): string | undefined {
|
||||
const timeago = full
|
||||
? this.deviceForecastFull(device)
|
||||
: this.deviceForecastEmpty(device);
|
||||
if (!timeago) return undefined;
|
||||
const key = full
|
||||
? "main.energyflow.batteryForecastFull"
|
||||
: "main.energyflow.batteryForecastEmpty";
|
||||
return this.$t(key, { timeago });
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@
|
|||
</span>
|
||||
<div class="d-flex flex-grow-1 ms-3 align-items-center text-truncate">
|
||||
<span v-if="!$slots['expanded']" class="text-truncate">
|
||||
{{ name }}
|
||||
{{ name
|
||||
}}<ForecastMessage v-if="prediction" class="ms-1" inline>{{
|
||||
prediction
|
||||
}}</ForecastMessage>
|
||||
</span>
|
||||
<button
|
||||
v-else
|
||||
|
|
@ -80,14 +83,16 @@ import formatter, { POWER_UNIT } from "@/mixins/formatter";
|
|||
import AnimatedNumber from "../Helper/AnimatedNumber.vue";
|
||||
import VehicleIcon from "../VehicleIcon";
|
||||
import ForecastIcon from "../MaterialIcon/Forecast.vue";
|
||||
import ForecastMessage from "./ForecastMessage.vue";
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "EnergyflowEntry",
|
||||
components: { BatteryIcon, AnimatedNumber, VehicleIcon, ForecastIcon },
|
||||
components: { BatteryIcon, AnimatedNumber, VehicleIcon, ForecastIcon, ForecastMessage },
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
name: { type: String },
|
||||
prediction: { type: String },
|
||||
icon: { type: String },
|
||||
iconProps: { type: Object, default: () => ({}) },
|
||||
power: { type: Number, default: 0 },
|
||||
|
|
|
|||
52
assets/js/components/Energyflow/ForecastMessage.vue
Normal file
52
assets/js/components/Energyflow/ForecastMessage.vue
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<template>
|
||||
<router-link to="/optimize" class="root" @click.stop>
|
||||
<template v-if="!inline">{{ $t("main.energyflow.forecast") }} </template>
|
||||
<span class="message"><slot /></span>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ForecastMessage",
|
||||
props: {
|
||||
inline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.root {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--evcc-battery) 0%,
|
||||
var(--evcc-battery) 15%,
|
||||
var(--evcc-self) 30%,
|
||||
var(--evcc-battery) 50%,
|
||||
var(--evcc-battery) 65%,
|
||||
var(--evcc-self) 80%,
|
||||
var(--evcc-battery) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: forecast-sweep 6s linear infinite;
|
||||
text-decoration: none;
|
||||
}
|
||||
.message {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@keyframes forecast-sweep {
|
||||
0% {
|
||||
background-position: 0% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -332,7 +332,7 @@ export default defineComponent({
|
|||
const unit = this.energyPriceSubunit(currency) || CURRENCY_SYMBOLS[currency] || currency;
|
||||
return `${unit}${short ? "" : "/kWh"}`;
|
||||
},
|
||||
fmtTimeAgo(elapsed: number) {
|
||||
fmtTimeAgo(elapsed: number, numeric: "auto" | "always" = "auto") {
|
||||
const units = {
|
||||
day: 24 * 60 * 60 * 1000,
|
||||
hour: 60 * 60 * 1000,
|
||||
|
|
@ -346,7 +346,7 @@ export default defineComponent({
|
|||
if (Math.abs(elapsed) > units[unitKey] || u == "second") {
|
||||
try {
|
||||
const rtf = new Intl.RelativeTimeFormat(this.$i18n?.locale, {
|
||||
numeric: "auto",
|
||||
numeric,
|
||||
});
|
||||
return rtf.format(Math.round(elapsed / units[unitKey]), unitKey);
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -511,17 +511,24 @@ export interface Meter {
|
|||
energy?: number;
|
||||
}
|
||||
|
||||
export interface BatteryForecast {
|
||||
full: string | null; // ISO 8601 datetime
|
||||
empty: string | null; // ISO 8601 datetime
|
||||
}
|
||||
|
||||
export interface Battery {
|
||||
power: number;
|
||||
capacity: number;
|
||||
soc: number;
|
||||
devices: BatteryMeter[];
|
||||
forecast?: BatteryForecast;
|
||||
}
|
||||
|
||||
export interface BatteryMeter extends Meter {
|
||||
soc: number;
|
||||
controllable: boolean;
|
||||
capacity: number; // 0 when not specified
|
||||
forecast?: BatteryForecast;
|
||||
}
|
||||
|
||||
export interface Vehicle {
|
||||
|
|
|
|||
20
core/site.go
20
core/site.go
|
|
@ -600,9 +600,9 @@ func (site *Site) updatePvMeters() {
|
|||
}
|
||||
|
||||
// updateBatteryMeters updates battery meters
|
||||
func (site *Site) updateBatteryMeters() []types.Measurement {
|
||||
func (site *Site) updateBatteryMeters() {
|
||||
if len(site.batteryMeters) == 0 {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
mm := site.collectMeters("battery", site.batteryMeters)
|
||||
|
|
@ -666,10 +666,14 @@ func (site *Site) updateBatteryMeters() []types.Measurement {
|
|||
site.log.DEBUG.Printf("battery soc: %.0f%%", math.Round(site.battery.Soc))
|
||||
}
|
||||
|
||||
site.battery.Devices = mm
|
||||
site.publish(keys.Battery, site.battery)
|
||||
// keep forecast property created by optimizer
|
||||
for i := range site.battery.Devices {
|
||||
mm[i].Forecast = site.battery.Devices[i].Forecast
|
||||
}
|
||||
|
||||
return mm
|
||||
site.battery.Devices = mm
|
||||
|
||||
site.publish(keys.Battery, util.NewSharder(keys.Battery, site.battery))
|
||||
}
|
||||
|
||||
// updateAuxMeters updates aux meters
|
||||
|
|
@ -756,10 +760,8 @@ func (site *Site) updateGridMeter() error {
|
|||
func (site *Site) updateMeters() error {
|
||||
var eg errgroup.Group
|
||||
|
||||
var battery []types.Measurement
|
||||
|
||||
eg.Go(func() error { site.updatePvMeters(); return nil })
|
||||
eg.Go(func() error { battery = site.updateBatteryMeters(); return nil })
|
||||
eg.Go(func() error { site.updateBatteryMeters(); return nil })
|
||||
eg.Go(func() error { site.updateAuxMeters(); return nil })
|
||||
eg.Go(func() error { site.updateExtMeters(); return nil })
|
||||
|
||||
|
|
@ -770,7 +772,7 @@ func (site *Site) updateMeters() error {
|
|||
}
|
||||
|
||||
if sponsor.IsAuthorized() {
|
||||
go site.optimizerUpdateAsync(battery)
|
||||
go site.optimizerUpdateAsync()
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -13,10 +13,12 @@ import (
|
|||
|
||||
evopt "github.com/andig/evopt/client"
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/core/keys"
|
||||
"github.com/evcc-io/evcc/core/loadpoint"
|
||||
"github.com/evcc-io/evcc/core/metrics"
|
||||
"github.com/evcc-io/evcc/core/types"
|
||||
"github.com/evcc-io/evcc/tariff"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/config"
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
"github.com/evcc-io/evcc/util/sponsor"
|
||||
|
|
@ -48,14 +50,40 @@ type batteryDetail struct {
|
|||
Capacity float64 `json:"capacity,omitempty"`
|
||||
}
|
||||
|
||||
type responseDetails struct {
|
||||
type batteryResult struct {
|
||||
batteryDetail
|
||||
Full *time.Time `json:"full"`
|
||||
Empty *time.Time `json:"empty"`
|
||||
}
|
||||
|
||||
// func (br batteryResult) MarshalJSON() ([]byte, error) {
|
||||
// var full, empty int64
|
||||
// if !br.Full.IsZero() {
|
||||
// full = int64(time.Until(br.Full).Seconds())
|
||||
// }
|
||||
// if !br.Empty.IsZero() {
|
||||
// empty = int64(time.Until(br.Empty).Seconds())
|
||||
// }
|
||||
|
||||
// return json.Marshal(struct {
|
||||
// batteryResult
|
||||
// UntilFull int64 `json:"untilFull,omitempty"`
|
||||
// UntilEmpty int64 `json:"untilEmpty,omitempty"`
|
||||
// }{
|
||||
// batteryResult: br,
|
||||
// UntilFull: full,
|
||||
// UntilEmpty: empty,
|
||||
// })
|
||||
// }
|
||||
|
||||
type requestDetails struct {
|
||||
Timestamps []time.Time `json:"timestamp"`
|
||||
BatteryDetails []batteryDetail `json:"batteryDetails"`
|
||||
}
|
||||
|
||||
const slotsPerHour = float64(time.Hour / tariff.SlotDuration)
|
||||
|
||||
func (site *Site) optimizerUpdateAsync(battery []types.Measurement) {
|
||||
func (site *Site) optimizerUpdateAsync() {
|
||||
var err error
|
||||
|
||||
if time.Since(updated) < 2*time.Minute {
|
||||
|
|
@ -79,7 +107,7 @@ func (site *Site) optimizerUpdateAsync(battery []types.Measurement) {
|
|||
}
|
||||
}()
|
||||
|
||||
err = site.optimizerUpdate(battery)
|
||||
err = site.optimizerUpdate(site.battery.Devices)
|
||||
}
|
||||
|
||||
func (site *Site) optimizerUpdate(battery []types.Measurement) error {
|
||||
|
|
@ -148,141 +176,37 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
|
|||
// end of horizon Wh value
|
||||
pa := lo.Min(req.TimeSeries.PN) * eta * 0.99
|
||||
|
||||
details := responseDetails{
|
||||
details := requestDetails{
|
||||
Timestamps: asTimestamps(dt),
|
||||
}
|
||||
|
||||
add := func(battery evopt.BatteryConfig, detail batteryDetail) {
|
||||
battery.PA = pa
|
||||
req.Batteries = append(req.Batteries, battery)
|
||||
details.BatteryDetails = append(details.BatteryDetails, detail)
|
||||
}
|
||||
|
||||
for _, lp := range site.Loadpoints() {
|
||||
// ignore disconnected loadpoints
|
||||
if lp.GetStatus() == api.StatusA {
|
||||
continue
|
||||
}
|
||||
|
||||
v := lp.GetVehicle()
|
||||
if v == nil || v.Capacity() == 0 {
|
||||
if v := lp.GetVehicle(); v == nil || v.Capacity() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
bat := evopt.BatteryConfig{
|
||||
ChargeFromGrid: true,
|
||||
CMin: float32(lp.EffectiveMinPower()),
|
||||
CMax: float32(lp.EffectiveMaxPower()),
|
||||
DMax: 0,
|
||||
SMin: 0,
|
||||
PA: pa,
|
||||
}
|
||||
|
||||
if profile := loadpointProfile(lp, minLen); profile != nil {
|
||||
bat.PDemand = prorate(profile, firstSlotDuration)
|
||||
}
|
||||
|
||||
detail := batteryDetail{
|
||||
Type: batteryTypeLoadpoint,
|
||||
Title: lp.GetTitle(),
|
||||
}
|
||||
|
||||
// vehicle
|
||||
maxSoc := v.Capacity() * 1e3 // Wh
|
||||
if v := lp.EffectiveLimitSoc(); v > 0 {
|
||||
maxSoc *= float64(v) / 100
|
||||
} else if v := lp.GetLimitEnergy(); v > 0 {
|
||||
maxSoc = v * 1e3
|
||||
}
|
||||
|
||||
bat.SInitial = float32(v.Capacity() * lp.GetSoc() * 10) // Wh
|
||||
bat.SMax = max(bat.SInitial, float32(maxSoc)) // prevent infeasible if current soc above maximum
|
||||
|
||||
detail.Type = batteryTypeVehicle
|
||||
detail.Capacity = v.Capacity()
|
||||
|
||||
if vt := v.GetTitle(); vt != "" {
|
||||
if detail.Title != "" {
|
||||
detail.Title += " – "
|
||||
}
|
||||
detail.Title += vt
|
||||
}
|
||||
|
||||
// find vehicle name/id
|
||||
for _, dev := range config.Vehicles().Devices() {
|
||||
if dev.Instance() == v {
|
||||
detail.Name = dev.Config().Name
|
||||
}
|
||||
}
|
||||
|
||||
var demand []float32
|
||||
|
||||
switch lp.GetMode() {
|
||||
case api.ModeOff:
|
||||
// disable charging
|
||||
bat.CMax = 0
|
||||
|
||||
case api.ModeNow:
|
||||
// forced max charging
|
||||
demand = continuousDemand(lp, minLen)
|
||||
|
||||
case api.ModeMinPV:
|
||||
// forced min charging
|
||||
demand = continuousDemand(lp, minLen)
|
||||
// add smartcost limit and plan goal, if configured
|
||||
demand = applySmartCostLimit(lp, demand, grid, minLen)
|
||||
site.applyPlanGoal(lp, &bat, minLen)
|
||||
|
||||
case api.ModePV:
|
||||
// add smartcost limit and plan goal, if configured
|
||||
demand = applySmartCostLimit(lp, nil, grid, minLen)
|
||||
site.applyPlanGoal(lp, &bat, minLen)
|
||||
}
|
||||
|
||||
if demand != nil {
|
||||
bat.PDemand = prorate(demand, firstSlotDuration)
|
||||
}
|
||||
|
||||
req.Batteries = append(req.Batteries, bat)
|
||||
|
||||
details.BatteryDetails = append(details.BatteryDetails, detail)
|
||||
add(site.loadpointRequest(lp, minLen, firstSlotDuration, grid))
|
||||
}
|
||||
|
||||
for i, b := range battery {
|
||||
for i, dev := range site.batteryMeters {
|
||||
b := battery[i]
|
||||
|
||||
if b.Capacity == nil || *b.Capacity == 0 || b.Soc == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
dev := site.batteryMeters[i]
|
||||
|
||||
bat := evopt.BatteryConfig{
|
||||
CMax: batteryPower,
|
||||
DMax: batteryPower,
|
||||
SInitial: float32(*b.Capacity * *b.Soc * 10), // Wh
|
||||
PA: pa,
|
||||
}
|
||||
bat.SMax = max(bat.SInitial, float32(*b.Capacity*1e3)) // Wh
|
||||
|
||||
instance := dev.Instance()
|
||||
|
||||
if _, ok := instance.(api.BatteryController); ok {
|
||||
bat.ChargeFromGrid = true
|
||||
}
|
||||
|
||||
if m, ok := instance.(api.BatteryPowerLimiter); ok {
|
||||
charge, discharge := m.GetPowerLimits()
|
||||
bat.CMax = float32(charge)
|
||||
bat.DMax = float32(discharge)
|
||||
}
|
||||
|
||||
if m, ok := instance.(api.BatterySocLimiter); ok {
|
||||
minSoc, maxSoc := m.GetSocLimits()
|
||||
bat.SMin = min(bat.SInitial, float32(*b.Capacity*minSoc*10)) // Wh
|
||||
bat.SMax = max(bat.SInitial, float32(*b.Capacity*maxSoc*10)) // Wh
|
||||
}
|
||||
|
||||
req.Batteries = append(req.Batteries, bat)
|
||||
|
||||
details.BatteryDetails = append(details.BatteryDetails, batteryDetail{
|
||||
Type: batteryTypeBattery,
|
||||
Name: dev.Config().Name,
|
||||
Title: deviceProperties(dev).Title,
|
||||
Capacity: *b.Capacity,
|
||||
})
|
||||
add(site.batteryRequest(dev, b))
|
||||
}
|
||||
|
||||
httpClient := request.NewClient(site.log)
|
||||
|
|
@ -310,13 +234,225 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
|
|||
site.publish("evopt", struct {
|
||||
Req evopt.OptimizationInput `json:"req"`
|
||||
Res evopt.OptimizationResult `json:"res"`
|
||||
Details responseDetails `json:"details"`
|
||||
Details requestDetails `json:"details"`
|
||||
}{
|
||||
Req: req,
|
||||
Res: *resp.JSON200,
|
||||
Details: details,
|
||||
})
|
||||
|
||||
var batteries []batteryResult
|
||||
for i, batReq := range req.Batteries {
|
||||
batResp := resp.JSON200.Batteries[i]
|
||||
|
||||
batResult := batteryResult{
|
||||
batteryDetail: details.BatteryDetails[i],
|
||||
Full: matchSoc(batResp.StateOfCharge, func(soc float32) bool {
|
||||
return soc >= batReq.SMax
|
||||
}),
|
||||
Empty: matchSoc(batResp.StateOfCharge, func(soc float32) bool {
|
||||
return soc <= batReq.SMin
|
||||
}),
|
||||
}
|
||||
|
||||
batteries = append(batteries, batResult)
|
||||
|
||||
for j, dev := range site.batteryMeters {
|
||||
if details.BatteryDetails[i].Name == dev.Config().Name {
|
||||
site.battery.Devices[j].Forecast = &types.BatteryForecast{
|
||||
Full: batResult.Full,
|
||||
Empty: batResult.Empty,
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
site.publish("evopt-batteries", batteries)
|
||||
|
||||
site.battery.Forecast = site.addBatteryForecastTotals(req.Batteries, resp.JSON200.Batteries)
|
||||
|
||||
site.publish(keys.Battery, util.NewSharder(keys.Battery, site.battery))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (site *Site) addBatteryForecastTotals(req []evopt.BatteryConfig, resp []evopt.BatteryResult) *types.BatteryForecast {
|
||||
if len(resp) == 0 || len(resp[0].StateOfCharge) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now().Round(tariff.SlotDuration)
|
||||
fullSlot, emptySlot := site.batteryForecastFullAndEmptySlots(req, resp)
|
||||
|
||||
const zero = -1
|
||||
if fullSlot == zero && emptySlot == zero {
|
||||
return nil
|
||||
}
|
||||
|
||||
var res types.BatteryForecast
|
||||
if fullSlot != zero {
|
||||
if ts := now.Add(time.Duration(fullSlot) * tariff.SlotDuration); ts.After(time.Now()) {
|
||||
res.Full = new(ts)
|
||||
}
|
||||
}
|
||||
if emptySlot != zero {
|
||||
if ts := now.Add(time.Duration(emptySlot) * tariff.SlotDuration); ts.After(time.Now()) {
|
||||
res.Empty = new(ts)
|
||||
}
|
||||
}
|
||||
|
||||
return &res
|
||||
}
|
||||
|
||||
func (site *Site) batteryForecastFullAndEmptySlots(req []evopt.BatteryConfig, resp []evopt.BatteryResult) (int, int) {
|
||||
matchSlot := func(fun func(soc float32, batIdx int) bool) int {
|
||||
for i := range resp[0].StateOfCharge {
|
||||
for batIdx := range req {
|
||||
if fun(resp[batIdx].StateOfCharge[i], batIdx) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
fullSlot := matchSlot(func(soc float32, batIdx int) bool {
|
||||
return soc >= req[batIdx].SMax
|
||||
})
|
||||
emptySlot := matchSlot(func(soc float32, batIdx int) bool {
|
||||
return soc <= req[batIdx].SMin
|
||||
})
|
||||
|
||||
return fullSlot, emptySlot
|
||||
}
|
||||
|
||||
func (site *Site) loadpointRequest(lp loadpoint.API, minLen int, firstSlotDuration time.Duration, grid api.Rates) (evopt.BatteryConfig, batteryDetail) {
|
||||
bat := evopt.BatteryConfig{
|
||||
ChargeFromGrid: true,
|
||||
CMin: float32(lp.EffectiveMinPower()),
|
||||
CMax: float32(lp.EffectiveMaxPower()),
|
||||
DMax: 0,
|
||||
SMin: 0,
|
||||
// PA: pa,
|
||||
}
|
||||
|
||||
if profile := loadpointProfile(lp, minLen); profile != nil {
|
||||
bat.PDemand = prorate(profile, firstSlotDuration)
|
||||
}
|
||||
|
||||
detail := batteryDetail{
|
||||
Type: batteryTypeLoadpoint,
|
||||
Title: lp.GetTitle(),
|
||||
}
|
||||
|
||||
// vehicle
|
||||
v := lp.GetVehicle()
|
||||
|
||||
maxSoc := v.Capacity() * 1e3 // Wh
|
||||
if v := lp.EffectiveLimitSoc(); v > 0 {
|
||||
maxSoc *= float64(v) / 100
|
||||
} else if v := lp.GetLimitEnergy(); v > 0 {
|
||||
maxSoc = v * 1e3
|
||||
}
|
||||
|
||||
bat.SInitial = float32(v.Capacity() * lp.GetSoc() * 10) // Wh
|
||||
bat.SMax = max(bat.SInitial, float32(maxSoc)) // prevent infeasible if current soc above maximum
|
||||
|
||||
detail.Type = batteryTypeVehicle
|
||||
detail.Capacity = v.Capacity()
|
||||
|
||||
if vt := v.GetTitle(); vt != "" {
|
||||
if detail.Title != "" {
|
||||
detail.Title += " – "
|
||||
}
|
||||
detail.Title += vt
|
||||
}
|
||||
|
||||
// find vehicle name/id
|
||||
for _, dev := range config.Vehicles().Devices() {
|
||||
if dev.Instance() == v {
|
||||
detail.Name = dev.Config().Name
|
||||
}
|
||||
}
|
||||
|
||||
var demand []float32
|
||||
|
||||
switch lp.GetMode() {
|
||||
case api.ModeOff:
|
||||
// disable charging
|
||||
bat.CMax = 0
|
||||
|
||||
case api.ModeNow:
|
||||
// forced max charging
|
||||
demand = continuousDemand(lp, minLen)
|
||||
|
||||
case api.ModeMinPV:
|
||||
// forced min charging
|
||||
demand = continuousDemand(lp, minLen)
|
||||
// add smartcost limit and plan goal, if configured
|
||||
demand = applySmartCostLimit(lp, demand, grid, minLen)
|
||||
site.applyPlanGoal(lp, &bat, minLen)
|
||||
|
||||
case api.ModePV:
|
||||
// add smartcost limit and plan goal, if configured
|
||||
demand = applySmartCostLimit(lp, nil, grid, minLen)
|
||||
site.applyPlanGoal(lp, &bat, minLen)
|
||||
}
|
||||
|
||||
if demand != nil {
|
||||
bat.PDemand = prorate(demand, firstSlotDuration)
|
||||
}
|
||||
|
||||
return bat, detail
|
||||
}
|
||||
|
||||
func (site *Site) batteryRequest(dev config.Device[api.Meter], b types.Measurement) (evopt.BatteryConfig, batteryDetail) {
|
||||
bat := evopt.BatteryConfig{
|
||||
CMax: batteryPower,
|
||||
DMax: batteryPower,
|
||||
SInitial: float32(*b.Capacity * *b.Soc * 10), // Wh
|
||||
// PA: pa,
|
||||
}
|
||||
bat.SMax = max(bat.SInitial, float32(*b.Capacity*1e3)) // Wh
|
||||
|
||||
instance := dev.Instance()
|
||||
|
||||
if _, ok := instance.(api.BatteryController); ok {
|
||||
bat.ChargeFromGrid = true
|
||||
}
|
||||
|
||||
if m, ok := instance.(api.BatteryPowerLimiter); ok {
|
||||
charge, discharge := m.GetPowerLimits()
|
||||
bat.CMax = float32(charge)
|
||||
bat.DMax = float32(discharge)
|
||||
}
|
||||
|
||||
if m, ok := instance.(api.BatterySocLimiter); ok {
|
||||
minSoc, maxSoc := m.GetSocLimits()
|
||||
bat.SMin = min(bat.SInitial, float32(*b.Capacity*minSoc*10)) // Wh
|
||||
bat.SMax = max(bat.SInitial, float32(*b.Capacity*maxSoc*10)) // Wh
|
||||
}
|
||||
|
||||
detail := batteryDetail{
|
||||
Type: batteryTypeBattery,
|
||||
Name: dev.Config().Name,
|
||||
Title: deviceProperties(dev).Title,
|
||||
Capacity: *b.Capacity,
|
||||
}
|
||||
|
||||
return bat, detail
|
||||
}
|
||||
|
||||
func matchSoc(ts []float32, fun func(float32) bool) *time.Time {
|
||||
for i, soc := range ts {
|
||||
if fun(soc) {
|
||||
// TODO first slot
|
||||
return new(time.Now().Add(time.Duration(i+1) * tariff.SlotDuration))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
evopt "github.com/andig/evopt/client"
|
||||
"github.com/benbjohnson/clock"
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/core/loadpoint"
|
||||
"github.com/evcc-io/evcc/core/metrics"
|
||||
"github.com/evcc-io/evcc/server/db"
|
||||
"github.com/jinzhu/now"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
|
@ -108,3 +110,56 @@ func TestLoadpointProfile(t *testing.T) {
|
|||
// expected slots: 0.25 kWh...
|
||||
require.Equal(t, []float64{250, 250, 250, 250, 250, 250, 250, 50}, loadpointProfile(lp, 8))
|
||||
}
|
||||
|
||||
func TestBatteryForecastTotals(t *testing.T) {
|
||||
site := new(Site)
|
||||
|
||||
req := []evopt.BatteryConfig{
|
||||
{SMax: 80},
|
||||
{SMax: 80},
|
||||
}
|
||||
|
||||
const zero = -1
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
bat1, bat2 []float32
|
||||
full, empty int
|
||||
}{
|
||||
{
|
||||
"never full",
|
||||
[]float32{0, 0},
|
||||
[]float32{0, 0},
|
||||
zero, 0,
|
||||
},
|
||||
{
|
||||
"never empty",
|
||||
[]float32{100, 100},
|
||||
[]float32{100, 100},
|
||||
0, zero,
|
||||
},
|
||||
{
|
||||
"first full then empty",
|
||||
[]float32{100, 0},
|
||||
[]float32{100, 0},
|
||||
0, 1,
|
||||
},
|
||||
{
|
||||
"first empty then full",
|
||||
[]float32{0, 100},
|
||||
[]float32{0, 100},
|
||||
1, 0,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resp := []evopt.BatteryResult{
|
||||
{StateOfCharge: tc.bat1},
|
||||
{StateOfCharge: tc.bat2},
|
||||
}
|
||||
|
||||
full, empty := site.batteryForecastFullAndEmptySlots(req, resp)
|
||||
assert.Equal(t, tc.full, full, "full")
|
||||
assert.Equal(t, tc.empty, empty, "empty")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,51 @@
|
|||
package types
|
||||
|
||||
import "github.com/evcc-io/evcc/api"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
)
|
||||
|
||||
// Measurement is the device measurements struct
|
||||
type Measurement struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Power float64 `json:"power"`
|
||||
Energy float64 `json:"energy,omitempty"`
|
||||
Powers []float64 `json:"powers,omitempty"`
|
||||
Currents []float64 `json:"currents,omitempty"`
|
||||
ExcessDCPower float64 `json:"excessdcpower,omitempty"`
|
||||
Capacity *float64 `json:"capacity,omitempty"`
|
||||
Soc *float64 `json:"soc,omitempty"`
|
||||
Controllable *bool `json:"controllable,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Power float64 `json:"power"`
|
||||
Energy float64 `json:"energy,omitempty"`
|
||||
Powers []float64 `json:"powers,omitempty"`
|
||||
Currents []float64 `json:"currents,omitempty"`
|
||||
ExcessDCPower float64 `json:"excessdcpower,omitempty"`
|
||||
Capacity *float64 `json:"capacity,omitempty"`
|
||||
Soc *float64 `json:"soc,omitempty"`
|
||||
Controllable *bool `json:"controllable,omitempty"`
|
||||
Forecast *BatteryForecast `json:"forecast,omitempty"`
|
||||
}
|
||||
|
||||
type BatteryForecast struct {
|
||||
Full *time.Time `json:"full"`
|
||||
Empty *time.Time `json:"empty"`
|
||||
}
|
||||
|
||||
// func (f BatteryForecast) MarshalJSON() ([]byte, error) {
|
||||
// var full, empty int64
|
||||
// if !f.Full.IsZero() {
|
||||
// full = int64(time.Until(f.Full).Seconds())
|
||||
// }
|
||||
// if !f.Empty.IsZero() {
|
||||
// empty = int64(time.Until(f.Empty).Seconds())
|
||||
// }
|
||||
|
||||
// return json.Marshal(struct {
|
||||
// BatteryForecast
|
||||
// UntilFull int64 `json:"untilFull,omitempty"`
|
||||
// UntilEmpty int64 `json:"untilEmpty,omitempty"`
|
||||
// }{
|
||||
// BatteryForecast: f,
|
||||
// UntilFull: full,
|
||||
// UntilEmpty: empty,
|
||||
// })
|
||||
// }
|
||||
|
||||
var _ api.TitleDescriber = (*Measurement)(nil)
|
||||
|
||||
// GetTitle implements api.TitleDescriber interface for InfluxDB tagging
|
||||
|
|
@ -24,9 +54,10 @@ func (m Measurement) GetTitle() string {
|
|||
}
|
||||
|
||||
type BatteryState struct {
|
||||
Power float64 `json:"power"`
|
||||
Energy float64 `json:"energy,omitempty"`
|
||||
Capacity float64 `json:"capacity,omitempty"`
|
||||
Soc float64 `json:"soc"`
|
||||
Devices []Measurement `json:"devices,omitempty" influxdb:"battery"`
|
||||
Power float64 `json:"power"`
|
||||
Energy float64 `json:"energy,omitempty"`
|
||||
Capacity float64 `json:"capacity,omitempty"`
|
||||
Soc float64 `json:"soc"`
|
||||
Devices []Measurement `json:"devices,omitempty" influxdb:"battery"`
|
||||
Forecast *BatteryForecast `json:"forecast,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -992,10 +992,13 @@
|
|||
"battery": "Batterie",
|
||||
"batteryCharge": "Batterie laden",
|
||||
"batteryDischarge": "Batterie entladen",
|
||||
"batteryGridChargeActive": "Netzladen aktiv",
|
||||
"batteryGridChargeLimit": "Netzladen wenn",
|
||||
"batteryForecastEmpty": "leer {timeago}",
|
||||
"batteryForecastFull": "voll {timeago}",
|
||||
"batteryGridChargeActive": "Netzladen: aktiv",
|
||||
"batteryGridChargeLimit": "Netzladen: wenn",
|
||||
"batteryHold": "Batterie (gesperrt)",
|
||||
"batteryTooltip": "{energy} von {total} ({soc})",
|
||||
"forecast": "Vorhersage: ",
|
||||
"forecastTooltip": "Vorhersage: verbleibende PV-Produktion heute",
|
||||
"gridImport": "Netzbezug",
|
||||
"homePower": "Verbrauch",
|
||||
|
|
|
|||
|
|
@ -992,10 +992,13 @@
|
|||
"battery": "Battery",
|
||||
"batteryCharge": "Battery charging",
|
||||
"batteryDischarge": "Battery discharging",
|
||||
"batteryGridChargeActive": "grid charging active",
|
||||
"batteryGridChargeLimit": "grid charging when",
|
||||
"batteryForecastEmpty": "empty {timeago}",
|
||||
"batteryForecastFull": "full {timeago}",
|
||||
"batteryGridChargeActive": "Grid charging: active",
|
||||
"batteryGridChargeLimit": "Grid charging: when",
|
||||
"batteryHold": "Battery (locked)",
|
||||
"batteryTooltip": "{energy} of {total} ({soc})",
|
||||
"forecast": "Forecast: ",
|
||||
"forecastTooltip": "forecast: remaining solar production today",
|
||||
"gridImport": "Grid use",
|
||||
"homePower": "Consumption",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/core/types"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
|
@ -97,30 +98,37 @@ func (suite *mqttSuite) TestSlice() {
|
|||
}
|
||||
|
||||
func (suite *mqttSuite) TestMeasurement() {
|
||||
topics := []string{"test/title", "test/icon", "test/power", "test/energy", "test/powers", "test/currents", "test/excessDCPower", "test/capacity", "test/soc", "test/controllable"}
|
||||
topics := lo.Map([]string{
|
||||
"title", "icon", "power", "energy", "powers", "currents", "excessDCPower", "capacity", "soc", "controllable", "forecast",
|
||||
}, func(s string, _ int) string {
|
||||
return "test/" + s
|
||||
})
|
||||
|
||||
suite.publish("test", false, types.Measurement{})
|
||||
suite.Equal(topics, suite.topics, "topics")
|
||||
suite.Equal([]string{"", "", "0", "", "", "", "", "", "", ""}, suite.payloads, "payloads")
|
||||
suite.Equal([]string{"", "", "0", "", "", "", "", "", "", "", ""}, suite.payloads, "empty payloads")
|
||||
|
||||
suite.publish("test", false, types.Measurement{Energy: 1})
|
||||
suite.Equal(topics, suite.topics, "topics")
|
||||
suite.Equal([]string{"", "", "0", "1", "", "", "", "", "", ""}, suite.payloads, "payloads")
|
||||
suite.Equal([]string{"", "", "0", "1", "", "", "", "", "", "", ""}, suite.payloads, "energy payloads")
|
||||
|
||||
suite.publish("test", false, types.Measurement{Controllable: new(false)})
|
||||
suite.Equal(topics, suite.topics, "topics")
|
||||
suite.Equal([]string{"", "", "0", "", "", "", "", "", "", "false"}, suite.payloads, "payloads")
|
||||
suite.Equal([]string{"", "", "0", "", "", "", "", "", "", "false", ""}, suite.payloads, "controllable payloads")
|
||||
|
||||
suite.publish("test", false, types.Measurement{Currents: []float64{1, 2, 3}})
|
||||
suite.Equal(append(topics, "test/currents/1", "test/currents/2", "test/currents/3"), suite.topics, "topics")
|
||||
suite.Equal([]string{"", "", "0", "", "", "3", "", "", "", "", "1", "2", "3"}, suite.payloads, "payloads")
|
||||
suite.Equal(append(topics, "test/currents/1", "test/currents/2", "test/currents/3"), suite.topics, "currents topics")
|
||||
suite.Equal([]string{"", "", "0", "", "", "3", "", "", "", "", "", "1", "2", "3"}, suite.payloads, "currents payloads")
|
||||
}
|
||||
|
||||
func (suite *mqttSuite) TestBatteryState() {
|
||||
topics := []string{
|
||||
"test/power", "test/energy", "test/capacity", "test/soc", "test/devices",
|
||||
"test/devices/1/title", "test/devices/1/icon", "test/devices/1/power", "test/devices/1/energy", "test/devices/1/powers", "test/devices/1/currents", "test/devices/1/excessDCPower", "test/devices/1/capacity", "test/devices/1/soc", "test/devices/1/controllable",
|
||||
}
|
||||
topics := lo.Map([]string{
|
||||
"power", "energy", "capacity", "soc",
|
||||
"devices", "devices/1/title", "devices/1/icon", "devices/1/power", "devices/1/energy", "devices/1/powers", "devices/1/currents", "devices/1/excessDCPower", "devices/1/capacity", "devices/1/soc", "devices/1/controllable", "devices/1/forecast",
|
||||
"forecast",
|
||||
}, func(s string, _ int) string {
|
||||
return "test/" + s
|
||||
})
|
||||
|
||||
suite.publish("test", false, types.BatteryState{
|
||||
Power: 2,
|
||||
|
|
@ -132,5 +140,5 @@ func (suite *mqttSuite) TestBatteryState() {
|
|||
})
|
||||
|
||||
suite.Equal(topics, suite.topics, "topics")
|
||||
suite.Equal([]string{"2", "", "", "20", "1", "", "", "1", "", "", "", "", "", "10", ""}, suite.payloads, "payloads")
|
||||
suite.Equal([]string{"2", "", "", "20", "1", "", "", "1", "", "", "", "", "", "10", "", "", ""}, suite.payloads, "payloads")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,13 +27,13 @@ test.describe("battery settings co2", async () => {
|
|||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await expectModalHidden(modal);
|
||||
await page.getByTestId("energyflow").click();
|
||||
await page.getByRole("button", { name: "grid charging active (≤ 150 g)" }).click();
|
||||
await page.getByRole("button", { name: "Grid charging: active (≤ 150 g)" }).click();
|
||||
await expectModalVisible(modal);
|
||||
await modal.getByLabel("CO₂ limit").selectOption({ label: "≤ 10 g/kWh" });
|
||||
await expect(modal.getByTestId("active-hours")).toHaveText("Active time");
|
||||
await modal.getByRole("button", { name: "Close" }).click();
|
||||
await expectModalHidden(modal);
|
||||
await expect(modal).not.toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "grid charging when ≤ 10 g" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Grid charging: when ≤ 10 g" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -56,14 +56,16 @@ test.describe("battery settings", async () => {
|
|||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await expectModalHidden(modal);
|
||||
await page.getByTestId("energyflow").click();
|
||||
await page.getByRole("button", { name: "grid charging active (≤ 50.0 ct)" }).click();
|
||||
await page.getByRole("button", { name: "Grid charging: active (≤ 50.0 ct)" }).click();
|
||||
await expectModalVisible(modal);
|
||||
await modal.getByLabel("Price limit").selectOption({ label: "≤ -10.0 ct/kWh" });
|
||||
await expect(modal.getByTestId("active-hours")).toHaveText("Active time");
|
||||
await modal.getByRole("button", { name: "Close" }).click();
|
||||
await expectModalHidden(modal);
|
||||
await expect(modal).not.toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "grid charging when ≤ -10.0 ct" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Grid charging: when ≤ -10.0 ct" })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("hold mode display", async ({ page }) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue