Heating: add time-based energy statistics (today, 24h, 7d) to loadpoint tile (#31781)
This commit is contained in:
parent
31cf9748aa
commit
b02a40353f
15 changed files with 236 additions and 26 deletions
|
|
@ -192,4 +192,7 @@ Heating.args = {
|
|||
vehicleSoc: 48,
|
||||
effectiveLimitSoc: 60,
|
||||
vehicleLimitSoc: 65,
|
||||
todayEnergy: 3200,
|
||||
last24hEnergy: 4100,
|
||||
last7dEnergy: 26500,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -71,6 +71,14 @@
|
|||
/>
|
||||
</div>
|
||||
<LabelAndValue
|
||||
v-if="integratedDevice"
|
||||
:label="$t('main.loadpoint.todayEnergy')"
|
||||
:value="todayEnergy"
|
||||
:valueFmt="fmtEnergy"
|
||||
align="center"
|
||||
/>
|
||||
<LabelAndValue
|
||||
v-else
|
||||
v-show="socBasedCharging"
|
||||
:label="$t('main.loadpoint.charged')"
|
||||
:value="chargedEnergy"
|
||||
|
|
@ -205,6 +213,9 @@ export default defineComponent({
|
|||
vehicleWelcomeActive: Boolean,
|
||||
chargePower: { type: Number, default: 0 },
|
||||
chargedEnergy: { type: Number, default: 0 },
|
||||
todayEnergy: { type: Number, default: 0 },
|
||||
last24hEnergy: Number,
|
||||
last7dEnergy: Number,
|
||||
chargeRemainingDuration: { type: Number, default: 0 },
|
||||
|
||||
// other information
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
import { defineComponent, type PropType } from "vue";
|
||||
import LabelAndValue from "../Helper/LabelAndValue.vue";
|
||||
import CustomSelect from "../Helper/CustomSelect.vue";
|
||||
import formatter from "@/mixins/formatter";
|
||||
import formatter, { POWER_UNIT } from "@/mixins/formatter";
|
||||
import { getLoadpointSessionInfo, setLoadpointSessionInfo } from "@/uiLoadpoints";
|
||||
import type { CURRENCY, SelectOption, SessionInfoKey } from "@/types/evcc";
|
||||
|
||||
|
|
@ -51,6 +51,8 @@ export default defineComponent({
|
|||
chargeRemainingDurationInterpolated: { type: Number, default: 0 },
|
||||
chargeDurationInterpolated: Number,
|
||||
sessionEnergy: { type: Number, default: 0 },
|
||||
last24hEnergy: Number,
|
||||
last7dEnergy: Number,
|
||||
tariffCo2: Number,
|
||||
tariffGrid: Number,
|
||||
},
|
||||
|
|
@ -107,6 +109,16 @@ export default defineComponent({
|
|||
value: this.emissionFormatted,
|
||||
available: this.tariffCo2 !== undefined,
|
||||
},
|
||||
{
|
||||
key: "last24hEnergy" as const,
|
||||
value: this.fmtWh(this.last24hEnergy ?? 0, POWER_UNIT.AUTO),
|
||||
available: this.last24hEnergy !== undefined,
|
||||
},
|
||||
{
|
||||
key: "last7dEnergy" as const,
|
||||
value: this.fmtWh(this.last7dEnergy ?? 0, POWER_UNIT.AUTO),
|
||||
available: this.last7dEnergy !== undefined,
|
||||
},
|
||||
];
|
||||
// only show options that are available
|
||||
return result.filter(({ available }) => available === undefined || available);
|
||||
|
|
|
|||
|
|
@ -360,6 +360,8 @@ export interface Loadpoint {
|
|||
priority: number;
|
||||
pvAction: PV_ACTION;
|
||||
pvRemaining: number;
|
||||
last24hEnergy?: number;
|
||||
last7dEnergy?: number;
|
||||
sessionCo2PerKWh: number | null;
|
||||
sessionEnergy: number;
|
||||
sessionPrice: number | null;
|
||||
|
|
@ -373,6 +375,7 @@ export interface Loadpoint {
|
|||
smartFeedInPriorityNextStart: string | null;
|
||||
suggestion?: LoadpointSuggestion | null;
|
||||
title: string;
|
||||
todayEnergy?: number;
|
||||
vehicleClimaterActive: boolean | null;
|
||||
vehicleDetectionActive: boolean;
|
||||
vehicleLimitSoc: number;
|
||||
|
|
@ -503,7 +506,9 @@ export type SessionInfoKey =
|
|||
| "avgPrice"
|
||||
| "price"
|
||||
| "emission"
|
||||
| "co2";
|
||||
| "co2"
|
||||
| "last24hEnergy"
|
||||
| "last7dEnergy";
|
||||
|
||||
export interface SponsorStatus {
|
||||
name?: string;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ javascript:
|
|||
loadpoints: [
|
||||
{ enabled: true, vehicleSoc: 62, maxcurrent: 6, phases: 1, chargepower: 0 },
|
||||
{ enabled: false, vehicleSoc: 22, maxcurrent: 0, phases: 3, chargepower: 0 }
|
||||
]
|
||||
],
|
||||
heatpump: { mode: 2, power: 0, temp: 48 }
|
||||
};
|
||||
function logState() {
|
||||
console.log("state:", JSON.stringify(state));
|
||||
|
|
@ -30,7 +31,7 @@ meters:
|
|||
source: js
|
||||
vm: shared
|
||||
script: |
|
||||
state.gridpower = state.loadpoints[0].chargepower + state.loadpoints[1].chargepower + state.residualpower - batterypower - pvpower;
|
||||
state.gridpower = state.loadpoints[0].chargepower + state.loadpoints[1].chargepower + state.heatpump.power + state.residualpower - batterypower - pvpower;
|
||||
state.gridpower;
|
||||
in:
|
||||
- name: pvpower
|
||||
|
|
@ -167,9 +168,42 @@ chargers:
|
|||
if (phases === 1) lp.phases = 1; else lp.phases = 3;
|
||||
lp.phases;
|
||||
|
||||
- name: charger_3
|
||||
type: sgready
|
||||
features: [heating, integrateddevice, continuous, switchdevice]
|
||||
setmode:
|
||||
source: js
|
||||
vm: shared
|
||||
script: |
|
||||
state.heatpump.mode = mode;
|
||||
mode;
|
||||
getmode:
|
||||
source: js
|
||||
vm: shared
|
||||
script: state.heatpump.mode;
|
||||
power:
|
||||
source: js
|
||||
vm: shared
|
||||
script: |
|
||||
var hp = state.heatpump;
|
||||
hp.power = hp.mode === 3 ? 3800 + 400 * Math.random() : 300 + 300 * Math.random();
|
||||
hp.power;
|
||||
temp:
|
||||
source: js
|
||||
vm: shared
|
||||
script: |
|
||||
var hp = state.heatpump;
|
||||
if (hp.mode === 3) hp.temp += 0.2; else hp.temp -= 0.1;
|
||||
if (hp.temp > 65) hp.temp = 65;
|
||||
if (hp.temp < 35) hp.temp = 35;
|
||||
hp.temp;
|
||||
limittemp:
|
||||
source: const
|
||||
value: 65
|
||||
|
||||
vehicles:
|
||||
- name: vehicle_1
|
||||
title: blauer e-Golf
|
||||
title: blue e-Golf
|
||||
type: custom
|
||||
soc:
|
||||
source: js
|
||||
|
|
@ -189,7 +223,7 @@ vehicles:
|
|||
range
|
||||
capacity: 44
|
||||
- name: vehicle_2
|
||||
title: weißes Model 3
|
||||
title: white Model 3
|
||||
type: custom
|
||||
soc:
|
||||
source: js
|
||||
|
|
@ -219,19 +253,14 @@ vehicles:
|
|||
- name: vehicle_3
|
||||
type: template
|
||||
template: offline
|
||||
title: grüner Honda e
|
||||
title: green Honda e
|
||||
capacity: 8
|
||||
- name: vehicle_4
|
||||
type: template
|
||||
template: offline
|
||||
title: schwarzes VanMoof
|
||||
title: black VanMoof
|
||||
icon: bike
|
||||
capacity: 0.46
|
||||
- name: vehicle_5
|
||||
type: template
|
||||
template: offline
|
||||
title: Wärmepumpe
|
||||
icon: waterheater
|
||||
|
||||
site:
|
||||
title: Demo Mode
|
||||
|
|
@ -251,6 +280,12 @@ loadpoints:
|
|||
mode: "off"
|
||||
meter: meter_charger_2
|
||||
vehicle: vehicle_2
|
||||
- title: Heat pump
|
||||
charger: charger_3
|
||||
mode: pv
|
||||
ui:
|
||||
minTemp: 30
|
||||
maxTemp: 70
|
||||
|
||||
tariffs:
|
||||
currency: EUR # three letter ISO-4217 currency code (default EUR)
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@ const (
|
|||
ChargedEnergy = "chargedEnergy" // charged energy
|
||||
ChargeDuration = "chargeDuration" // charge duration
|
||||
ChargeTotalImport = "chargeTotalImport" // charge meter total import
|
||||
TodayEnergy = "todayEnergy" // energy since midnight
|
||||
Last24hEnergy = "last24hEnergy" // energy, rolling 24h
|
||||
Last7dEnergy = "last7dEnergy" // energy, rolling 7 days
|
||||
|
||||
// session
|
||||
ConnectedDuration = "connectedDuration" // connected duration
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ type Loadpoint struct {
|
|||
|
||||
Soc loadpoint.SocConfig
|
||||
Enable, Disable loadpoint.ThresholdConfig
|
||||
ui loadpoint.UIConfig // display-only, not used in control logic
|
||||
Ui loadpoint.UIConfig // display-only, not used in control logic
|
||||
|
||||
// from yaml
|
||||
DefaultMode api.ChargeMode `mapstructure:"mode"` // Default charge mode, used for disconnect
|
||||
|
|
@ -389,7 +389,7 @@ func (lp *Loadpoint) restoreSettings() {
|
|||
|
||||
var ui loadpoint.UIConfig
|
||||
if err := lp.settings.Json(keys.UI, &ui); err == nil {
|
||||
lp.ui = ui
|
||||
lp.Ui = ui
|
||||
}
|
||||
|
||||
t, err1 := lp.settings.Time(keys.PlanTime)
|
||||
|
|
@ -705,7 +705,7 @@ func (lp *Loadpoint) Prepare(site site.API, uiChan chan<- util.Param, pushChan c
|
|||
lp.publish(keys.EnableDelay, lp.Enable.Delay)
|
||||
lp.publish(keys.DisableDelay, lp.Disable.Delay)
|
||||
|
||||
lp.publish(keys.UI, lp.ui)
|
||||
lp.publish(keys.UI, lp.Ui)
|
||||
|
||||
if phases := lp.getChargerPhysicalPhases(); phases != 0 {
|
||||
if lp.phasesConfigured != phases && lp.phasesConfigured != 0 {
|
||||
|
|
@ -723,6 +723,7 @@ func (lp *Loadpoint) Prepare(site site.API, uiChan chan<- util.Param, pushChan c
|
|||
lp.publish(keys.SmartFeedInPriorityLimit, lp.smartFeedInPriorityLimit)
|
||||
lp.publishTimer(phaseTimer, 0, timerInactive)
|
||||
lp.publishTimer(pvTimer, 0, timerInactive)
|
||||
lp.publishEnergyStats()
|
||||
|
||||
// charger features
|
||||
for _, f := range api.FeatureValues() {
|
||||
|
|
@ -1851,6 +1852,25 @@ func (lp *Loadpoint) publishChargeProgress() {
|
|||
lp.chargeEnergy.SetSocTemp(v, lp.chargerHasFeature(api.Heating))
|
||||
}
|
||||
}
|
||||
|
||||
lp.publishEnergyStats()
|
||||
}
|
||||
|
||||
// publishEnergyStats publishes time-based energy statistics in Wh
|
||||
func (lp *Loadpoint) publishEnergyStats() {
|
||||
if lp.chargeEnergy == nil {
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := lp.chargeEnergy.EnergyStats()
|
||||
if err != nil {
|
||||
lp.log.ERROR.Printf("energy stats: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
lp.publish(keys.TodayEnergy, math.Round(stats.Today*1e3))
|
||||
lp.publish(keys.Last24hEnergy, math.Round(stats.Last24h*1e3))
|
||||
lp.publish(keys.Last7dEnergy, math.Round(stats.Last7d*1e3))
|
||||
}
|
||||
|
||||
// publish state of charge, remaining charge duration and range
|
||||
|
|
|
|||
|
|
@ -498,11 +498,11 @@ func (lp *Loadpoint) SetSocConfig(soc loadpoint.SocConfig) {
|
|||
func (lp *Loadpoint) GetUI() loadpoint.UIConfig {
|
||||
lp.RLock()
|
||||
defer lp.RUnlock()
|
||||
return lp.ui
|
||||
return lp.Ui
|
||||
}
|
||||
|
||||
func (lp *Loadpoint) setUI(ui loadpoint.UIConfig) {
|
||||
lp.ui = ui
|
||||
lp.Ui = ui
|
||||
lp.publish(keys.UI, ui)
|
||||
lp.settings.SetJson(keys.UI, ui)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ type Collector struct {
|
|||
entity entity
|
||||
accu *Accumulator
|
||||
started time.Time
|
||||
statsCache EnergyStats
|
||||
}
|
||||
|
||||
func NewCollector(group, name, title string, opt ...func(*Accumulator)) (*Collector, error) {
|
||||
|
|
|
|||
57
core/metrics/stats.go
Normal file
57
core/metrics/stats.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package metrics
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/server/db"
|
||||
"github.com/evcc-io/evcc/tariff"
|
||||
"github.com/jinzhu/now"
|
||||
)
|
||||
|
||||
// EnergyStats holds time-based energy sums in kWh
|
||||
type EnergyStats struct {
|
||||
slot time.Time // cache key
|
||||
Today float64
|
||||
Last24h float64
|
||||
Last7d float64
|
||||
}
|
||||
|
||||
// energyStats sums persisted slot energy for the periods ending at slotStart
|
||||
func energyStats(entity entity, slotStart, midnight time.Time) (EnergyStats, error) {
|
||||
res := EnergyStats{slot: slotStart}
|
||||
|
||||
sqlDB, err := db.Instance.DB()
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
err = sqlDB.QueryRow(`SELECT
|
||||
COALESCE(SUM(CASE WHEN ts >= ? THEN energy END), 0),
|
||||
COALESCE(SUM(CASE WHEN ts >= ? THEN energy END), 0),
|
||||
COALESCE(SUM(energy), 0)
|
||||
FROM meters WHERE meter = ? AND ts >= ? AND ts < ?`,
|
||||
midnight.Unix(), slotStart.Add(-24*time.Hour).Unix(),
|
||||
entity.Id, slotStart.AddDate(0, 0, -7).Unix(), slotStart.Unix(),
|
||||
).Scan(&res.Today, &res.Last24h, &res.Last7d)
|
||||
|
||||
return res, err
|
||||
}
|
||||
|
||||
// EnergyStats returns time-based energy sums. Persisted sums are cached per
|
||||
// slot, the in-flight accumulator is added to today only.
|
||||
func (c *Collector) EnergyStats() (EnergyStats, error) {
|
||||
slotStart := c.accu.clock.Now().Truncate(tariff.SlotDuration)
|
||||
|
||||
if !slotStart.Equal(c.statsCache.slot) {
|
||||
stats, err := energyStats(c.entity, slotStart, now.With(c.accu.clock.Now()).BeginningOfDay())
|
||||
if err != nil {
|
||||
return EnergyStats{}, err
|
||||
}
|
||||
|
||||
c.statsCache = stats
|
||||
}
|
||||
|
||||
res := c.statsCache
|
||||
res.Today += c.accu.Energy
|
||||
return res, nil
|
||||
}
|
||||
56
core/metrics/stats_test.go
Normal file
56
core/metrics/stats_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package metrics
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/benbjohnson/clock"
|
||||
"github.com/evcc-io/evcc/server/db"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCollectorEnergyStats(t *testing.T) {
|
||||
clock := clock.NewMock()
|
||||
|
||||
loc := time.Now().Location()
|
||||
midnight := time.Date(2026, 4, 15, 0, 0, 0, 0, loc)
|
||||
clock.Set(midnight.Add(2*time.Hour + 5*time.Minute))
|
||||
|
||||
slotStart := clock.Now().Truncate(15 * time.Minute)
|
||||
|
||||
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
||||
require.NoError(t, SetupSchema())
|
||||
|
||||
col, err := NewCollector("stats", "stats", "", WithClock(clock))
|
||||
require.NoError(t, err)
|
||||
|
||||
e := col.entity
|
||||
require.NoError(t, persist(e, slotStart.AddDate(0, 0, -8), 100, 0, nil)) // outside 7d
|
||||
require.NoError(t, persist(e, slotStart.Add(-24*time.Hour-15*time.Minute), 5, 0, nil)) // 7d only
|
||||
require.NoError(t, persist(e, slotStart.Add(-24*time.Hour), 7, 0, nil)) // 24h window start
|
||||
require.NoError(t, persist(e, slotStart.Add(-12*time.Hour), 3, 0, nil)) // yesterday, within 24h
|
||||
require.NoError(t, persist(e, midnight, 1, 0, nil)) // first slot today
|
||||
require.NoError(t, persist(e, slotStart.Add(-15*time.Minute), 2, 0, nil)) // last completed slot
|
||||
require.NoError(t, persist(e, slotStart, 4, 0, nil)) // current slot, excluded
|
||||
|
||||
stats, err := col.EnergyStats()
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, 3, stats.Today, 1e-9)
|
||||
require.InDelta(t, 13, stats.Last24h, 1e-9)
|
||||
require.InDelta(t, 18, stats.Last7d, 1e-9)
|
||||
|
||||
// in-flight accumulator counts towards today only
|
||||
col.accu.Energy = 0.5
|
||||
stats, err = col.EnergyStats()
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, 3.5, stats.Today, 1e-9)
|
||||
require.InDelta(t, 13, stats.Last24h, 1e-9)
|
||||
|
||||
// next slot: cache refreshes, windows shift by one slot
|
||||
clock.Add(15 * time.Minute)
|
||||
stats, err = col.EnergyStats()
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, 7.5, stats.Today, 1e-9) // 1+2+4 + in-flight
|
||||
require.InDelta(t, 10, stats.Last24h, 1e-9) // drops 7, gains 4
|
||||
require.InDelta(t, 22, stats.Last7d, 1e-9) // gains 4, 8d slot stays out
|
||||
}
|
||||
|
|
@ -1368,12 +1368,15 @@
|
|||
"emission": "Emission",
|
||||
"fallbackName": "Ladepunkt",
|
||||
"finished": "Ladeende",
|
||||
"last24hEnergy": "Letzte 24h",
|
||||
"last7dEnergy": "Letzte 7 Tage",
|
||||
"power": "Leistung",
|
||||
"price": "Kosten",
|
||||
"remaining": "Restzeit",
|
||||
"remoteDisabledHard": "{source}: Deaktiviert",
|
||||
"remoteDisabledSoft": "{source}: Adaptives PV-Laden deaktiviert",
|
||||
"solar": "Sonne"
|
||||
"solar": "Sonne",
|
||||
"todayEnergy": "Heute"
|
||||
},
|
||||
"loadpointSettings": {
|
||||
"batteryBoost": {
|
||||
|
|
|
|||
|
|
@ -1368,12 +1368,15 @@
|
|||
"emission": "Emission",
|
||||
"fallbackName": "Charging point",
|
||||
"finished": "Finish time",
|
||||
"last24hEnergy": "Last 24h",
|
||||
"last7dEnergy": "Last 7 days",
|
||||
"power": "Power",
|
||||
"price": "Cost",
|
||||
"remaining": "Remaining",
|
||||
"remoteDisabledHard": "{source}: turned off",
|
||||
"remoteDisabledSoft": "{source}: turned off adaptive solar-charging",
|
||||
"solar": "Solar"
|
||||
"solar": "Solar",
|
||||
"todayEnergy": "Today"
|
||||
},
|
||||
"loadpointSettings": {
|
||||
"batteryBoost": {
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ test.describe("session info", async () => {
|
|||
await expect(page.getByTestId("sessionInfoLabel").first()).toContainText("Solar");
|
||||
// by click on value
|
||||
await page.getByTestId("sessionInfoValue").first().click();
|
||||
await expect(page.getByTestId("sessionInfoLabel").first()).toContainText("Duration");
|
||||
await expect(page.getByTestId("sessionInfoLabel").first()).toContainText("Last 24h");
|
||||
});
|
||||
test("keep selection on reload", async ({ page }) => {
|
||||
await expect(page.getByTestId("sessionInfoLabel").first()).toContainText("Duration");
|
||||
|
|
|
|||
|
|
@ -28,10 +28,11 @@ test.describe("demo mode", async () => {
|
|||
await expect(page.getByRole("heading", { name: "Demo Mode" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("two loadpoints", async ({ page }) => {
|
||||
await expect(page.getByTestId("loadpoint")).toHaveCount(2);
|
||||
test("three loadpoints", async ({ page }) => {
|
||||
await expect(page.getByTestId("loadpoint")).toHaveCount(3);
|
||||
await expect(page.getByTestId("loadpoint").nth(0)).toContainText("Carport");
|
||||
await expect(page.getByTestId("loadpoint").nth(1)).toContainText("Garage");
|
||||
await expect(page.getByTestId("loadpoint").nth(2)).toContainText("Heat pump");
|
||||
});
|
||||
|
||||
test("auth is locked", async ({ page }) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue