From fe237d1db7eb32221c9599bb67581307fa082462 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Sun, 27 Sep 2026 21:09:27 +0200 Subject: [PATCH] Battery: learn usable capacity and efficiency from the stored slots New tile Lastmanagement-Details -> Batterie-Vermessung. From the 15 minute slots evcc stores per battery (energy charged and discharged, soc at the slot start) of the last 60 days, charging and discharging runs over at least 20 % soc give the energy per 100 % soc each way; the medians of at least 3 runs each give capacity = sqrt(kc * kd) and round trip efficiency = kd / kc. Runs with a soc jump, a gap or flow the other way are left out, an hour idle ends a run. Refreshed every 6 hours, plausible at 50-120 % of the configured capacity and 60-100 % round trip. "Gemessene Werte verwenden" (off by default) puts the measured capacity into the optimizer request (stored energies scaled, soc values unchanged) and lets one-time grid charging plan with the measured capacity and charging efficiency. The optimizer's efficiency is shared with the vehicles and stays. Co-Authored-By: Claude Opus 5.5 --- .../components/Config/BatteryIdentModal.vue | 150 ++++++++ .../components/Config/PeakShavingConfig.vue | 12 +- assets/js/types/evcc.ts | 18 + assets/js/views/Config.vue | 3 + core/keys/site_custom.go | 4 + core/lm/README.md | 19 +- core/metrics/slots_custom.go | 41 +++ core/metrics/slots_custom_test.go | 32 ++ core/site.go | 2 + core/site/api_custom.go | 4 + core/site_battery_ident.go | 322 ++++++++++++++++++ core/site_battery_ident_test.go | 172 ++++++++++ core/site_lm.go | 6 + core/site_lm_once.go | 10 +- core/site_optimizer_lm.go | 3 +- i18n/de.json | 14 + i18n/en.json | 14 + server/http_custom.go | 3 + 18 files changed, 824 insertions(+), 5 deletions(-) create mode 100644 assets/js/components/Config/BatteryIdentModal.vue create mode 100644 core/metrics/slots_custom.go create mode 100644 core/metrics/slots_custom_test.go create mode 100644 core/site_battery_ident.go create mode 100644 core/site_battery_ident_test.go diff --git a/assets/js/components/Config/BatteryIdentModal.vue b/assets/js/components/Config/BatteryIdentModal.vue new file mode 100644 index 0000000000..ecb57194d7 --- /dev/null +++ b/assets/js/components/Config/BatteryIdentModal.vue @@ -0,0 +1,150 @@ + + + + diff --git a/assets/js/components/Config/PeakShavingConfig.vue b/assets/js/components/Config/PeakShavingConfig.vue index 9978078587..9dd49a438f 100644 --- a/assets/js/components/Config/PeakShavingConfig.vue +++ b/assets/js/components/Config/PeakShavingConfig.vue @@ -59,6 +59,15 @@ > + + + + + @@ -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"; @@ -782,6 +784,7 @@ export default defineComponent({ LmShedGuardModal, LmAdvancedModal, PeakTariffModal, + BatteryIdentModal, LmProfilesModal, FeedInEegModal, GridChargeModal, diff --git a/core/keys/site_custom.go b/core/keys/site_custom.go index d1803f133d..5a254fb1f1 100644 --- a/core/keys/site_custom.go +++ b/core/keys/site_custom.go @@ -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" diff --git a/core/lm/README.md b/core/lm/README.md index 09b8488d90..403d56c862 100644 --- a/core/lm/README.md +++ b/core/lm/README.md @@ -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 | @@ -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 diff --git a/core/metrics/slots_custom.go b/core/metrics/slots_custom.go new file mode 100644 index 0000000000..c0b0ff5984 --- /dev/null +++ b/core/metrics/slots_custom.go @@ -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 +} diff --git a/core/metrics/slots_custom_test.go b/core/metrics/slots_custom_test.go new file mode 100644 index 0000000000..e6f02e3c03 --- /dev/null +++ b/core/metrics/slots_custom_test.go @@ -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) +} diff --git a/core/site.go b/core/site.go index df82e6a34e..f24fe35e79 100644 --- a/core/site.go +++ b/core/site.go @@ -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 diff --git a/core/site/api_custom.go b/core/site/api_custom.go index 20e86d8a30..c12c319888 100644 --- a/core/site/api_custom.go +++ b/core/site/api_custom.go @@ -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 diff --git a/core/site_battery_ident.go b/core/site_battery_ident.go new file mode 100644 index 0000000000..93ff881f3e --- /dev/null +++ b/core/site_battery_ident.go @@ -0,0 +1,322 @@ +package core + +// Custom extension: battery identification. The optimizer and one-time grid +// charging plan with the battery's datasheet capacity and a fixed efficiency. +// Both are learned here 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 % +// soc, kc = capacity / η; a discharging run the energy taken out, kd = +// capacity × η (η: one way efficiency, the same both ways) +// - from the medians of both: capacity = √(kc × kd), round trip efficiency = +// kd / kc +// +// Runs with a soc jump (recalibration), a gap or noticeable flow the other way +// are left out. Shown under Lastmanagement-Details → Batterie-Vermessung; the +// optimizer and one-time grid charging only use the values when switched on +// there and plausible. The optimizer's efficiency is one value for all +// batteries and vehicles, so it stays; only the capacity goes into its request. + +import ( + "math" + "slices" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/core/metrics" + "github.com/evcc-io/evcc/db/settings" + optimizer "github.com/evcc-io/optimizer/client" +) + +const ( + identDays = 60 // days of history + identMinSpan = 20.0 // % soc a run must cover + identMaxOpposite = 0.05 // share of flow the other way a run may have + identMaxSocStep = 15.0 // % soc change within one slot counted as recalibration + identMinFlow = 0.01 // kWh per slot below which a slot is idle + identIdleSlots = 4 // idle slots that end a run, e.g. a full battery + identMinRuns = 3 // runs needed in each direction + identRefresh = 6 * time.Hour +) + +// batteryIdentResult is one battery's identification +type batteryIdentResult struct { + Name string `json:"name"` + Title string `json:"title"` + Configured float64 `json:"configured"` // kWh, datasheet + Capacity float64 `json:"capacity"` // kWh measured, 0 = not yet + Efficiency float64 `json:"efficiency"` // round trip 0..1, 0 = not yet + Charges int `json:"charges"` // charging runs found + Discharges int `json:"discharges"` // discharging runs found + Valid bool `json:"valid"` // enough runs and plausible +} + +// batteryIdentState is what the ui shows +type batteryIdentState struct { + Use bool `json:"use"` + Batteries []batteryIdentResult `json:"batteries"` + Updated time.Time `json:"updated"` +} + +// batteryRuns returns the energy per 100 % soc of every charging and +// discharging run in the slots, oldest first +func batteryRuns(slots []metrics.MeterSlot) (charge, discharge []float64) { + type run struct { + dir int // 1 charging, -1 discharging + startSoc float64 + main, opp float64 + idle int // idle slots in a row + } + + var cur *run + + finish := func(endSoc float64) { + if cur == nil { + return + } + span := (endSoc - cur.startSoc) * float64(cur.dir) + if span >= identMinSpan && cur.main > 0 && cur.opp <= identMaxOpposite*cur.main { + if v := cur.main / (span / 100); cur.dir > 0 { + charge = append(charge, v) + } else { + discharge = append(discharge, v) + } + } + cur = nil + } + + for i, s := range slots { + contiguous := i > 0 && s.Start.Sub(slots[i-1].Start) == 15*time.Minute + if !contiguous || s.Soc == nil || slots[i-1].Soc == nil || math.Abs(*s.Soc-*slots[i-1].Soc) > identMaxSocStep { + cur = nil // no usable end for a running run + } + if s.Soc == nil { + continue + } + + dir := 0 + switch { + case s.Energy > s.ReturnEnergy && s.Energy >= identMinFlow: + dir = 1 + case s.ReturnEnergy > s.Energy && s.ReturnEnergy >= identMinFlow: + dir = -1 + } + + // a run ends where the flow turns or stops for a while, its end soc is + // this slot's start soc + if cur != nil && dir != 0 && dir != cur.dir { + finish(*s.Soc) + } + if cur != nil && dir == 0 { + if cur.idle++; cur.idle >= identIdleSlots { + finish(*s.Soc) + } + continue + } + if cur != nil { + cur.idle = 0 + } + + if cur == nil { + if dir == 0 { + continue + } + cur = &run{dir: dir, startSoc: *s.Soc} + } + + if cur.dir > 0 { + cur.main += s.Energy + cur.opp += s.ReturnEnergy + } else { + cur.main += s.ReturnEnergy + cur.opp += s.Energy + } + } + + return charge, discharge +} + +func median(v []float64) float64 { + s := slices.Clone(v) + slices.Sort(s) + n := len(s) + if n%2 == 1 { + return s[n/2] + } + return (s[n/2-1] + s[n/2]) / 2 +} + +// identifyBattery estimates capacity and efficiency from a battery's slots +func identifyBattery(slots []metrics.MeterSlot, configured float64) batteryIdentResult { + charge, discharge := batteryRuns(slots) + res := batteryIdentResult{Configured: configured, Charges: len(charge), Discharges: len(discharge)} + + if len(charge) < identMinRuns || len(discharge) < identMinRuns { + return res + } + + kc, kd := median(charge), median(discharge) + res.Capacity = math.Round(math.Sqrt(kc*kd)*100) / 100 + res.Efficiency = math.Round(kd/kc*1000) / 1000 + + ratio := res.Capacity / configured + res.Valid = configured > 0 && ratio >= 0.5 && ratio <= 1.2 && res.Efficiency >= 0.6 && res.Efficiency <= 1 + + return res +} + +// restoreBatteryIdent restores the setting and runs a first identification +func (site *Site) restoreBatteryIdent() { + if v, err := settings.Bool(keys.BatteryIdentUse); err == nil { + s := site.lms() + s.mu.Lock() + s.identUse = v + s.mu.Unlock() + } +} + +// updateBatteryIdent identifies the batteries, every few hours +func (site *Site) updateBatteryIdent() { + s := site.lms() + + s.mu.Lock() + due := time.Since(s.identAt) >= identRefresh + if due { + s.identAt = time.Now() + } + s.mu.Unlock() + + if !due { + return + } + + var res []batteryIdentResult + for _, dev := range site.batteryMeters { + ref := dev.Config().Name + + c, ok := site.collectors[ref] + if !ok { + continue + } + + var configured float64 + if m, ok := api.Cap[api.BatteryCapacity](dev.Instance()); ok { + configured = m.Capacity() + } + + slots, err := c.Slots(time.Now().AddDate(0, 0, -identDays)) + if err != nil { + site.log.ERROR.Printf("battery identification %s: %v", ref, err) + continue + } + + r := identifyBattery(slots, configured) + r.Name, r.Title = ref, deviceProperties(dev).Title + if r.Valid { + site.log.DEBUG.Printf("battery identification %s: %.2f kWh (configured %.2f kWh), round trip %.0f%%", ref, r.Capacity, configured, r.Efficiency*100) + } + res = append(res, r) + } + + s.mu.Lock() + s.ident = res + s.mu.Unlock() + + site.publishBatteryIdent() +} + +func (site *Site) publishBatteryIdent() { + s := site.lms() + + s.mu.Lock() + res := batteryIdentState{Use: s.identUse, Batteries: slices.Clone(s.ident), Updated: s.identAt} + s.mu.Unlock() + + if res.Batteries == nil { + res.Batteries = make([]batteryIdentResult, 0) + } + + site.publish(keys.BatteryIdent, res) +} + +// batteryIdentFor returns a battery's identification when it is used +func (site *Site) batteryIdentFor(name string) (batteryIdentResult, bool) { + s := site.lms() + + s.mu.Lock() + defer s.mu.Unlock() + + if !s.identUse { + return batteryIdentResult{}, false + } + for _, r := range s.ident { + if r.Name == name && r.Valid { + return r, true + } + } + return batteryIdentResult{}, false +} + +// applyBatteryIdent puts the measured capacity into a battery's optimizer +// request: every stored energy scales with it, so the soc values stay +func (site *Site) applyBatteryIdent(bat *optimizer.BatteryConfig, detail *batteryDetail) { + r, ok := site.batteryIdentFor(detail.Name) + if !ok || bat.SCapacity <= 0 { + return + } + + f := float32(r.Capacity*1e3) / bat.SCapacity + bat.SCapacity *= f + bat.SInitial *= f + bat.SMin *= f + bat.SMax *= f + for i := range bat.SGoal { + bat.SGoal[i] *= f + } + detail.Capacity = r.Capacity +} + +// identChargeEta is the charging efficiency: measured where every battery has +// one in use (capacity weighted), else the one the optimizer assumes +func (site *Site) identChargeEta() float64 { + var sum, weight float64 + for _, dev := range site.batteryMeters { + r, ok := site.batteryIdentFor(dev.Config().Name) + if !ok { + return eta + } + sum += math.Sqrt(r.Efficiency) * r.Capacity + weight += r.Capacity + } + if weight <= 0 { + return eta + } + return sum / weight +} + +func (site *Site) GetBatteryIdentUse() bool { + s := site.lms() + + s.mu.Lock() + defer s.mu.Unlock() + + return s.identUse +} + +// SetBatteryIdentUse switches using the measured values +func (site *Site) SetBatteryIdentUse(use bool) error { + s := site.lms() + + s.mu.Lock() + s.identUse = use + s.mu.Unlock() + + site.log.DEBUG.Println("set battery identification use:", use) + settings.SetBool(keys.BatteryIdentUse, use) + + site.publishBatteryIdent() + site.Optimize() // custom: the optimizer inputs changed, see core/site_optimizer_lm.go + + return nil +} diff --git a/core/site_battery_ident_test.go b/core/site_battery_ident_test.go new file mode 100644 index 0000000000..9a96292b4f --- /dev/null +++ b/core/site_battery_ident_test.go @@ -0,0 +1,172 @@ +package core + +import ( + "math" + "testing" + "time" + + "github.com/evcc-io/evcc/core/metrics" + "github.com/evcc-io/evcc/util" + optimizer "github.com/evcc-io/optimizer/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// simBattery produces slots of a battery with the given capacity and one way +// efficiency: each day charging from 20 to 90 % and discharging back, soc +// rounded to whole percent like most batteries report it +type simBattery struct { + capacity, eta float64 // kWh, one way + soc float64 + at time.Time + slots []metrics.MeterSlot +} + +func (b *simBattery) slot(charge, discharge float64) { + soc := math.Round(b.soc) + b.slots = append(b.slots, metrics.MeterSlot{Start: b.at, Energy: charge, ReturnEnergy: discharge, Soc: &soc}) + b.soc += (charge*b.eta - discharge/b.eta) / b.capacity * 100 + b.at = b.at.Add(15 * time.Minute) +} + +func (b *simBattery) days(n int) { + for range n { + for b.soc < 90 { + b.slot(0.5, 0) + } + for range 8 { + b.slot(0, 0) + } + for b.soc > 20 { + b.slot(0, 0.4) + } + for range 8 { + b.slot(0, 0) + } + } +} + +func newSimBattery() *simBattery { + return &simBattery{capacity: 10, eta: 0.95, soc: 20, at: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)} +} + +func TestIdentifyBattery(t *testing.T) { + b := newSimBattery() + b.days(5) + + res := identifyBattery(b.slots, 11) + assert.Equal(t, 5, res.Charges) + assert.Equal(t, 5, res.Discharges) + assert.True(t, res.Valid) + assert.InDelta(t, 10, res.Capacity, 0.3, "capacity") + assert.InDelta(t, 0.95*0.95, res.Efficiency, 0.03, "round trip") +} + +func TestIdentifyBatteryNeedsRuns(t *testing.T) { + b := newSimBattery() + b.days(2) + + res := identifyBattery(b.slots, 10) + assert.False(t, res.Valid, "two runs each are not enough") + assert.Zero(t, res.Capacity) +} + +func TestIdentifyBatteryImplausible(t *testing.T) { + b := newSimBattery() + b.days(4) + + res := identifyBattery(b.slots, 5) // measured twice the datasheet value + assert.False(t, res.Valid) + assert.NotZero(t, res.Capacity, "still shown") +} + +func TestBatteryRunsSkipsDisturbed(t *testing.T) { + // a recalibration jump in the middle of the charging run + b := newSimBattery() + b.slot(0.5, 0) + b.slot(0.5, 0) + b.soc += 20 + for b.soc < 90 { + b.slot(0.5, 0) + } + b.slot(0, 0.4) + charge, _ := batteryRuns(b.slots) + require.Len(t, charge, 1, "jump: only the run after it") + assert.InDelta(t, 10/0.95, charge[0], 0.4) + + // a gap of an hour + b = newSimBattery() + for i := 0; b.soc < 90; i++ { + if i == 5 { + b.at = b.at.Add(time.Hour) + } + b.slot(0.5, 0) + } + b.slot(0, 0.4) + charge, _ = batteryRuns(b.slots) + require.Len(t, charge, 1, "gap: only the run after it") + assert.InDelta(t, 10/0.95, charge[0], 0.4) + + // noticeable flow the other way within the run + b = newSimBattery() + for b.soc < 90 { + b.slot(0.5, 0.05) + } + b.slot(0, 0.4) + charge, _ = batteryRuns(b.slots) + assert.Empty(t, charge, "flow both ways") + + // too small a run + b = newSimBattery() + for b.soc < 35 { + b.slot(0.5, 0) + } + b.slot(0, 0.4) + charge, _ = batteryRuns(b.slots) + assert.Empty(t, charge, "15 % soc") +} + +// A run followed by idle slots ends there, e.g. a full battery. +func TestBatteryRunsEndIdle(t *testing.T) { + b := newSimBattery() + for b.soc < 90 { + b.slot(0.5, 0) + } + for range 4 { + b.slot(0, 0) + } + charge, discharge := batteryRuns(b.slots) + assert.Len(t, charge, 1) + assert.Empty(t, discharge) +} + +func TestApplyBatteryIdent(t *testing.T) { + site := &Site{log: util.NewLogger("test")} + s := site.lms() + s.ident = []batteryIdentResult{{Name: "batt", Capacity: 9, Efficiency: 0.9, Valid: true}} + + bat := optimizer.BatteryConfig{SCapacity: 10000, SInitial: 5000, SMin: 1000, SMax: 10000} + detail := batteryDetail{Name: "batt", Capacity: 10} + + site.applyBatteryIdent(&bat, &detail) + assert.Equal(t, float32(10000), bat.SCapacity, "not used unless switched on") + + require.NoError(t, site.SetBatteryIdentUse(true)) + site.applyBatteryIdent(&bat, &detail) + assert.InDelta(t, 9000, bat.SCapacity, 0.1) + assert.InDelta(t, 4500, bat.SInitial, 0.1, "soc stays 50 %") + assert.InDelta(t, 900, bat.SMin, 0.1) + assert.InDelta(t, 9000, bat.SMax, 0.1) + assert.Equal(t, 9.0, detail.Capacity) + + other := optimizer.BatteryConfig{SCapacity: 5000} + site.applyBatteryIdent(&other, &batteryDetail{Name: "other"}) + assert.Equal(t, float32(5000), other.SCapacity, "not identified") + + s.ident[0].Valid = false + bat = optimizer.BatteryConfig{SCapacity: 10000} + site.applyBatteryIdent(&bat, &detail) + assert.Equal(t, float32(10000), bat.SCapacity, "implausible values are not used") + + require.NoError(t, site.SetBatteryIdentUse(false)) +} diff --git a/core/site_lm.go b/core/site_lm.go index 4eaba69f22..b8a994f2d8 100644 --- a/core/site_lm.go +++ b/core/site_lm.go @@ -68,6 +68,11 @@ type lmState struct { batteryCircuit api.Circuit // resolved from the assignment batteryCircuitRef string // what batteryCircuit was resolved from batteryLoad *batteryLoad + + // battery identification, see site_battery_ident.go + identUse bool + ident []batteryIdentResult + identAt time.Time } // lms returns the load management state, applying defaults on first use @@ -118,6 +123,7 @@ func (site *Site) restoreLmSettings() { site.restoreLmGuard() site.restoreLmAdvanced() site.restoreLmSwitch() + site.restoreBatteryIdent() site.publishLmProfiles() site.publishLmSettings() diff --git a/core/site_lm_once.go b/core/site_lm_once.go index 05c2ac452c..9d53bc2d52 100644 --- a/core/site_lm_once.go +++ b/core/site_lm_once.go @@ -163,7 +163,7 @@ func (site *Site) onceRequiredDuration(target, soc float64) time.Duration { return 0 } - hours := (target - soc) / 100 * capacity * 1e3 / (power * eta) + hours := (target - soc) / 100 * capacity * 1e3 / (power * site.identChargeEta()) return time.Duration(hours * float64(time.Hour)) } @@ -193,10 +193,16 @@ func (site *Site) onceSlotActive(o gridChargeOnce, soc float64) bool { return false } -// batteryCapacityKWh returns the capacity of all batteries, 0 if one is unknown +// batteryCapacityKWh returns the capacity of all batteries, measured where used +// (see site_battery_ident.go), 0 if one is unknown func (site *Site) batteryCapacityKWh() float64 { var res float64 for _, dev := range site.batteryMeters { + if r, ok := site.batteryIdentFor(dev.Config().Name); ok { + res += r.Capacity + continue + } + m, ok := api.Cap[api.BatteryCapacity](dev.Instance()) if !ok || m.Capacity() <= 0 { return 0 diff --git a/core/site_optimizer_lm.go b/core/site_optimizer_lm.go index f97318ad52..4579dec028 100644 --- a/core/site_optimizer_lm.go +++ b/core/site_optimizer_lm.go @@ -91,6 +91,7 @@ func (site *Site) applyLmOptimizerInputs(req *optimizer.OptimizationInput, batte switch b.detail.Type { case batteryTypeBattery: + site.applyBatteryIdent(&b.cfg, &b.detail) site.applyLmBatteryInputs(&b.cfg, req.TimeSeries.Dt, peakOn, reserve) if lmActive && site.lmBatteryCircuit() != nil { @@ -172,7 +173,7 @@ func (site *Site) applyLmBatteryInputs(bat *optimizer.BatteryConfig, dt []int, p d = site.onceRequiredDuration(o.Target, float64(bat.SInitial/bat.SCapacity*100)) // unknown grid charge power: the battery's maximum charge power if d <= 0 && bat.CMax > 0 { - d = time.Duration(float64(goal-bat.SInitial) / (float64(bat.CMax) * eta) * float64(time.Hour)) + d = time.Duration(float64(goal-bat.SInitial) / (float64(bat.CMax) * site.identChargeEta()) * float64(time.Hour)) } } if len(bat.SGoal) != len(dt) { diff --git a/i18n/de.json b/i18n/de.json index a9b28c2662..db65f32a7b 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1274,6 +1274,20 @@ "template": "Hersteller", "titleAdd": "Fahrzeug hinzufügen", "titleEdit": "Fahrzeug bearbeiten" + }, + "batteryident": { + "entryLabel": "Batterie-Vermessung", + "title": "Batterie-Vermessung", + "description": "Nutzbare Kapazität und Wirkungsgrad, gelernt aus den gespeicherten Lade- und Entladevorgängen der letzten 60 Tage (je mindestens 20 % Ladestand am Stück).", + "none": "Keine Hausbatterie konfiguriert.", + "configured": "Laut Konfiguration", + "capacity": "Gemessen", + "efficiency": "Wirkungsgrad (laden und entladen)", + "valid": "Aus {charges} Lade- und {discharges} Entladevorgängen.", + "implausible": "Nicht plausibel, wird nicht verwendet ({charges} Lade-, {discharges} Entladevorgänge). Kapazität in der Batterie-Konfiguration prüfen.", + "learning": "Lernt noch: {charges} Lade- und {discharges} Entladevorgänge, je {min} nötig.", + "use": "Gemessene Werte verwenden", + "useHelp": "Optimizer und Einmal-Netzladen planen dann mit der gemessenen Kapazität, Einmal-Netzladen auch mit dem gemessenen Wirkungsgrad. Nur solange die Werte plausibel sind." } }, "footer": { diff --git a/i18n/en.json b/i18n/en.json index 27b6335a71..31b0a3af00 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1274,6 +1274,20 @@ "template": "Manufacturer", "titleAdd": "Add Vehicle", "titleEdit": "Edit Vehicle" + }, + "batteryident": { + "entryLabel": "Battery identification", + "title": "Battery identification", + "description": "Usable capacity and efficiency, learned from the stored charging and discharging runs of the last 60 days (each over at least 20 % soc).", + "none": "No home battery configured.", + "configured": "Configured", + "capacity": "Measured", + "efficiency": "Efficiency (charge and discharge)", + "valid": "From {charges} charging and {discharges} discharging runs.", + "implausible": "Not plausible, not used ({charges} charging, {discharges} discharging runs). Check the capacity in the battery configuration.", + "learning": "Still learning: {charges} charging and {discharges} discharging runs, {min} each needed.", + "use": "Use measured values", + "useHelp": "The optimizer and one-time grid charging then plan with the measured capacity, one-time grid charging also with the measured efficiency. Only while the values are plausible." } }, "footer": { diff --git a/server/http_custom.go b/server/http_custom.go index 7626b2c110..1c194a3147 100644 --- a/server/http_custom.go +++ b/server/http_custom.go @@ -47,6 +47,9 @@ func customSiteRoutes(site site.API) map[string]route { "lmcircuit": {"POST", "/lmcircuit/{value:" + namePattern + "}", stringHandler(site.SetLmCircuit, site.GetLmCircuit)}, "lmcircuitdelete": {"DELETE", "/lmcircuit", stringHandler(site.SetLmCircuit, site.GetLmCircuit)}, + // battery identification, see core/site_battery_ident.go + "batteryidentuse": {"POST", "/batteryidentuse/{value:[01truefalse]+}", boolHandler(site.SetBatteryIdentUse, site.GetBatteryIdentUse)}, + // advanced load management settings, see core/site_lm_advanced.go "lmadvanced": {"POST", "/lmadvanced/{name:[a-zA-Z]+}/{value:[0-9.]+}", lmAdvancedHandler(site)},