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)},