Add MaxACPower hybrid battery setting (#16609)

This commit is contained in:
andig 2024-10-13 19:20:40 +02:00 • committed by GitHub
parent ccade7c1f0
commit 3a88cb4add
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1472 additions and 171 deletions

View file

@ -44,6 +44,11 @@ type BatteryCapacity interface {
Capacity() float64
}
// BatteryMaxACPower provides max AC power in W
type BatteryMaxACPower interface {
MaxACPower() float64
}
// ChargeState provides current charging status
type ChargeState interface {
Status() (ChargeStatus, error)

View file

@ -46,6 +46,7 @@ var a struct {
api.Battery
api.BatteryCapacity
api.BatteryMaxACPower
api.BatteryController
}
@ -57,7 +58,7 @@ var dependents = map[string][]string{
typ(&a.Meter): {typ(&a.MeterEnergy), typ(&a.PhaseCurrents), typ(&a.PhaseVoltages), typ(&a.PhasePowers)},
typ(&a.PhaseCurrents): {typ(&a.PhasePowers)}, // phase powers are only used to determine currents sign
typ(&a.PhaseSwitcher): {typ(&a.PhaseGetter)},
typ(&a.Battery): {typ(&a.BatteryCapacity), typ(&a.BatteryController)},
typ(&a.Battery): {typ(&a.BatteryCapacity), typ(&a.BatteryMaxACPower), typ(&a.BatteryController)},
}
// hasIntersection returns if the slices intersect

View file

@ -5,7 +5,6 @@ import (
"time"
"github.com/cenkalti/backoff/v4"
"github.com/evcc-io/evcc/util"
)
var (
@ -37,20 +36,6 @@ func currentToPower(current float64, phases int) float64 {
return current * float64(phases) * Voltage
}
// sitePower returns the available delta power that the charger might additionally consume
// negative value: available power (grid export), positive value: grid import
func sitePower(log *util.Logger, maxGrid, grid, battery, residual float64) float64 {
// For hybrid inverters, battery can be charged from DC power in excess of
// inverter AC rating. This battery charge must not be counted as available for AC consumption.
// https://github.com/evcc-io/evcc/issues/2734, https://github.com/evcc-io/evcc/issues/2986
if maxGrid > 0 && grid > maxGrid && battery < 0 {
log.TRACE.Printf("ignoring excess DC charging due to grid consumption: %.0fW > %.0fW", grid, maxGrid)
battery = 0
}
return grid + battery + residual
}
// printPtr returns a string representation of a pointer value
func printPtr[T any](format string, v *T) string {
if v == nil {

View file

@ -39,13 +39,12 @@ const (
AuxMeters = "auxMeters"
// battery settings
BatteryCapacity = "batteryCapacity"
BatteryDischargeControl = "batteryDischargeControl"
BatteryGridChargeLimit = "batteryGridChargeLimit"
BatteryGridChargeActive = "batteryGridChargeActive"
BufferSoc = "bufferSoc"
BufferStartSoc = "bufferStartSoc"
MaxGridSupplyWhileBatteryCharging = "maxGridSupplyWhileBatteryCharging"
BatteryCapacity = "batteryCapacity"
BatteryDischargeControl = "batteryDischargeControl"
BatteryGridChargeLimit = "batteryGridChargeLimit"
BatteryGridChargeActive = "batteryGridChargeActive"
BufferSoc = "bufferSoc"
BufferStartSoc = "bufferStartSoc"
// battery status
Battery = "battery"

View file

@ -49,11 +49,12 @@ type meterMeasurement struct {
// batteryMeasurement is used as slice element for publishing structured data
type batteryMeasurement struct {
Power float64 `json:"power"`
Energy float64 `json:"energy,omitempty"`
Soc float64 `json:"soc,omitempty"`
Capacity float64 `json:"capacity,omitempty"`
Controllable bool `json:"controllable"`
Power float64 `json:"power"`
ExcessDCPower float64 `json:"excessdcpower,omitempty"`
Energy float64 `json:"energy,omitempty"`
Soc float64 `json:"soc,omitempty"`
Capacity float64 `json:"capacity,omitempty"`
Controllable bool `json:"controllable"`
}
var _ site.API = (*Site)(nil)
@ -74,9 +75,8 @@ type Site struct {
ResidualPower float64 `mapstructure:"residualPower"` // PV meter only: household usage. Grid meter: household safety margin
Meters MetersConfig `mapstructure:"meters"` // Meter references
// TODO deprecated
CircuitRef_ string `mapstructure:"circuit"` // Circuit reference
MaxGridSupplyWhileBatteryCharging float64 `mapstructure:"maxGridSupplyWhileBatteryCharging"` // ignore battery charging if AC consumption is above this value
CircuitRef_ string `mapstructure:"circuit"` // Circuit reference
MaxGridSupplyWhileBatteryCharging_ float64 `mapstructure:"maxGridSupplyWhileBatteryCharging"` // ignore battery charging if AC consumption is above this value
// meters
circuit api.Circuit // Circuit
@ -100,12 +100,13 @@ type Site struct {
stats *Stats // Stats
// cached state
gridPower float64 // Grid power
pvPower float64 // PV power
auxPower float64 // Aux power
batteryPower float64 // Battery charge power
batterySoc float64 // Battery soc
batteryMode api.BatteryMode // Battery mode (runtime only, not persisted)
gridPower float64 // Grid power
pvPower float64 // PV power
auxPower float64 // Aux power
batteryPower float64 // Battery charge power
batteryExcessDC float64 // Battery excess DC charge power (hybrid only)
batterySoc float64 // Battery soc
batteryMode api.BatteryMode // Battery mode (runtime only, not persisted)
publishCache map[string]any // store last published values to avoid unnecessary republishing
}
@ -213,7 +214,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
site.log.WARN.Println("battery configured but residualPower is missing or <= 0 (add residualPower: 100 to site), see https://docs.evcc.io/en/docs/reference/configuration/site#residualpower")
}
// Meters used only for monitoring
// meters used only for monitoring
for _, ref := range site.Meters.ExtMetersRef {
dev, err := config.Meters().ByName(ref)
if err != nil {
@ -231,6 +232,10 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
site.auxMeters = append(site.auxMeters, dev.Instance())
}
if site.MaxGridSupplyWhileBatteryCharging_ != 0 {
site.log.WARN.Println("`MaxGridSupplyWhileBatteryCharging` is deprecated- use `maxACPower` in battery configuration instead")
}
// revert battery mode on shutdown
shutdown.Register(func() {
if mode := site.GetBatteryMode(); batteryModeModified(mode) {
@ -295,11 +300,6 @@ func (site *Site) restoreSettings() error {
}
}
// TODO migrate from YAML
if v, err := settings.Float(keys.MaxGridSupplyWhileBatteryCharging); err == nil {
if err := site.SetMaxGridSupplyWhileBatteryCharging(v); err != nil {
return err
}
}
if v, err := settings.Float(keys.PrioritySoc); err == nil {
if err := site.SetPrioritySoc(v); err != nil {
return err
@ -567,6 +567,7 @@ func (site *Site) updateBatteryMeters() error {
var mu sync.Mutex
site.batteryPower = 0
site.batteryExcessDC = 0
site.batterySoc = 0
mm := make([]batteryMeasurement, len(site.batteryMeters))
@ -582,8 +583,21 @@ func (site *Site) updateBatteryMeters() error {
site.batteryPower += power
mu.Unlock()
var excessDC float64
var excessStr string
if m, ok := meter.(api.BatteryMaxACPower); ok {
if dc := power + m.MaxACPower(); dc < 0 {
mu.Lock()
site.batteryExcessDC += dc
mu.Unlock()
excessDC = dc
excessStr = fmt.Sprintf(" (includes %.0fW excess DC)", dc)
}
}
if len(site.batteryMeters) > 1 {
site.log.DEBUG.Printf("battery %d power: %.0fW", i+1, power)
site.log.DEBUG.Printf("battery %d power: %.0fW"+excessStr, i+1, power)
}
// battery energy (discharge)
@ -629,11 +643,12 @@ func (site *Site) updateBatteryMeters() error {
_, controllable := meter.(api.BatteryController)
mm[i] = batteryMeasurement{
Power: power,
Energy: energy,
Soc: batSoc,
Capacity: capacity,
Controllable: controllable,
Power: power,
ExcessDCPower: excessDC,
Energy: energy,
Soc: batSoc,
Capacity: capacity,
Controllable: controllable,
}
return nil
@ -674,10 +689,10 @@ func (site *Site) updateGridMeter() error {
if res, err := backoff.RetryWithData(site.gridMeter.CurrentPower, bo()); err == nil {
site.gridPower = res
site.log.DEBUG.Printf("grid meter: %.0fW", res)
site.log.DEBUG.Printf("grid power: %.0fW", res)
site.publish(keys.GridPower, res)
} else {
return fmt.Errorf("grid meter: %v", err)
return fmt.Errorf("grid power: %v", err)
}
// grid phase currents (signed)
@ -756,6 +771,7 @@ func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, b
// honour battery priority
batteryPower := site.batteryPower
batteryExcessDC := site.batteryExcessDC
// handed to loadpoint
var batteryBuffered, batteryStart bool
@ -768,6 +784,7 @@ func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, b
if site.batterySoc < site.prioritySoc && batteryPower < 0 {
site.log.DEBUG.Printf("battery has priority at soc %.0f%% (< %.0f%%)", site.batterySoc, site.prioritySoc)
batteryPower = 0
batteryExcessDC = 0
} else {
// if battery is above bufferSoc allow using it for charging
batteryBuffered = site.bufferSoc > 0 && site.batterySoc > site.bufferSoc
@ -775,18 +792,15 @@ func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, b
}
}
sitePower := sitePower(site.log, site.GetMaxGridSupplyWhileBatteryCharging(), site.gridPower, batteryPower, site.GetResidualPower())
// deduct smart loads
sitePower -= site.auxPower
sitePower := site.gridPower + batteryPower - batteryExcessDC + site.GetResidualPower() - site.auxPower - flexiblePower
// handle priority
var flexStr string
if flexiblePower > 0 {
site.log.DEBUG.Printf("giving loadpoint priority for additional: %.0fW", flexiblePower)
sitePower -= flexiblePower
flexStr = fmt.Sprintf(" (including %.0fW prioritized power)", flexiblePower)
}
site.log.DEBUG.Printf("site power: %.0fW", sitePower)
site.log.DEBUG.Printf("site power: %.0fW"+flexStr, sitePower)
return sitePower, batteryBuffered, batteryStart, nil
}
@ -975,7 +989,6 @@ func (site *Site) prepare() {
site.publish(keys.PrioritySoc, site.prioritySoc)
site.publish(keys.BufferSoc, site.bufferSoc)
site.publish(keys.BufferStartSoc, site.bufferStartSoc)
site.publish(keys.MaxGridSupplyWhileBatteryCharging, site.MaxGridSupplyWhileBatteryCharging)
site.publish(keys.BatteryMode, site.batteryMode)
site.publish(keys.BatteryDischargeControl, site.batteryDischargeControl)
site.publish(keys.ResidualPower, site.GetResidualPower())

View file

@ -37,8 +37,6 @@ type API interface {
SetBufferSoc(float64) error
GetBufferStartSoc() float64
SetBufferStartSoc(float64) error
GetMaxGridSupplyWhileBatteryCharging() float64
SetMaxGridSupplyWhileBatteryCharging(float64) error
// GetBatteryGridChargeLimit get the grid charge limit
GetBatteryGridChargeLimit() *float64

View file

@ -248,31 +248,6 @@ func (site *Site) SetBufferStartSoc(soc float64) error {
return nil
}
func (site *Site) GetMaxGridSupplyWhileBatteryCharging() float64 {
site.RLock()
defer site.RUnlock()
return site.MaxGridSupplyWhileBatteryCharging
}
func (site *Site) SetMaxGridSupplyWhileBatteryCharging(power float64) error {
site.Lock()
defer site.Unlock()
if len(site.batteryMeters) == 0 {
return ErrBatteryNotConfigured
}
site.log.DEBUG.Println("set max grid supply while battery charging:", power)
if site.MaxGridSupplyWhileBatteryCharging != power {
site.MaxGridSupplyWhileBatteryCharging = power
settings.SetFloat(keys.MaxGridSupplyWhileBatteryCharging, site.MaxGridSupplyWhileBatteryCharging)
site.publish(keys.MaxGridSupplyWhileBatteryCharging, site.MaxGridSupplyWhileBatteryCharging)
}
return nil
}
// GetResidualPower returns the ResidualPower
func (site *Site) GetResidualPower() float64 {
site.RLock()

View file

@ -4,35 +4,9 @@ import (
"testing"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/assert"
)
func TestSitePower(t *testing.T) {
tc := []struct {
maxGrid, grid, battery, site float64
}{
{0, 0, 0, 0}, // silent night
{0, 0, 1, 1}, // battery discharging
{0, 0, -1, -1}, // battery charging -> negative result cannot occur in reality
{0, 1, 0, 1}, // grid import
{0, 1, 1, 2}, // grid import + battery discharging
{0, -1, 0, -1}, // grid export
{0, -1, -1, -2}, // grid export + battery charging
{0, 1, -1, 0}, // grid import + battery charging -> should not happen
{0.5, 1, -1, 1}, // grid import + DC battery charging
}
log := util.NewLogger("foo")
for _, tc := range tc {
res := sitePower(log, tc.maxGrid, tc.grid, tc.battery, 0)
if res != tc.site {
t.Errorf("sitePower wanted %.f, got %.f", tc.site, res)
}
}
}
func TestGreenShare(t *testing.T) {
tc := []struct {
title string

View file

@ -108,7 +108,6 @@ site:
aux:
- aux # list of auxiliary meters for adjusting grid operating point
residualPower: 0 # additional household usage margin
maxGridSupplyWhileBatteryCharging: 0 # ignore battery charging if AC consumption is above this value
# loadpoint describes the charger, charge meter and connected vehicle
loadpoints:

View file

@ -6,7 +6,7 @@ type capacity struct {
// var _ api.BatteryCapacity = (*capacity)(nil)
// Decorator returns the capacity decorator if capacity is specified or nil otherwise
// Decorator returns the capacity decorator
func (m *capacity) Decorator() func() float64 {
if m.Capacity == 0 {
return nil

15
meter/battery_power.go Normal file
View file

@ -0,0 +1,15 @@
package meter
type maxpower struct {
MaxACPower float64
}
// MaxACPowerDecorator returns the max AC power decorator
func (m *maxpower) Decorator() func() float64 {
if m.MaxACPower == 0 {
return nil
}
return func() float64 {
return m.MaxACPower
}
}

View file

@ -13,7 +13,7 @@ func init() {
registry.AddCtx(api.Custom, NewConfigurableFromConfig)
}
//go:generate go run ../cmd/tools/decorate.go -f decorateMeter -b api.Meter -t "api.MeterEnergy,TotalEnergy,func() (float64, error)" -t "api.PhaseCurrents,Currents,func() (float64, float64, float64, error)" -t "api.PhaseVoltages,Voltages,func() (float64, float64, float64, error)" -t "api.PhasePowers,Powers,func() (float64, float64, float64, error)" -t "api.Battery,Soc,func() (float64, error)" -t "api.BatteryCapacity,Capacity,func() float64" -t "api.BatteryController,SetBatteryMode,func(api.BatteryMode) error"
//go:generate go run ../cmd/tools/decorate.go -f decorateMeter -b api.Meter -t "api.MeterEnergy,TotalEnergy,func() (float64, error)" -t "api.PhaseCurrents,Currents,func() (float64, float64, float64, error)" -t "api.PhaseVoltages,Voltages,func() (float64, float64, float64, error)" -t "api.PhasePowers,Powers,func() (float64, float64, float64, error)" -t "api.Battery,Soc,func() (float64, error)" -t "api.BatteryCapacity,Capacity,func() float64" -t "api.BatteryMaxACPower,MaxACPower,func() float64" -t "api.BatteryController,SetBatteryMode,func(api.BatteryMode) error"
// NewConfigurableFromConfig creates api.Meter from config
func NewConfigurableFromConfig(ctx context.Context, other map[string]interface{}) (api.Meter, error) {
@ -26,6 +26,7 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]interface{}
// battery
capacity `mapstructure:",squash"`
maxpower `mapstructure:",squash"`
battery `mapstructure:",squash"`
Soc *provider.Config // optional
LimitSoc *provider.Config // optional
@ -82,7 +83,7 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]interface{}
batModeS = cc.battery.ModeController(modeS)
}
res := m.Decorate(energyG, currentsG, voltagesG, powersG, socG, cc.capacity.Decorator(), batModeS)
res := m.Decorate(energyG, currentsG, voltagesG, powersG, socG, cc.capacity.Decorator(), cc.maxpower.Decorator(), batModeS)
return res, nil
}
@ -107,10 +108,11 @@ func (m *Meter) Decorate(
voltages func() (float64, float64, float64, error),
powers func() (float64, float64, float64, error),
batterySoc func() (float64, error),
capacity func() float64,
batteryCapacity func() float64,
batteryMaxACPower func() float64,
setBatteryMode func(api.BatteryMode) error,
) api.Meter {
return decorateMeter(m, totalEnergy, currents, voltages, powers, batterySoc, capacity, setBatteryMode)
return decorateMeter(m, totalEnergy, currents, voltages, powers, batterySoc, batteryCapacity, batteryMaxACPower, setBatteryMode)
}
// CurrentPower implements the api.Meter interface

View file

@ -70,7 +70,7 @@ func NewMovingAverageFromConfig(ctx context.Context, other map[string]interface{
powers = m.Powers
}
return meter.Decorate(totalEnergy, currents, voltages, powers, batterySoc, cc.Meter.capacity.Decorator(), nil), nil
return meter.Decorate(totalEnergy, currents, voltages, powers, batterySoc, cc.Meter.capacity.Decorator(), nil, nil), nil
}
type MovingAverage struct {

File diff suppressed because it is too large Load diff

View file

@ -138,7 +138,7 @@ func NewOpenWBFromConfig(other map[string]interface{}) (api.Meter, error) {
return nil, err
}
res := m.Decorate(nil, currents, nil, nil, soc, capacity, nil)
res := m.Decorate(nil, currents, nil, nil, soc, capacity, nil, nil)
return res, nil
}

View file

@ -107,7 +107,6 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
"batterydischargecontrol": {"POST", "/batterydischargecontrol/{value:[01truefalse]+}", boolHandler(site.SetBatteryDischargeControl, site.GetBatteryDischargeControl)},
"batterygridcharge": {"POST", "/batterygridchargelimit/{value:-?[0-9.]+}", floatPtrHandler(pass(site.SetBatteryGridChargeLimit), site.GetBatteryGridChargeLimit)},
"batterygridchargedelete": {"DELETE", "/batterygridchargelimit", floatPtrHandler(pass(site.SetBatteryGridChargeLimit), site.GetBatteryGridChargeLimit)},
"maxgridsupply": {"POST", "/maxgridsupply/{value:[0-9.]+}", floatHandler(site.SetMaxGridSupplyWhileBatteryCharging, site.GetMaxGridSupplyWhileBatteryCharging)},
"prioritysoc": {"POST", "/prioritysoc/{value:[0-9.]+}", floatHandler(site.SetPrioritySoc, site.GetPrioritySoc)},
"residualpower": {"POST", "/residualpower/{value:-?[0-9.]+}", floatHandler(site.SetResidualPower, site.GetResidualPower)},
"smartcost": {"POST", "/smartcostlimit/{value:-?[0-9.]+}", updateSmartCostLimit(site)},

View file

@ -18,6 +18,7 @@ params:
- 2
- name: capacity
advanced: true
- name: maxacpower
render: |
type: custom
{{- if eq .usage "grid" }}
@ -174,4 +175,5 @@ render: |
type: writemultiple
encoding: uint16
capacity: {{ .capacity }} # kWh
maxacpower: {{ .maxacpower }} # W
{{- end }}

View file

@ -13,6 +13,7 @@ params:
id: 1
- name: capacity
advanced: true
- name: maxacpower
render: |
type: custom
{{- if eq .usage "grid" }}
@ -213,4 +214,5 @@ render: |
type: writemultiple
decode: uint16
capacity: {{ .capacity }} # kWh
maxacpower: {{ .maxacpower }} # W
{{- end }}

View file

@ -25,6 +25,7 @@ params:
id: 1
- name: capacity
advanced: true
- name: maxacpower
render: |
type: custom
{{- if eq .usage "grid" }}
@ -175,4 +176,5 @@ render: |
type: writemultiple
decode: uint16
capacity: {{ .capacity }} # kWh
maxacpower: {{ .maxacpower }} # W
{{- end }}

View file

@ -33,6 +33,7 @@ params:
advanced: true
- name: capacity
advanced: true
- name: maxacpower
# battery control
- name: watchdog
type: duration
@ -110,4 +111,5 @@ render: |
type: writemultiple
encoding: {{ if (eq .endianness "big") }}float32{{ else }}float32s{{ end }}
capacity: {{ .capacity }} # kWh
maxacpower: {{ .maxacpower }} # W
{{- end }}

View file

@ -33,6 +33,7 @@ params:
advanced: true
- name: capacity
advanced: true
- name: maxacpower
# battery control
- name: minsoc
type: number
@ -86,6 +87,7 @@ render: |
type: writemultiple
encoding: {{ if (eq .endianness "big") }}float32{{ else }}float32s{{ end }}
capacity: {{ .capacity }} # kWh
maxacpower: {{ .maxacpower }} # W
minsoc: {{ .minsoc }} # %
maxsoc: {{ .maxsoc }} # %
{{- end }}

View file

@ -18,6 +18,7 @@ params:
de: Der ModbusTCP Server muss aktiviert sein.
- name: capacity
advanced: true
- name: maxacpower
- name: watchdog
type: duration
default: 30s
@ -183,4 +184,5 @@ render: |
type: writemultiple
decode: uint32
capacity: {{ .capacity }} # kWh
maxacpower: {{ .maxacpower }} # W
{{- end }}

View file

@ -23,6 +23,7 @@ params:
- name: timeout
- name: capacity
advanced: true
- name: maxacpower
- name: watchdog
type: duration
default: 60s
@ -206,4 +207,5 @@ render: |
type: writemultiple
encoding: float32s
capacity: {{ .capacity }} # kWh
maxacpower: {{ .maxacpower }} # W
{{- end }}

View file

@ -13,6 +13,7 @@ params:
id: 1
- name: capacity
advanced: true
- name: maxacpower
render: |
type: custom
{{- if eq .usage "grid" }}
@ -129,4 +130,5 @@ render: |
type: input
decode: uint16
capacity: {{ .capacity }} # kWh
maxacpower: {{ .maxacpower }} # W
{{- end }}

View file

@ -13,6 +13,7 @@ params:
id: 1
- name: capacity
advanced: true
- name: maxacpower
render: |
type: custom
{{- if eq .usage "grid" }}
@ -117,4 +118,5 @@ render: |
type: input
decode: uint16
capacity: {{ .capacity }} # kWh
maxacpower: {{ .maxacpower }} # W
{{- end }}

View file

@ -13,6 +13,7 @@ params:
choice: ["tcpip", "rs485"]
- name: capacity
advanced: true
- name: maxacpower
render: |
type: custom
# sunspec model 203 (int+sf)/ 213 (float) meter
@ -124,4 +125,5 @@ render: |
- 124:ChaState
- 802:SoC
capacity: {{ .capacity }} # kWh
maxacpower: {{ .maxacpower }} # W
{{- end }}

View file

@ -92,9 +92,21 @@ params:
help:
de: Akkukapazität in kWh
en: Battery capacity in kWh
example: "50"
example: 50
type: float
usages: ["vehicle", "battery"]
- name: maxacpower
description:
de: Maximale AC Leistung des Hybrid-Wechselrichters
en: Maximum AC power of the hybrid inverter
help:
de: Maximale AC Leistung des Hybrid-Wechselrichters
en: Maximum AC power of the hybrid inverter
default: 0
example: 5000
type: float
usages: ["battery"]
advanced: true
- name: vin
description:
de: Fahrzeugidentifikationsnummer