Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions assets/js/components/Config/BatteryIdentModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<template>
<GenericModal
id="batteryIdentModal"
ref="modal"
:title="$t('config.batteryident.title')"
data-testid="batteryident-modal"
config-modal-name="batteryident"
@open="open"
>
<p>{{ $t("config.batteryident.description") }}</p>
<p v-if="error" class="text-danger">{{ error }}</p>

<p v-if="!batteries.length" class="text-muted">{{ $t("config.batteryident.none") }}</p>
<div v-for="b in batteries" :key="b.name" class="mb-3" data-testid="batteryident-battery">
<h6 v-if="batteries.length > 1" class="mb-2">{{ b.title || b.name }}</h6>
<div class="d-flex justify-content-between">
<span class="evcc-gray">{{ $t("config.batteryident.configured") }}</span>
<span>{{ fmtKWh(b.configured) }}</span>
</div>
<div class="d-flex justify-content-between">
<span class="evcc-gray">{{ $t("config.batteryident.capacity") }}</span>
<span data-testid="batteryident-capacity">{{
b.capacity ? capacityText(b) : "–"
}}</span>
</div>
<div class="d-flex justify-content-between">
<span class="evcc-gray">{{ $t("config.batteryident.efficiency") }}</span>
<span data-testid="batteryident-efficiency">{{
b.efficiency ? fmtPercentage(b.efficiency * 100) : "–"
}}</span>
</div>
<p class="small evcc-gray mt-1 mb-0" data-testid="batteryident-status">
{{ statusText(b) }}
</p>
</div>

<form class="container mx-0 px-0" @submit.prevent="save">
<div class="form-check form-switch mt-3">
<input
id="batteryIdentUse"
v-model="use"
class="form-check-input"
type="checkbox"
role="switch"
data-testid="batteryident-use"
/>
<label class="form-check-label" for="batteryIdentUse">
{{ $t("config.batteryident.use") }}
</label>
</div>
<p class="small evcc-gray">{{ $t("config.batteryident.useHelp") }}</p>

<div class="mt-4 d-flex justify-content-between gap-2 flex-column flex-sm-row">
<button
type="button"
class="btn btn-link text-muted btn-cancel"
data-bs-dismiss="modal"
>
{{ $t("config.general.cancel") }}
</button>

<button
type="submit"
class="btn btn-primary order-1 order-sm-2 flex-grow-1 flex-sm-grow-0 px-4"
:disabled="saving || use === initialUse"
>
<span
v-if="saving"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
{{ $t("config.general.save") }}
</button>
</div>
</form>
</GenericModal>
</template>

<script>
import GenericModal from "../Helper/GenericModal.vue";
import formatter from "@/mixins/formatter";
import store from "@/store";
import api from "@/api";
// Custom extension: capacity and efficiency learned from the stored battery
// slots, see core/site_battery_ident.go. Needs 3 charging and 3 discharging
// runs over 20 % soc within 60 days.
const MIN_RUNS = 3;
export default {
name: "BatteryIdentModal",
components: { GenericModal },
mixins: [formatter],
emits: ["changed"],
data() {
return { saving: false, error: "", use: false, initialUse: false };
},
computed: {
state() {
return store.state?.batteryIdent;
},
batteries() {
return this.state?.batteries || [];
},
},
methods: {
open() {
this.saving = false;
this.error = "";
this.use = !!this.state?.use;
this.initialUse = this.use;
},
fmtKWh(v) {
return v ? `${this.fmtNumber(v, 1)} kWh` : "–";
},
capacityText(b) {
const share = b.configured
? ` (${this.fmtPercentage((b.capacity / b.configured) * 100)})`
: "";
return `${this.fmtKWh(b.capacity)}${share}`;
},
statusText(b) {
const runs = { charges: b.charges, discharges: b.discharges, min: MIN_RUNS };
if (b.valid) return this.$t("config.batteryident.valid", runs);
if (b.capacity) return this.$t("config.batteryident.implausible", runs);
return this.$t("config.batteryident.learning", runs);
},
async save() {
this.saving = true;
this.error = "";
try {
await api.post(`batteryidentuse/${this.use}`);
this.$emit("changed");
this.$refs.modal.close();
} catch (e) {
this.error = e?.response?.data?.error || e.message;
}
this.saving = false;
},
},
};
</script>
<style scoped>
.container {
margin-left: calc(var(--bs-gutter-x) * -0.5);
margin-right: calc(var(--bs-gutter-x) * -0.5);
padding-right: 0;
}
</style>
12 changes: 11 additions & 1 deletion assets/js/components/Config/PeakShavingConfig.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@
></template>
</DeviceCard>

<DeviceCard
:title="$t('config.batteryident.entryLabel')"
editable
data-testid="batteryident-entry"
@edit="openModal('batteryident')"
>
<template #icon><shopicon-regular-ruler></shopicon-regular-ruler></template>
</DeviceCard>

<DeviceCard
:title="$t('config.lmprofiles.entryLabel')"
editable
Expand Down Expand Up @@ -87,6 +96,7 @@ import "@h2d2/shopicons/es/regular/lightning";
import "@h2d2/shopicons/es/regular/cloudsun";
import "@h2d2/shopicons/es/regular/settings";
import "@h2d2/shopicons/es/regular/receivepayment";
import "@h2d2/shopicons/es/regular/ruler";
import { defineComponent } from "vue";
import store from "@/store";
import { openModal } from "@/configModal";
Expand All @@ -95,7 +105,7 @@ import CircuitsIcon from "../MaterialIcon/Circuits.vue";

// Tiles of the load management details, laid out like the services. The settings
// themselves are in PeakShavingCircuitModal, LmPrioritiesModal, LmShedGuardModal,
// GridChargeModal, PeakShavingModal, PeakTariffModal, LmProfilesModal and
// GridChargeModal, PeakShavingModal, PeakTariffModal, BatteryIdentModal, LmProfilesModal and
// LmAdvancedModal. The
// switches, the peak limit and the soc values live on the battery page.
export default defineComponent({
Expand Down
18 changes: 18 additions & 0 deletions assets/js/types/evcc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,24 @@ export interface State {
};
/** Capacity tariff with each month's cost with and without the battery, a zero price is off. */
peakTariff?: PeakTariff;
/** Battery capacity and efficiency learned from the stored slots. */
batteryIdent?: {
use: boolean;
updated: string;
batteries: {
name: string;
title: string;
/** kWh, datasheet */
configured: number;
/** kWh measured, 0 = not yet */
capacity: number;
/** round trip 0..1, 0 = not yet */
efficiency: number;
charges: number;
discharges: number;
valid: boolean;
}[];
};
/** Home Assistant number entity receiving the grid charge power, empty = on/off charging. */
peakShavingChargeEntity?: string;
/** Grid charge power in W currently written to that entity, 0 = not charging. */
Expand Down
3 changes: 3 additions & 0 deletions assets/js/views/Config.vue
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,7 @@
<LmShedGuardModal />
<LmAdvancedModal />
<PeakTariffModal />
<BatteryIdentModal />
<LmProfilesModal />
<FeedInEegModal />
<GridChargeModal />
Expand Down Expand Up @@ -657,6 +658,7 @@ import LmPrioritiesModal from "../components/Config/LmPrioritiesModal.vue";
import LmShedGuardModal from "../components/Config/LmShedGuardModal.vue";
import LmAdvancedModal from "../components/Config/LmAdvancedModal.vue";
import PeakTariffModal from "../components/Config/PeakTariffModal.vue";
import BatteryIdentModal from "../components/Config/BatteryIdentModal.vue";
import LmProfilesModal from "../components/Config/LmProfilesModal.vue";
import FeedInEegModal from "../components/Config/FeedInEegModal.vue";
import GridChargeModal from "../components/Config/GridChargeModal.vue";
Expand Down Expand Up @@ -782,6 +784,7 @@ export default defineComponent({
LmShedGuardModal,
LmAdvancedModal,
PeakTariffModal,
BatteryIdentModal,
LmProfilesModal,
FeedInEegModal,
GridChargeModal,
Expand Down
4 changes: 4 additions & 0 deletions core/keys/site_custom.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ const (
LmOff = "lmOff"
LmCircuit = "lmCircuit" // the load management (peak) circuit

// battery identification, see core/site_battery_ident.go
BatteryIdent = "batteryIdent" // published: measured capacity and efficiency
BatteryIdentUse = "batteryIdentUse" // the measured values are used

// load management overview: every load's state and the event log, see core/site_lm_status.go
LmStatus = "lmStatus"

Expand Down
19 changes: 18 additions & 1 deletion core/lm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ Keep these in mind when merging a new evcc version:

| File | Change |
| --- | --- |
| `core/site.go` | `lm` import, `LoadManagement`/`loadMgmt`/`peakShaving` fields, two restore calls, `batteryGridChargeRequested`, `updatePeakShaving`, `updateBatteryModePeakAware`, `setPeakGridEnergy` in `updateGridMeter` |
| `core/site.go` | `lm` import, `LoadManagement`/`loadMgmt`/`peakShaving` fields, two restore calls, `batteryGridChargeRequested`, `updatePeakShaving`, `updateBatteryIdent`, `updateBatteryModePeakAware`, `setPeakGridEnergy` in `updateGridMeter` |
| `core/circuit/circuit.go` | over power logged via `overPowerLog()` (INFO, no ui notification), see `circuit_custom.go` |
| `core/site_circuits.go` | `circuitLoads()` instead of `loadpointsAsCircuitDevices()` |
| `core/loadpoint.go` | `lm` import, `LmPrio` field (yaml fallback), `setLimit` checks against `lp.lmCircuit()` instead of `lp.circuit` (upstream calculation unchanged) and calls `done`, two `lm.Peek*` probes |
Expand Down Expand Up @@ -263,6 +263,23 @@ configured limits (or the raised one of follow the peak). Survives a restart
Circuit limits changed at runtime are published with their configured values
in `lmOff.limits`, so the overview shows them.

## Battery identification

Lastmanagement-Details → *Batterie-Vermessung*. The usable capacity and the
round trip efficiency are learned from the 15 minute slots evcc stores for each
battery (energy charged and discharged, soc at the slot start) over the last 60
days: a charging run over at least 20 % soc gives the energy put in per 100 %
(capacity / η), a discharging run the energy taken out (capacity × η). From the
medians of at least 3 runs each: capacity = √(kc × kd), round trip = kd / kc.
Runs with a soc jump, a gap or flow the other way are left out; an hour idle
ends a run. Plausible: 50-120 % of the configured capacity, 60-100 % round
trip. Refreshed every 6 hours, see `core/site_battery_ident.go`.

With *Gemessene Werte verwenden* on, the optimizer request gets the measured
capacity (all stored energies scaled, the soc values stay) and one-time grid
charging plans with the measured capacity and charging efficiency. The
optimizer's own efficiency is one value for batteries and vehicles and stays.

## Capacity tariff

Lastmanagement-Details → Leistungstarif. The month's highest quarter hour is
Expand Down
41 changes: 41 additions & 0 deletions core/metrics/slots_custom.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package metrics

// Custom extension: a meter's persisted slots, for the battery identification,
// see core/site_battery_ident.go.

import (
"time"

"github.com/evcc-io/evcc/db"
)

// MeterSlot is one persisted 15 minute slot of a meter
type MeterSlot struct {
Start time.Time
Energy float64 // kWh, import (battery: charged)
ReturnEnergy float64 // kWh, export (battery: discharged)
Soc *float64 // % at the start of the slot
}

// Slots returns the slots from the given time on, oldest first, without
// recovered downtime
func (c *Collector) Slots(from time.Time) ([]MeterSlot, error) {
var rows []meter
if err := db.Instance.
Where("meter = ? AND ts >= ? AND COALESCE(recovered, 0) = 0", c.entity.Id, from.Unix()).
Order("ts").Find(&rows).Error; err != nil {
return nil, err
}

res := make([]MeterSlot, 0, len(rows))
for _, r := range rows {
res = append(res, MeterSlot{
Start: time.Unix(r.Timestamp, 0),
Energy: r.Energy,
ReturnEnergy: r.ReturnEnergy,
Soc: r.SocTemp,
})
}

return res, nil
}
32 changes: 32 additions & 0 deletions core/metrics/slots_custom_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package metrics

import (
"testing"
"time"

"github.com/evcc-io/evcc/db"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestSlots(t *testing.T) {
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, SetupSchema())

c, err := NewCollector(Battery, "batt", "")
require.NoError(t, err)

start := time.Date(2026, 9, 1, 12, 0, 0, 0, time.Local)
soc := 50.0
require.NoError(t, persist(c.entity, start.Add(15*time.Minute), 0, 0.3, &soc, false))
require.NoError(t, persist(c.entity, start, 0.5, 0, &soc, false))
require.NoError(t, persist(c.entity, start.Add(30*time.Minute), 9, 0, nil, true)) // recovered

res, err := c.Slots(start.Add(-time.Hour))
require.NoError(t, err)
require.Len(t, res, 2)
assert.Equal(t, start, res[0].Start, "oldest first")
assert.Equal(t, 0.5, res[0].Energy)
assert.Equal(t, 0.3, res[1].ReturnEnergy)
assert.Equal(t, 50.0, *res[1].Soc)
}
2 changes: 2 additions & 0 deletions core/site.go
Original file line number Diff line number Diff line change
Expand Up @@ -1294,6 +1294,8 @@ func (site *Site) update(lp updater) {
site.updatePeakShaving(state)
// custom: export under a second feed-in tariff, see core/site_feedin_eeg.go
site.updateFeedInEeg()
// custom: battery capacity and efficiency, see core/site_battery_ident.go
site.updateBatteryIdent()
}

// smart grid charging
Expand Down
4 changes: 4 additions & 0 deletions core/site/api_custom.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ type CustomAPI interface {
GetLmCircuit() string
SetLmCircuit(string) error

// battery identification, see core/site_battery_ident.go
GetBatteryIdentUse() bool
SetBatteryIdentUse(bool) error

// advanced load management settings, see core/site_lm_advanced.go
SetLmAdvanced(name string, value float64) error

Expand Down
Loading
Loading